Merge branch 'stable-2.14'

* stable-2.14:
  PolyGerrit: Update docs on install nodejs 6
  Don't fail in loadJARs when there are no jar files to load
  Fix build failure with Bazel 0.5rc3.

Change-Id: I466935303024a33ea3d1ac4084b2b75f55450bea
diff --git a/.bazelproject b/.bazelproject
index 41bb27f..e3a7a9c 100644
--- a/.bazelproject
+++ b/.bazelproject
@@ -18,3 +18,6 @@
 java_language_level: 8
 
 workspace_type: java
+
+build_flags:
+  --javacopt=-g
diff --git a/.mailmap b/.mailmap
index ebf2781..c35b2ec 100644
--- a/.mailmap
+++ b/.mailmap
@@ -1,28 +1,40 @@
 Adrian Görler <adrian.goerler@sap.com>                                                      Adrian Goerler <adrian.goerler@sap.com>
 Ahaan Ugale <ahaanugale@gmail.com>                                                          <augale@codeaurora.org>
 Alex Blewitt <alex.blewitt@gmail.com>                                                       <alex.blewitt@gs.com>
+Alex Blewitt <alex.blewitt@gmail.com>                                                       <alex.blewitt@credit-suisse.com>
 Alex Ryazantsev <alex.ryazantsev@gmail.com>                                                 alex <alex.ryazantsev@gmail.com>
 Alex Ryazantsev <alex.ryazantsev@gmail.com>                                                 alex.ryazantsev <alex.ryazantsev@gmail.com>
+Andrew Bonventre <andybons@chromium.org>                                                    <andybons@google.com>
 Becky Siegel <beckysiegel@google.com>                                                       beckysiegel <beckysiegel@google.com>
 Brad Larson <bklarson@gmail.com>                                                            <brad.larson@garmin.com>
-Bruce Zu <bruce.zu@sonymobile.com>                                                          <bruce.zu@sonyericsson.com>
+Bruce Zu <bruce.zu.run10@gmail.com>                                                         <bruce.zu@sonyericsson.com>
+Bruce Zu <bruce.zu.run10@gmail.com>                                                         <bruce.zu@sonymobile.com>
 Carlos Eduardo Baldacin <carloseduardo.baldacin@sonyericsson.com>                           carloseduardo.baldacin <carloseduardo.baldacin@sonyericsson.com>
+Dariusz Luksza <dluksza@collab.net>                                                         <dariusz@luksza.org>
 David Ostrovsky <david@ostrovsky.org>                                                       <d.ostrovsky@gmx.de>
+David Ostrovsky <david@ostrovsky.org>                                                       <david.ostrovsky@gmail.com>
+David Pursehouse <dpursehouse@collab.net>                                                   <david.pursehouse@sonymobile.com>
 Deniz Türkoglu <deniz@spotify.com>                                                          Deniz Türkoglu <deniz@spotify.com>
 Deniz Türkoglu <deniz@spotify.com>                                                          Deniz Turkoglu <deniz@spotify.com>
+Doug Kelly <dougk.ff7@gmail.com>                                                            <doug.kelly@garmin.com>
 Edwin Kempin <ekempin@google.com>                                                           Edwin Kempin <edwin.kempin@gmail.com>
 Edwin Kempin <ekempin@google.com>                                                           Edwin Kempin <edwin.kempin@sap.com>
 Edwin Kempin <ekempin@google.com>                                                           ekempin <ekempin@google.com>
 Eryk Szymanski <eryksz@gmail.com>                                                           <eryksz@google.com>
 Fredrik Luthander <fredrik.luthander@sonymobile.com>                                        <fredrik@gandaraj.com>
 Fredrik Luthander <fredrik.luthander@sonymobile.com>                                        <fredrik.luthander@sonyericsson.com>
-Gustaf Lundh <gustaf.lundh@sonymobile.com>                                                  <gustaf.lundh@sonyericsson.com>
+Gustaf Lundh <gustaflh@axis.com>                                                            <gustaf.lundh@axis.com>
+Gustaf Lundh <gustaflh@axis.com>                                                            <gustaf.lundh@sonyericsson.com>
+Gustaf Lundh <gustaflh@axis.com>                                                            <gustaf.lundh@sonymobile.com>
 Hugo Arès <hugo.ares@ericsson.com>                                                          Hugo Ares <hugo.ares@ericsson.com>
+Jacek Centkowski <jcentkowski@collab.net>                                                   <gemincia.programs@gmail.com>
+Jacek Centkowski <jcentkowski@collab.net>                                                   <geminica.programs@gmail.com>
 Jason Huntley <jhuntley@houghtonassociates.com>                                             jhuntley <jhuntley@houghtonassociates.com>
 Jiří Engelthaler <EngyCZ@gmail.com>                                                         <engycz@gmail.com>
 Joe Onorato <onoratoj@gmail.com>                                                            <joeo@android.com>
 Joel Dodge <dodgejoel@gmail.com>                                                            dodgejoel <dodgejoel@gmail.com>
 Johan Björk <jbjoerk@gmail.com>                                                             Johan Bjork <phb@spotify.com>
+JT Olds <hello@jtolds.com>                                                                  <jtolds@gmail.com>
 Lincoln Oliveira Campos Do Nascimento <lincoln.oliveiracamposdonascimento@sonyericsson.com> lincoln <lincoln.oliveiracamposdonascimento@sonyericsson.com>
 Luca Milanesio <luca.milanesio@gmail.com>                                                   <luca@gitent-scm.com>
 Magnus Bäck <baeck@google.com>                                                              <magnus.back@sonyericsson.com>
@@ -36,10 +48,15 @@
 Peter Jönsson <peter.joensson@gmail.com>                                                    Peter Jönsson <peter.joensson@gmail.com>
 Rafael Rabelo Silva <rafael.rabelosilva@sonyericsson.com>                                   rafael.rabelosilva <rafael.rabelosilva@sonyericsson.com>
 Richard Möhn <richard.moehn@posteo.de>                                                      <richard.moehn@fu-berlin.de>
+Sam Saccone <samccone@google.com>                                                           <samccone@gmail.com>
 Saša Živkov <sasa.zivkov@sap.com>                                                           Sasa Zivkov <sasa.zivkov@sap.com>
 Saša Živkov <sasa.zivkov@sap.com>                                                           Saša Živkov <zivkov@gmail.com>
+Saša Živkov <sasa.zivkov@sap.com>                                                           Sasa Zivkov <zivkov@gmail.com>
+Scott Dial <scott@scottdial.com>                                                            <geekmug@gmail.com>
 Shawn Pearce <sop@google.com>                                                               Shawn O. Pearce <sop@google.com>
 Sixin Li <sixin210@gmail.com>                                                               sixin li <sixin210@gmail.com>
+Sven Selberg <svense@axis.com>                                                              <sven.selberg@axis.com>
+Sven Selberg <svense@axis.com>                                                              <sven.selberg@sonymobile.com>
 Tom Wang <twang10@gmail.com>                                                                Tom <twang10@gmail.com>
 Tomas Westling <thomas.westling@sonyericsson.com>                                           thomas.westling <thomas.westling@sonyericsson.com>
 Ulrik Sjölin <ulrik.sjolin@sonyericsson.com>                                                <ulrik.sjolin@gmail.com>
diff --git a/.settings/org.eclipse.jdt.ui.prefs b/.settings/org.eclipse.jdt.ui.prefs
index 3d5f5f6..18c15dd 100644
--- a/.settings/org.eclipse.jdt.ui.prefs
+++ b/.settings/org.eclipse.jdt.ui.prefs
@@ -2,4 +2,4 @@
 org.eclipse.jdt.ui.ignorelowercasenames=true
 org.eclipse.jdt.ui.ondemandthreshold=99
 org.eclipse.jdt.ui.staticondemandthreshold=99
-org.eclipse.jdt.ui.text.custom_code_templates=<?xml version\="1.0" encoding\="UTF-8" standalone\="no"?><templates/>
+org.eclipse.jdt.ui.text.custom_code_templates=<?xml version\="1.0" encoding\="UTF-8" standalone\="no"?><templates><template autoinsert\="true" context\="gettercomment_context" deleted\="false" description\="Comment for getter method" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.gettercomment" name\="gettercomment">/**\n * @return the ${bare_field_name}\n */</template><template autoinsert\="true" context\="settercomment_context" deleted\="false" description\="Comment for setter method" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.settercomment" name\="settercomment">/**\n * @param ${param} the ${bare_field_name} to set\n */</template><template autoinsert\="true" context\="constructorcomment_context" deleted\="false" description\="Comment for created constructors" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.constructorcomment" name\="constructorcomment">/**\n * ${tags}\n */</template><template autoinsert\="false" context\="filecomment_context" deleted\="false" description\="Comment for created Java files" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.filecomment" name\="filecomment">// Copyright (C) ${year} The Android Open Source Project\n//\n// Licensed under the Apache License, Version 2.0 (the "License");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n// http\://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an "AS IS" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.</template><template autoinsert\="true" context\="typecomment_context" deleted\="false" description\="Comment for created types" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.typecomment" name\="typecomment">/**\n * @author ${user}\n *\n * ${tags}\n */</template><template autoinsert\="true" context\="fieldcomment_context" deleted\="false" description\="Comment for fields" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.fieldcomment" name\="fieldcomment">/**\n * \n */</template><template autoinsert\="true" context\="methodcomment_context" deleted\="false" description\="Comment for non-overriding methods" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.methodcomment" name\="methodcomment">/**\n * ${tags}\n */</template><template autoinsert\="true" context\="overridecomment_context" deleted\="false" description\="Comment for overriding methods" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.overridecomment" name\="overridecomment">/* (non-Javadoc)\n * ${see_to_overridden}\n */</template><template autoinsert\="true" context\="delegatecomment_context" deleted\="false" description\="Comment for delegate methods" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.delegatecomment" name\="delegatecomment">/**\n * ${tags}\n * ${see_to_target}\n */</template><template autoinsert\="false" context\="newtype_context" deleted\="false" description\="Newly created files" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.newtype" name\="newtype">${filecomment}\n\n${package_declaration}\n\n${typecomment}\n${type_declaration}</template><template autoinsert\="false" context\="classbody_context" deleted\="false" description\="Code in new class type bodies" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.classbody" name\="classbody"/><template autoinsert\="true" context\="interfacebody_context" deleted\="false" description\="Code in new interface type bodies" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.interfacebody" name\="interfacebody">\n</template><template autoinsert\="true" context\="enumbody_context" deleted\="false" description\="Code in new enum type bodies" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.enumbody" name\="enumbody">\n</template><template autoinsert\="true" context\="annotationbody_context" deleted\="false" description\="Code in new annotation type bodies" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.annotationbody" name\="annotationbody">\n</template><template autoinsert\="false" context\="catchblock_context" deleted\="false" description\="Code in new catch blocks" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.catchblock" name\="catchblock">${exception_var}.printStackTrace();</template><template autoinsert\="false" context\="methodbody_context" deleted\="false" description\="Code in created method stubs" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.methodbody" name\="methodbody">${body_statement}</template><template autoinsert\="false" context\="constructorbody_context" deleted\="false" description\="Code in created constructor stubs" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.constructorbody" name\="constructorbody">${body_statement}</template><template autoinsert\="true" context\="getterbody_context" deleted\="false" description\="Code in created getters" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.getterbody" name\="getterbody">return ${field};</template><template autoinsert\="true" context\="setterbody_context" deleted\="false" description\="Code in created setters" enabled\="true" id\="org.eclipse.jdt.ui.text.codetemplates.setterbody" name\="setterbody">${field} \= ${param};</template></templates>
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index f64f739..aa27f2b 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -497,7 +497,7 @@
 
 Deletion of references is also possible if `Push` with the force option
 is granted, however that includes the permission to fast-forward and
-force-update references to exiting and new commits. Being able to push
+force-update references to existing and new commits. Being able to push
 references for new commits is bad if bypassing of code review must be
 prevented.
 
@@ -850,6 +850,15 @@
 Note that this permission is named `submitAs` in the `project.config`
 file.
 
+[[category_view_private_changes]]
+=== View Private Changes
+
+This category permits users to view all private changes.
+
+The change owner and any explicitly added reviewers can always see
+private changes (even without having the `View Private Changes` access
+right assigned).
+
 [[category_view_drafts]]
 === View Drafts
 
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index ebe6ddb..c029031 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -420,8 +420,14 @@
 the "Switch Account" link is displayed next to "Sign Out".
 +
 When `auth.type` does not normally enable this URL administrators may
-set this to `login/` or `$canonicalWebUrl/login`, allowing users to
-begin a new web session.
+set this to `login/`, allowing users to begin a new web session. This value
+is used as an href in PolyGerrit and the GWT UI, so absolute URLs like
+`https://someotherhost/login` work as well.
++
+If a ${path} parameter is included, then PolyGerrit will substitute the
+currently viewed path in the link. Be aware that this path will include
+a leading slash, so a value like this might be appropriate: `/login${path}`.
+Note: in the GWT UI this substitution for ${path} is *always* `/`.
 
 [[auth.cookiePath]]auth.cookiePath::
 +
@@ -1015,7 +1021,7 @@
 +
 If 0 the update polling is disabled.
 +
-Default is 30 seconds.
+Default is 5 minutes.
 
 [[change.allowBlame]]change.allowBlame::
 +
@@ -1127,6 +1133,16 @@
 Default is "Reply and score". In the user interface it becomes "Reply
 and score (Shortcut: a)".
 
+[[change.robotCommentSizeLimit]]change.robotCommentSizeLimit::
++
+Maximum allowed size of a robot comment that will be accepted. Robot comments
+which exceed the indicated size will be rejected on addition. The specified
+value is interpreted as the maximum size in bytes of the JSON representation of
+the robot comment. Common unit suffixes of 'k', 'm', or 'g' are supported.
+Zero or negative values allow robot comments of unlimited size.
++
+The default limit is 1024kB.
+
 [[changeCleanup]]
 === Section changeCleanup
 
@@ -2639,6 +2655,19 @@
 +
 Defaults to 1024.
 
+[[index.reindexAfterRefUpdate]]index.reindexAfterRefUpdate::
++
+Whether to reindex all affected open changes after a ref is updated. This
+includes reindexing all open changes to recompute the "mergeable" bit every time
+the destination branch moves, as well as reindexing changes to take into account
+new project configuration (e.g. label definitions).
++
+Leaving this enabled may result in fresher results, but may cause performance
+problems if there are lots of open changes on a project whose branches advance
+frequently.
++
+Defaults to true.
+
 ==== Lucene configuration
 
 Open and closed changes are indexed in separate indexes named
@@ -3855,6 +3884,13 @@
 Defaults to an empty string which adds <<sendemail.from,sendemail.from>> as
 Reply-To if inbound email is enabled and the review's author otherwise.
 
+[[sendemail.allowTLD]]sendemail.allowTLD::
++
+List of custom TLDs to allow sending emails to in addition to those specified
+in the link:http://data.iana.org/TLD/[IANA list].
++
+Defaults to an empty list, meaning no additional TLDs are allowed.
+
 [[site]]
 === Section site
 
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index 34f39c8..90b0a83 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -291,6 +291,20 @@
 check. If the `branchOrder` section is not defined then the mergeability of a
 change into other branches will not be done.
 
+[[reviewer-section]]
+=== reviewer section
+
+Defines config options to adjust a project's reviewer workflow such as enabling
+reviewers and CCs by email.
+
+[[reviewer.enableByEmail]]reviewer.enableByEmail::
++
+A boolean indicating if reviewers and CCs that do not currently have a Gerrit
+account can be added to a change by providing their email address.
+
+Default is `INHERIT`, which means that this property is inherited from
+the parent project. If the property is not set in any parent project, the
+default value is `FALSE`.
 
 [[file-groups]]
 == The file +groups+
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index 823424e..be6d025 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -154,8 +154,8 @@
 To format Java source code, Gerrit uses the
 link:https://github.com/google/google-java-format[`google-java-format`]
 tool (version 1.3), and to format Bazel BUILD and WORKSPACE files the
-link:https://github.com/bazelbuild/buildifier[`buildifier`] tool. These
-tools automatically apply format according to the style guides; this
+link:https://github.com/bazelbuild/buildifier[`buildifier`] tool (version 0.4.5).
+These tools automatically apply format according to the style guides; this
 streamlines code review by reducing the need for time-consuming, tedious,
 and contentious discussions about trivial issues like whitespace.
 
@@ -341,7 +341,8 @@
 We have created a
 link:https://bugs.chromium.org/p/gerrit/issues/list?can=2&q=label%3AStarterProject[StarterProject]
 category in the issue tracker and try to assign easy hack projects to it. If in
-doubt, do not hesitate to ask on the developer mailing list.
+doubt, do not hesitate to ask on the developer
+link:https://groups.google.com/forum/#!forum/repo-discuss[mailing list].
 
 GERRIT
 ------
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index 39fa333..9743779 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -10,8 +10,8 @@
 [[setup]]
 == Project Setup
 
-In your Eclipse installation's `eclipse.ini` file, add the following line in
-the `vmargs` section:
+In your Eclipse installation's link:https://wiki.eclipse.org/Eclipse.ini[`eclipse.ini`] file,
+add the following line in the `vmargs` section:
 
 ----
   -DmaxCompiledUnitsAtOnce=10000
@@ -30,7 +30,8 @@
   AutoAnnotation_Commands_named cannot be resolved to a type
 ----
 
-In Eclipse, choose 'Import existing project' and select the `gerrit` project
+First, generate the Eclipse project by running the `tools/eclipse/project.py` script.
+Then, in Eclipse, choose 'Import existing project' and select the `gerrit` project
 from the current working directory.
 
 Expand the `gerrit` project, right-click on the `eclipse-out` folder, select
diff --git a/Documentation/dev-note-db.txt b/Documentation/dev-note-db.txt
index dd3b316..e5bd54e 100644
--- a/Documentation/dev-note-db.txt
+++ b/Documentation/dev-note-db.txt
@@ -32,8 +32,8 @@
 - Storing the rest of account data is a work in progress.
 - Storing group data is a work in progress.
 
-To match the current configuration of `googlesource.com`, paste the following
-config snippet in your `gerrit.config`:
+To use a configuration similar to the current state of `googlesource.com`, paste
+the following config snippet in your `gerrit.config`:
 
 ----
 [noteDb "changes"]
@@ -43,6 +43,12 @@
   disableReviewDb = true
 ----
 
+This is the configuration that will be produced if you enable experimental
+NoteDb support on a new site with `init`.
+
+`googlesource.com` additionally uses `fuseUpdates = true`, because its repo
+backend supports atomic multi-ref transactions. Native JGit doesn't, so setting
+this option on a dev server would fail.
 
 For an example NoteDb change, poke around at this one:
 ----
@@ -96,6 +102,11 @@
   implementation of the `rebuild-note-db` program does not do this. +
   In this phase, it would be possible to delete the Changes tables out from
   under a running server with no effect.
+- `noteDb.changes.fuseUpdates=true`: Code and meta updates within a single
+  repository are fused into a single atomic `BatchRefUpdate`, so they either
+  all succeed or all fail. This mode is only possible on a backend that
+  supports atomic ref updates, which notably excludes the default file repository
+  backend.
 
 [[migration]]
 == Migration
@@ -135,3 +146,34 @@
 - Run a Flume to migrate all existing changes to NoteDb primary. (Also
   closed-source, but basically just a wrapper around `PrimaryStorageMigrator`.)
 - Turn off access to ReviewDb changes tables.
+
+== Configuration
+
+This section describes general configuration for the NoteDb backend that is not
+directly related to the migration process. These options will continue to be
+supported after the migration is complete and the migration-related options are
+obsolete.
+
+[[noteDb.retryMaxWait]]noteDb.retryMaxWait::
++
+Maximum time to wait between attempts to retry update operations when one
+attempt fails due to contention (aka lock failure) on the underlying ref
+storage. Operations are retried with exponential backoff, plus some random
+jitter, until the interval reaches this limit. After that, retries continue to
+occur after a fixed timeout (plus jitter), up to
+link:#noteDb.retryTimeout[`noteDb.retryTimeout`].
++
+Only applicable when `noteDb.changes.fuseUpdates=true`.
++
+Defaults to 5 seconds; unit suffixes are supported, and assumes milliseconds if
+not specified.
+
+[[noteDb.retryTimeout]]noteDb.retryTimeout::
++
+Total timeout for retrying update operations when one attempt fails due to
+contention (aka lock failure) on the underlying ref storage.
++
+Only applicable when `noteDb.changes.fuseUpdates=true`.
++
+Defaults to 20 seconds; unit suffixes are supported, and assumes milliseconds if
+not specified.
diff --git a/Documentation/dev-plugin-pg-styling.txt b/Documentation/dev-plugin-pg-styling.txt
new file mode 100644
index 0000000..618d984
--- /dev/null
+++ b/Documentation/dev-plugin-pg-styling.txt
@@ -0,0 +1,61 @@
+= Gerrit Code Review - PolyGerrit Plugin Styling
+
+CAUTION: Work in progress. Hard hat area. +
+This document will be populated with details along with implementation. +
+link:https://groups.google.com/d/topic/repo-discuss/vb8WJ4m0hK0/discussion[Join the discussion.]
+
+== Plugin styles
+
+Plugins may provide link:https://www.polymer-project.org/2.0/docs/devguide/style-shadow-dom#style-modules[Polymer style modules] for UI CSS-based customization.
+
+PolyGerrit UI implements number of styling endpoints, which apply CSS mixins link:https://tabatkins.github.io/specs/css-apply-rule/[using @apply] to its direct contents.
+
+NOTE: Only items (ie CSS properties and mixin targets) documented here are guaranteed to work in the long term, since they are covered by integration tests. +
+When there is a need to add new property or endpoint, please link:https://bugs.chromium.org/p/gerrit/issues/entry?template=PolyGerrit%20Issue[file a bug] stating your usecase to track and maintain for future releases.
+
+Plugin should be html-based and imported following PolyGerrit's link:dev-plugins-pg.html#loading[dev guide].
+
+Plugin should provide Style Module, for example:
+
+``` html
+  <dom-module id="some-style">
+    <style>
+      :root {
+        --css-mixin-name: {
+          property: value;
+        }
+      }
+    </style>
+  </dom-module>
+```
+
+Plugin should register style module with a styling endpoint using `Plugin.prototype.registerStyleModule(endpointName, styleModuleName)`, for example:
+
+``` js
+  Gerrit.install(function(plugin) {
+    plugin.registerStyleModule('some-endpoint', 'some-style');
+  });
+```
+
+== Available styling endpoints
+=== change-metadata
+Following custom css mixins are recognized:
+
+* `--change-metadata-assignee`
++
+is applied to `gr-change-metadata section.assignee`
+* `--change-metadata-label-status`
++
+is applied to `gr-change-metadata section.labelStatus`
+* `--change-metadata-strategy`
++
+is applied to `gr-change-metadata section.strategy`
+* `--change-metadata-topic`
++
+is applied to `gr-change-metadata section.topic`
+
+Following CSS properties have link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html[long-term support via integration test]:
+
+* `display`
++
+can be set to `none` to hide a section.
diff --git a/Documentation/dev-plugins-pg.txt b/Documentation/dev-plugins-pg.txt
new file mode 100644
index 0000000..77748a4
--- /dev/null
+++ b/Documentation/dev-plugins-pg.txt
@@ -0,0 +1,25 @@
+= Gerrit Code Review - PolyGerrit Plugin Development
+
+CAUTION: Work in progress. Hard hat area. +
+This document will be populated with details along with implementation. +
+link:https://groups.google.com/d/topic/repo-discuss/vb8WJ4m0hK0/discussion[Join the discussion.]
+
+[[loading]]
+== Plugin loading and initialization
+
+link:https://gerrit-review.googlesource.com/Documentation/js-api.html#_entry_point[Entry point] for the plugin and the loading method is based on link:http://w3c.github.io/webcomponents/spec/imports/[HTML Imports] spec.
+
+* Plugin provides index.html, similar to link:https://gerrit-review.googlesource.com/Documentation/dev-plugins.html#deployment[.js Web UI plugins]
+* index.html contains a `dom-module` tag with a script that uses `Gerrit.install()`.
+* PolyGerrit imports index.html along with all required resources defined in it (fonts, styles, etc)
+* For standalone plugins, the entry point file is a `pluginname.html` file located in `gerrit-site/plugins` folder, where pluginname is an alphanumeric plugin name.
+
+Here's a sample `myplugin.html`:
+
+``` html
+<dom-module id="my-plugin">
+  <script>
+    Gerrit.install(function() { console.log('Ready.'); });
+  </script>
+</dom-module>
+```
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 3692dfb7..7da501a 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -712,6 +712,99 @@
     }
 ====
 
+[[command_options]]
+=== Command Options ===
+
+Plugins can provide additional options for each of the gerrit ssh and the
+REST API commands by implementing the DynamicBean interface and registering
+it to a command class name in the plugin module's `configure()` method. The
+plugin's name will be prepended to the name of each @Option annotation found
+on the DynamicBean object provided by the plugin. The example below shows a
+plugin that adds an option to log a value from the gerrit 'ban-commits'
+ssh command.
+
+[source, java]
+----
+public class SshModule extends AbstractModule {
+  private static final Logger log = LoggerFactory.getLogger(SshModule.class);
+
+  @Override
+  protected void configure() {
+    bind(DynamicOptions.DynamicBean.class)
+        .annotatedWith(Exports.named(
+        com.google.gerrit.sshd.commands.BanCommitCommand.class))
+        .to(BanOptions.class);
+  }
+
+  public static class BanOptions implements DynamicOptions.DynamicBean {
+    @Option(name = "--log", aliases = { "-l" }, usage = "Say Hello in the Log")
+    private void parse(String arg) {
+      log.error("Say Hello in the Log " + arg);
+    }
+  }
+----
+
+[[query_attributes]]
+=== Query Attributes ===
+
+Plugins can provide additional attributes to be returned in Gerrit queries by
+implementing the ChangeAttributeFactory interface and registering it to the
+ChangeQueryProcessor.ChangeAttributeFactory class in the plugin module's
+'configure()' method. The new attribute(s) will be output under a "plugin"
+attribute in the change query output.
+
+The example below shows a plugin that adds two attributes ('exampleName' and
+'changeValue'), to the change query output.
+
+[source, java]
+----
+public class Module extends AbstractModule {
+  @Override
+  protected void configure() {
+    bind(ChangeAttributeFactory.class)
+        .annotatedWith(Exports.named("example"))
+        .to(AttributeFactory.class);
+  }
+}
+
+public class AttributeFactory implements ChangeAttributeFactory {
+
+  public class PluginAttribute extends PluginDefinedInfo {
+    public String exampleName;
+    public String changeValue;
+
+    public PluginAttribute(ChangeData c) {
+      this.exampleName = "Attribute Example";
+      this.changeValue = Integer.toString(c.getId().get());
+    }
+  }
+
+  @Override
+  public PluginDefinedInfo create(ChangeData c, ChangeQueryProcessor qp, String plugin) {
+    return new PluginAttribute(c);
+  }
+}
+----
+
+Example
+----
+
+ssh -p 29418 localhost gerrit query "change:1" --format json
+
+Output:
+
+{
+   "url" : "http://localhost:8080/1",
+   "plugins" : [
+      {
+         "name" : "myplugin-name",
+         "exampleName" : "Attribute Example",
+         "changeValue" : "1"
+      }
+   ],
+    ...
+}
+----
 
 [[simple-configuration]]
 == Simple Configuration in `gerrit.config`
@@ -1217,6 +1310,7 @@
   @Override
   public void onPluginLoad() {
     Plugin.get().panel(GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK,
+        "my_panel_name",
         new Panel.EntryPoint() {
           @Override
           public void onLoad(Panel panel) {
@@ -1228,6 +1322,23 @@
 }
 ----
 
+Change Screen panel ordering may be specified in the
+project config. Values may be either "plugin name" or
+"plugin name"."panel name".
+Panels not specified in the config will be added
+to the end in load order. Panels specified in the config that
+are not found will be ignored.
+
+Example config:
+----
+[extension-panels "CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK"]
+        panel = helloworld.change_id
+        panel = myotherplugin
+        panel = myplugin.my_panel_name
+----
+
+
+
 [[actions]]
 === Actions
 
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt
index 2a857b2..5e07fc7 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -16,8 +16,8 @@
 == Gerrit Release Type
 
 Here are some guidelines on release approaches depending on the
-type of release you want to make (`stable-fix`, `stable`, `RC0`,
-`RC1`...).
+type of release you want to make (`stable-fix`, `stable`, `rc0`,
+`rc1`...).
 
 [[stable]]
 === Stable
@@ -27,19 +27,19 @@
 
 * Propose the release with any plans/objectives to the mailing list
 
-* Create a Gerrit `RC0`
+* Create a Gerrit `rc0`
 
-* If needed create a Gerrit `RC1`
+* If needed create a Gerrit `rc1`
 
 [NOTE]
 You may let in a few features to this release
 
-* If needed create a Gerrit `RC2`
+* If needed create a Gerrit `rc2`
 
 [NOTE]
 There should be no new features in this release, only bug fixes
 
-* Finally create the `stable` release (no `RC`)
+* Finally create the `stable` release (no `rc`)
 
 
 === Stable-Fix
@@ -75,7 +75,6 @@
 
 To create a Gerrit release the following steps have to be done:
 
-. link:#subproject[Release Subprojects]
 . link:#build-gerrit[Build the Gerrit Release]
 . link:#publish-gerrit[Publish the Gerrit Release]
 .. link:#publish-to-maven-central[Publish the Gerrit artifacts to Maven Central]
@@ -90,34 +89,10 @@
 . link:#merge-stable[Merge `stable` into `master`]
 
 
-[[subproject]]
-=== Release Subprojects
-
-The subprojects to be released are:
-
-* `gwtjsonrpc`
-* `gwtorm`
-* `prolog-cafe`
-
-For each subproject do:
-
-* Check the dependency to the Subproject in the Gerrit parent `pom.xml`:
-+
-If a `SNAPSHOT` version of the subproject is referenced the subproject
-needs to be released so that Gerrit can reference a released version of
-the subproject.
-
-* link:dev-release-subproject.html#make-snapshot[Make a snapshot and test it]
-* link:dev-release-subproject.html#prepare-release[Prepare the Release]
-* link:dev-release-subproject.html#publish-release[Publish the Release]
-
-* Update the `artifact`, `sha1`, and `src_sha1` values in the `maven_jar`
-for the Subproject in `WORKSPACE` to the released version.
-
 [[update-versions]]
 === Update Versions and Create Release Tag
 
-Before doing the release build, the `GERRIT_VERSION` in the `VERSION`
+Before doing the release build, the `GERRIT_VERSION` in the `version.bzl`
 file must be updated, e.g. change it from `2.5-SNAPSHOT` to `2.5`.
 
 In addition the version must be updated in a number of pom.xml files.
@@ -383,7 +358,7 @@
 Use the `version` tool to set the version in the `version.bzl` file:
 
 ----
- ./tools/version.py 2.11-SNAPSHOT
+ ./tools/version.py 2.6-SNAPSHOT
 ----
 
 Verify that the changes made by the tool are sane, then commit them, push
diff --git a/Documentation/dev-stars.txt b/Documentation/dev-stars.txt
index 553ac5b..1fb871a 100644
--- a/Documentation/dev-stars.txt
+++ b/Documentation/dev-stars.txt
@@ -61,6 +61,19 @@
 
 The ignore star is represented by the special star label 'ignore'.
 
+[[mute-star]]
+== Mute Star
+
+If the "mute/<patchset_id>"-star is set by a user, and <patchset_id>
+matches the current patch set, the change is always reported as "reviewed"
+in the ChangeInfo.
+
+This allows users to "de-highlight" changes in a dashboard until a new
+patchset has been uploaded.
+
+The ChangeInfo muted-field will show if the change is currently in a
+mute state.
+
 [[query-stars]]
 == Query Stars
 
diff --git a/Documentation/intro-quick.txt b/Documentation/intro-quick.txt
index c6dad5b..a827bbd 100644
--- a/Documentation/intro-quick.txt
+++ b/Documentation/intro-quick.txt
@@ -1,8 +1,7 @@
 = Gerrit Code Review - A Quick Introduction
 
 Gerrit is a web-based code review tool built on top of the git version
-control system, but if you've got as far as reading this guide then
-you probably already know that. The purpose of this introduction is to
+control system. The purpose of this introduction is to
 allow you to answer the question, is Gerrit the right tool for me?
 Will it fit in my work flow and in my organization?
 
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index 5e6fe59..9aa0a3b 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -482,9 +482,66 @@
   $ git push origin HEAD:refs/heads/master -o topic=multi-master
 ----
 
+[[private-changes]]
+== Private Changes
+
+Private changes are changes that are only visible to their owners and
+reviewers. Private changes are useful in a number of cases:
+
+* You want to check what the change looks before formal review starts.
+  By marking the change private without reviewers, nobody can't
+  prematurely comment on your changes.
+
+* You want to use Gerrit to sync data between different devices. By
+  creating a private throwaway change without reviewers, you can push
+  from one device, and fetch to another device.
+
+* You want to do code review on a change that has sensitive
+  aspects. By reviewing a security fix in a private change,
+  outsiders can't discover the fix before it is pushed out. Even after
+  merging the change, the review can be kept private.
+
+To create a private change, you push it with the `private` option.
+
+.Push a private change
+----
+  $ git commit
+  $ git push origin HEAD:refs/for/master%private
+----
+
+The change will remain private on subsequent pushes until you specify
+the `remove-private` option. Alternatively, the web UI provides buttons
+to mark a change private and non-private again.
+
+When pushing a private change with a commit that is authored by another
+user, the other user will not be automatically added as a reviewer and
+must be explicitly added.
+
+For CI systems that must verify private changes, a special permission
+can be granted
+(link:access-control.html#category_view_private_changes[View Private Changes]).
+In that case, care should be taken to prevent the CI system from
+exposing secret details.
+
+[[ignore]]
+== Ignoring and Muting Changes
+
+Changes can be ignored, which means they will not appear in the 'Incoming
+Reviews' dashboard and any related email notifications will be suppressed.
+This can be useful when you are added as a reviewer to a change on which
+you do not actively participate in the review, but do not want to completely
+remove yourself.
+
+Alternatively, rather than completely ignoring the change, it can be muted.
+Muting a change means it will always be marked as "reviewed" in dashboards,
+until a new patch set is uploaded.
+
 [[drafts]]
 == Working with Drafts
 
+Drafts is a deprecated feature and will be removed soon. Consider using
+private changes instead.
+
 Changes can be uploaded as drafts. By default draft changes are only
 visible to the change owner. This gives you the possibility to have
 some staging before making your changes visible to the reviewers. Draft
@@ -716,6 +773,12 @@
 and `Edit Config` buttons on the project screen, and the `Follow-Up`
 button on the change screen).
 
+- [[publish-comments-on-push]]`Publish Draft Comments When a Change Is Updated by Push`:
++
+Whether to publish any outstanding draft comments by default when pushing
+updates to open changes. This preference just sets the default; the behavior can
+still be overridden using a link:user-upload.html#publish-comments[push option].
+
 - [[use-flash]]`Use Flash Clipboard Widget`:
 +
 Whether the Flash clipboard widget should be used. If enabled and the Flash
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 091dbb3..e90e3e5 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -90,6 +90,9 @@
 * `notedb/auto_rebuild_latency`: NoteDb auto-rebuilding latency by table.
 * `notedb/auto_rebuild_failure_count`: NoteDb auto-rebuilding attempts that
 failed by table.
+* `notedb/external_id_update_count`: Total number of external ID updates.
+* `notedb/read_all_external_ids_latency`: Latency for reading all
+external ID's from NoteDb.
 
 === Reviewer Suggestion
 
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 13fca66..fbfd5e4 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -1248,6 +1248,7 @@
     "size_bar_in_change_table": true,
     "review_category_strategy": "ABBREV",
     "mute_common_path_prefixes": true,
+    "publish_comments_on_push": true,
     "default_base_for_merges": "FIRST_PARENT",
     "my": [
       {
@@ -1361,6 +1362,7 @@
     "size_bar_in_change_table": true,
     "review_category_strategy": "NAME",
     "diff_view": "SIDE_BY_SIDE",
+    "publish_comments_on_push": true,
     "mute_common_path_prefixes": true,
     "my": [
       {
@@ -2643,6 +2645,9 @@
 The base which should be pre-selected in the 'Diff Against' drop-down
 list when the change screen is opened for a merge commit.
 Allowed values are `AUTO_MERGE` and `FIRST_PARENT`.
+|`publish_comments_on_push`     |not set if `false`|
+Whether to link:user-upload.html#publish-comments[publish draft comments] on
+push by default.
 |============================================
 
 [[preferences-input]]
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 69afaa5..75d31d2c 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -2107,6 +2107,195 @@
   }
 ----
 
+[[set-work-in-pogress]]
+== Set Work-In-Progress
+--
+'POST /changes/link:#change-id[\{change-id\}]/wip'
+--
+
+Marks the change as not ready for review yet.
+
+The request body does not need to include a
+link:#work-in-progress-input[WorkInProgressInput] entity if no review comment
+is added. Actions that create a new patch set in a WIP change default to
+notifying *OWNER* instead of *ALL*.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/wip HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "message": "Refactoring needs to be done before we can proceed here."
+  }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+----
+
+[[set-ready-for-review]]
+== Set Ready-For-Review
+--
+'POST /changes/link:#change-id[\{change-id\}]/ready'
+--
+
+Marks the change as ready for review (set WIP property to false).
+
+Activates notifications of reviewer. The request body does not need
+to include a link:#work-in-progress-input[WorkInProgressInput] entity
+if no review comment is added.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/ready HTTP/1.0
+  Content-Type: application/json;charset=UTF-8
+
+  {
+    "message": "Refactoring is done."
+  }
+
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+----
+
+[[mark-private]]
+=== Mark Private
+--
+'POST /changes/link:#change-id[\{change-id\}]/private'
+--
+
+Marks the change to be private. Changes may only be marked private by the
+owner or site administrators.
+
+A message can be specified in the request body inside a
+link:#private-input[PrivateInput] entity.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/private HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "message": "After this security fix has been released we can make it public now."
+  }
+----
+
+.Response
+----
+  HTTP/1.1 201 Created
+----
+
+If the change was already private the response is "`200 OK`".
+
+[[unmark-private]]
+=== Unmark Private
+--
+'DELETE /changes/link:#change-id[\{change-id\}]/private'
+--
+
+Marks the change to be non-private. Note users can only unmark own private
+changes.
+
+A message can be specified in the request body inside a
+link:#private-input[PrivateInput] entity.
+
+.Request
+----
+  DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/private HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "message": "This is a security fix that must not be public."
+  }
+----
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
+
+If the change was already not private, the response is "`409 Conflict`".
+
+Please note that some proxies prohibit request bodies for DELETE
+requests. In this case, if you want to set a message options, use a
+POST request:
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/private.delete HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "message": "This is a security fix that must not be public."
+  }
+----
+
+[[ignore]]
+=== Ignore
+--
+'PUT /changes/link:#change-id[\{change-id\}]/ignore'
+--
+
+Marks a change as ignored. The change will not be shown in the incoming
+reviews dashboard, and email notifications will be suppressed.
+
+.Request
+----
+  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/ignore HTTP/1.0
+----
+
+[[unignore]]
+=== Unignore
+--
+'PUT /changes/link:#change-id[\{change-id\}]/unignore'
+--
+
+Un-marks a change as ignored.
+
+.Request
+----
+  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/unignore HTTP/1.0
+----
+
+[[mute]]
+=== Mute
+--
+'PUT /changes/link:#change-id[\{change-id\}]/mute'
+--
+
+Marks a change as muted.
+
+This allows users to "de-highlight" changes in their dashboard until a new
+patch set is uploaded.
+
+This differs from the link:#ignore[ignore] endpoint, which will mute
+emails and hide the change from dashboard completely until it is
+link:#unignore[unignored] again.
+
+
+.Request
+----
+  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/mute HTTP/1.0
+----
+
+[[unmute]]
+=== Unmute
+--
+'PUT /changes/link:#change-id[\{change-id\}]/unmute'
+--
+
+Unmutes a change.
+
+.Request
+----
+  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/unmute HTTP/1.0
+----
+
 [[edit-endpoints]]
 == Change Edit Endpoints
 
@@ -2704,16 +2893,16 @@
 
   )]}'
   {
+    "input": "john.doe@example.com",
     "reviewers": [
       {
-        "input": "john.doe@example.com",
+        "_account_id": 1000096,
+        "name": "John Doe",
+        "email": "john.doe@example.com"
         "approvals": {
           "Verified": " 0",
           "Code-Review": " 0"
         },
-        "_account_id": 1000096,
-        "name": "John Doe",
-        "email": "john.doe@example.com"
       }
     ]
   }
@@ -2764,6 +2953,41 @@
   }
 ----
 
+If link:config-project-config.html#reviewer.enableByEmail[reviewer.enableByEmail] is set
+for the project, reviewers and CCs are not required to have a Gerrit account. If you POST
+an email address of a reviewer or CC then, they will be added to the change even if they
+don't have a Gerrit account.
+
+If this option is disabled, the request would fail with `400 Bad Request` if the email
+address can't be resolved to an active Gerrit account.
+
+Note that the name is optional so both "un.registered@reviewer.com" and
+"John Doe <un.registered@reviewer.com>" are valid inputs.
+
+Reviewers without Gerrit accounts can only be added on changes visible to anonymous users.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "reviewer": "John Doe <un.registered@reviewer.com>"
+  }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "input": "John Doe <un.registered@reviewer.com>"
+  }
+----
+
 [[delete-reviewer]]
 === Delete Reviewer
 --
@@ -3311,6 +3535,11 @@
 The review must be provided in the request body as a
 link:#review-input[ReviewInput] entity.
 
+A review cannot be set on a change edit. Trying to post a review for a
+change edit fails with `409 Conflict`.
+
+This API method can be used to set labels:
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/review HTTP/1.0
@@ -3346,8 +3575,9 @@
   }
 ----
 
-As response a link:#review-info[ReviewInfo] entity is returned that
-describes the applied labels.
+As response a link:#review-result[ReviewResult] entity is returned that
+describes the applied labels and any added reviewers (e.g. yourself,
+if you set a label but weren't previously a reviewer on this CL).
 
 .Response
 ----
@@ -3363,11 +3593,8 @@
   }
 ----
 
-A review cannot be set on a change edit. Trying to post a review for a
-change edit fails with `409 Conflict`.
-
-It is also possible to add one or more reviewers to a change simultaneously
-with a review.
+It is also possible to add one or more reviewers or CCs
+to a change simultaneously with a review.
 
 .Request
 ----
@@ -3375,16 +3602,17 @@
   Content-Type: application/json; charset=UTF-8
 
   {
-    "message": "Looks good to me, but Jane and John should also take a look.",
-    "labels": {
-      "Code-Review": 1
-    },
+    "message": "I don't have context here. Jane and maybe John and the project leads should take a look.",
     "reviewers": [
       {
         "reviewer": "jane.roe@example.com"
       },
       {
-        "reviewer": "john.doe@example.com"
+        "reviewer": "john.doe@example.com",
+        "state": "CC"
+      }
+      {
+        "reviewer": "MyProjectVerifiers",
       }
     ]
   }
@@ -3392,8 +3620,8 @@
 
 Each element of the `reviewers` list is an instance of
 link:#reviewer-input[ReviewerInput]. The corresponding result of
-adding each reviewer will be returned in a list of
-link:#add-reviewer-result[AddReviewerResult].
+adding each reviewer will be returned in a map of inputs to
+link:#add-reviewer-result[AddReviewerResult]s.
 
 .Response
 ----
@@ -3403,36 +3631,66 @@
 
   )]}'
   {
-    "labels": {
-      "Code-Review": 1
-    },
-    "reviewers": [
-      {
+    "reviewers": {
+      "jane.roe@example.com": {
         "input": "jane.roe@example.com",
-        "approvals": {
-          "Verified": " 0",
-          "Code-Review": " 0"
-        },
-        "_account_id": 1000097,
-        "name": "Jane Roe",
-        "email": "jane.roe@example.com"
+        "reviewers": [
+          {
+            "_account_id": 1000097,
+            "name": "Jane Roe",
+            "email": "jane.roe@example.com"
+            "approvals": {
+              "Verified": " 0",
+              "Code-Review": " 0"
+            },
+          },
+        ]
       },
-      {
+      "john.doe@example.com": {
         "input": "john.doe@example.com",
-        "approvals": {
-          "Verified": " 0",
-          "Code-Review": " 0"
-        },
-        "_account_id": 1000096,
-        "name": "John Doe",
-        "email": "john.doe@example.com"
+        "ccs": [
+          {
+            "_account_id": 1000096,
+            "name": "John Doe",
+            "email": "john.doe@example.com"
+            "approvals": {
+              "Verified": " 0",
+              "Code-Review": " 0"
+            },
+          }
+        ]
+      },
+      "MyProjectVerifiers": {
+        "input": "MyProjectVerifiers",
+        "reviewers": [
+          {
+            "_account_id": 1000098,
+            "name": "Alice Ansel",
+            "email": "alice.ansel@example.com"
+            "approvals": {
+              "Verified": " 0",
+              "Code-Review": " 0"
+            },
+          },
+          {
+            "_account_id": 1000099,
+            "name": "Bob Bollard",
+            "email": "bob.bollard@example.com"
+            "approvals": {
+              "Verified": " 0",
+              "Code-Review": " 0"
+            },
+          },
+        ]
       }
-    ]
+    }
   }
 ----
 
 If there are any errors returned for reviewers, the entire review request will
-be rejected with `400 Bad Request`.
+be rejected with `400 Bad Request`. None of the entries will have the
+`reviewers` or `ccs` field set, and those which specifically failed will have
+the `errors` field set containing details of why they failed.
 
 .Error Response
 ----
@@ -3443,6 +3701,13 @@
   )]}'
   {
     "reviewers": {
+      "jane.roe@example.com": {
+        "input": "jane.roe@example.com",
+        "error": "Account of jane.roe@example.com is inactive."
+      },
+      "john.doe@example.com": {
+        "input": "john.doe@example.com"
+      },
       "MyProjectVerifiers": {
         "input": "MyProjectVerifiers",
         "error": "The group My Group has 15 members. Do you want to add them all as reviewers?",
@@ -4160,6 +4425,62 @@
   }
 ----
 
+[[delete-comment]]
+=== Delete Comment
+--
+'DELETE /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/comments/link:#comment-id[\{comment-id\}]' +
+'POST /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/comments/link:#comment-id[\{comment-id\}]/delete'
+--
+
+Deletes a published comment of a revision. Instead of deleting the
+whole comment, this endpoint just replaces the comment's message
+with a new message, which contains the name of the user who deletes
+the comment and the reason why it's deleted. The reason can be
+provided in the request body as a
+link:#delete-comment-input[DeleteCommentInput] entity.
+
+Note that only users with the
+link:access-control.html#capability_administrateServer[Administrate Server]
+global capability are permitted to delete a comment.
+
+Please note that some proxies prohibit request bodies for DELETE
+requests. In this case, if you want to specify options, use a
+POST request:
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/comments/TvcXrmjM/delete HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "reason": "contains confidential information"
+  }
+----
+
+As response a link:#comment-info[CommentInfo] entity is returned that
+describes the updated comment.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "TvcXrmjM",
+    "path": "gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java",
+    "line": 23,
+    "message": "Comment removed by: Administrator; Reason: contains confidential information",
+    "updated": "2013-02-26 15:40:43.986000000",
+    "author": {
+      "_account_id": 1000096,
+      "name": "John Doe",
+      "email": "john.doe@example.com"
+    }
+  }
+----
+
 [[list-robot-comments]]
 === List Robot Comments
 --
@@ -4256,6 +4577,72 @@
   }
 ----
 
+[[apply-fix]]
+=== Apply Fix
+--
+'POST /changes/<<change-id,\{change-id\}>>/revisions/<<revision-id,\{revision-id\}>>/fixes/<<fix-id,\{fix-id\}>>/apply'
+--
+
+Applies a suggested fix by creating a change edit which includes the
+modifications indicated by the fix suggestion. If a change edit already exists,
+it will be updated accordingly. A fix can only be applied if no change edit
+exists and the fix refers to the current patch set, or the fix refers to the
+patch set on which the change edit is based.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/fixes/8f605a55_f6aa4ecc/apply HTTP/1.0
+----
+
+If the fix was successfully applied, an <<edit-info,EditInfo>> describing the
+resulting change edit is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+    Content-Disposition: attachment
+    Content-Type: application/json; charset=UTF-8
+
+    )]}'
+    {
+      "commit":{
+        "parents":[
+          {
+            "commit":"1eee2c9d8f352483781e772f35dc586a69ff5646",
+          }
+        ],
+        "author":{
+          "name":"John Doe",
+          "email":"john.doe@example.com",
+          "date":"2013-05-07 15:21:27.000000000",
+          "tz":120
+         },
+         "committer":{
+           "name":"Jane Doe",
+           "email":"jane.doe@example.com",
+           "date":"2013-05-07 15:35:43.000000000",
+           "tz":120
+         },
+         "subject":"Implement feature X",
+         "message":"Implement feature X\n\nWith this feature ..."
+      },
+      "base_revision":"674ac754f91e64a0efb8087e59a176484bd534d1"
+    }
+----
+
+If the application failed e.g. due to conflicts with an existing change edit,
+the response "`409 Conflict`" including an error message in the response body
+is returned.
+
+.Response
+----
+  HTTP/1.1 409 Conflict
+  Content-Disposition: attachment
+  Content-Type: text/plain; charset=UTF-8
+
+  The existing change edit could not be merged with another tree.
+----
+
 [[list-files]]
 === List Files
 --
@@ -4336,6 +4723,11 @@
 
 Gets the content of a file from a certain revision.
 
+The optional, integer-valued `parent` parameter can be specified to request
+the named file from a parent commit of the specified revision. The value is
+the 1-based index of the parent's position in the commit object. If the
+parameter is omitted or the value is non-positive, the patch set is referenced.
+
 .Request
 ----
   GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/files/gerrit-server%2Fsrc%2Fmain%2Fjava%2Fcom%2Fgoogle%2Fgerrit%2Fserver%2Fproject%2FRefControl.java/content HTTP/1.0
@@ -4710,7 +5102,8 @@
 Cherry picks a revision to a destination branch.
 
 The commit message and destination branch must be provided in the request body inside a
-link:#cherrypick-input[CherryPickInput] entity.
+link:#cherrypick-input[CherryPickInput] entity.  If the commit message
+does not specify a Change-Id, a new one is picked for the destination change.
 
 .Request
 ----
@@ -5140,6 +5533,8 @@
 change. The labels are lexicographically sorted.
 |`reviewed`           |not set if `false`|
 Whether the change was reviewed by the calling user.
+|`muted`              |not set if `false`|
+Whether the change has been link:#mute[muted] by the calling user.
 Only set if link:#reviewed[reviewed] is requested.
 |`submit_type`        |optional|
 The link:project-configuration.html#submit_type[submit type] of the change. +
@@ -5211,6 +5606,10 @@
 |`problems`           |optional|
 A list of link:#problem-info[ProblemInfo] entities describing potential
 problems with this change. Only set if link:#check[CHECK] is set.
+|`is_private`         |optional, not set if `false`|
+When present, change is marked as private.
+|`work_in_progress`   |optional, not set if `false`|
+When present, change is marked as Work In Progress.
 |==================================
 
 [[change-input]]
@@ -5229,6 +5628,10 @@
 |`topic`              |optional|The topic to which this change belongs.
 |`status`             |optional, default to `NEW`|
 The status of the change (only `NEW` and `DRAFT` accepted here).
+|`is_private`         |optional, default to `false`|
+Whether the new change should be marked as private.
+|`work_in_progress`   |optional, default to `false`|
+Whether the new change should be set to work in progress.
 |`base_change`        |optional|
 A link:#change-id[\{change-id\}] that identifies the base change for a create
 change operation.
@@ -5259,6 +5662,10 @@
 Author of the message as an
 link:rest-api-accounts.html#account-info[AccountInfo] entity. +
 Unset if written by the Gerrit system.
+|`real_author`         |optional|
+Real author of the message as an
+link:rest-api-accounts.html#account-info[AccountInfo] entity. +
+Set if the message was posted on behalf of another user.
 |`date`            ||
 The link:rest-api.html#timestamp[timestamp] this message was posted.
 |`message`            ||The text left by the user.
@@ -5282,6 +5689,14 @@
 |`destination`      ||Destination branch
 |`parent`           |optional, defaults to 1|
 Number of the parent relative to which the cherry-pick should be considered.
+|`notify`           |optional|
+Notify handling that defines to whom email notifications should be sent
+after the cherry-pick. +
+Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
+If not set, the default is `NONE`.
+|`notify_details`   |optional|
+Additional information about whom to notify about the update as a map
+of recipient type to link:#notify-info[NotifyInfo] entity.
 |===========================
 
 [[comment-info]]
@@ -5419,6 +5834,20 @@
 link:#web-link-info[WebLinkInfo] entities.
 |===========================
 
+[[delete-comment-input]]
+=== DeleteCommentInput
+The `DeleteCommentInput` entity contains the option for deleting a comment.
+
+[options="header",cols="1,^1,5"]
+|=============================
+|Field Name               ||Description
+|`reason`                 |optional|
+The reason why the comment should be deleted. +
+If set, the comment's message will be replaced with
+"Comment removed by: `name`; Reason: `reason`",
+or just "Comment removed by: `name`." if not set.
+|=============================
+
 [[delete-reviewer-input]]
 === DeleteReviewerInput
 The `DeleteReviewerInput` entity contains options for the deletion of a
@@ -5592,7 +6021,7 @@
 |`commit`       ||The commit of change edit as
 link:#commit-info[CommitInfo] entity.
 |`base_revision`||The revision of the patch set the change edit is based on.
-|`fetch`        ||
+|`fetch`        |optional|
 Information about how to fetch this patch set. The fetch information is
 provided as a map that maps the protocol name ("`git`", "`http`",
 "`ssh`") to link:#fetch-info[FetchInfo] entities.
@@ -5671,8 +6100,8 @@
 for input objects.
 |`description`      ||A description of the suggested fix.
 |`replacements`     ||A list of <<fix-replacement-info,FixReplacementInfo>>
-entities indicating how the content of the file on which the comment was placed
-should be modified. They should refer to non-overlapping regions.
+entities indicating how the content of one or several files should be modified.
+Within a file, they should refer to non-overlapping regions.
 |==========================
 
 [[fix-replacement-info]]
@@ -5683,10 +6112,13 @@
 [options="header",cols="1,6"]
 |==========================
 |Field Name      |Description
-|`path`          |The path of the file which should be modified. Modifications
-are only allowed for the file on which the corresponding comment was placed.
+|`path`          |The path of the file which should be modified. Any file in
+the repository may be modified.
 |`range`         |A <<comment-range,CommentRange>> indicating which content
-of the file should be replaced.
+of the file should be replaced. Lines in the file are assumed to be separated
+by the line feed character, the carriage return character, the carriage return
+followed by the line feed character, or one of the other Unicode linebreak
+sequences supported by Java.
 |`replacement`   |The content which should be used instead of the current one.
 |==========================
 
@@ -5799,7 +6231,9 @@
 |===========================
 |Field Name    ||Description
 |`all`         |optional|List of all approvals for this label as a list
-of link:#approval-info[ApprovalInfo] entities.
+of link:#approval-info[ApprovalInfo] entities. Items in this list may
+not represent actual votes cast by users; if a user votes on any label,
+a corresponding ApprovalInfo will appear in this list for all labels.
 |`values`      |optional|A map of all values that are allowed for this
 label. The map maps the values ("`-2`", "`-1`", " `0`", "`+1`", "`+2`")
 to the value descriptions.
@@ -5896,6 +6330,17 @@
 identify the accounts that should be should be notified.
 |=======================
 
+[[private-input]]
+=== PrivateInput
+The `PrivateInput` entity contains information for changing the private
+flag on a change.
+
+[options="header",cols="1,^1,5"]
+|=======================
+|Field Name||Description
+|`message` |optional|Message describing why the private flag was changed.
+|=======================
+
 [[problem-info]]
 === ProblemInfo
 The `ProblemInfo` entity contains a description of a potential consistency problem
@@ -6120,6 +6565,27 @@
 link:rest-api-accounts.html#account-id[\{account-id\}] the review
 should be posted on behalf of. To use this option the caller must
 have been granted `labelAs-NAME` permission for all keys of labels.
+|`reviewers`              |optional|
+A list of link:rest-api-changes.html#reviewer-input[ReviewerInput]
+representing reviewers that should be added to the change.
+|============================
+
+[[review-result]]
+=== ReviewResult
+The `ReviewResult` entity contains information regarding the updates
+that were made to a review.
+
+[options="header",cols="1,^1,5"]
+|============================
+|Field Name               ||Description
+|`labels`                 |optional|
+Map of labels to values after the review was posted. Null if any reviewer
+additions were rejected.
+|`reviewers`              |optional|
+Map of account or group identifier to
+link:rest-api-changes.html#add-reviewer-result[AddReviewerResult]
+representing the outcome of adding as a reviewer.
+Absent if no reviewer additions were requested.
 |============================
 
 [[reviewer-info]]
@@ -6138,6 +6604,10 @@
 |`approvals`   |
 The approvals of the reviewer as a map that maps the label names to the
 approval values ("`-2`", "`-1`", "`0`", "`+1`", "`+2`").
+|`_account_id`   |
+This field is inherited from `AccountInfo` but is optional here if an
+unregistered reviewer was added by email. See
+link:rest-api-changes.html#add-reviewer[add-reviewer] for details.
 |==========================
 
 [[reviewer-input]]
@@ -6226,6 +6696,9 @@
 patch set as a link:#push-certificate-info[PushCertificateInfo] entity.
 This field is always set if the option is requested; if no push
 certificate was provided, it is set to an empty object.
+|`description` |optional|
+The description of this patchset, as displayed in the patchset
+selector menu. May be null if no description is set.
 |===========================
 
 [[robot-comment-info]]
@@ -6435,6 +6908,18 @@
 |`image_url`|URL to the icon of the link.
 |======================
 
+[[work-in-progress-input]]
+=== WorkInProgressInput
+The `WorkInProgressInput` entity contains additional information for a change
+set to WorkInProgress/ReadyForReview.
+
+[options="header",cols="1,^1,5"]
+|=============================
+|Field Name      ||Description
+|`message`       |optional|
+Message to be added as a review comment to the change being set WorkInProgress/ReadyForReview.
+|=============================
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 62e3ee4..49dbf30 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -138,6 +138,91 @@
   }
 ----
 
+[[check-consistency]]
+=== Check Consistency
+--
+'POST /config/server/check.consistency'
+--
+
+Runs consistency checks and returns detected problems.
+
+Input for the consistency checks that should be run must be provided in
+the request body inside a
+link:#consistency-check-input[ConsistencyCheckInput] entity.
+
+.Request
+----
+  POST /config/server/check.consistency HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "check_account_external_ids": {}
+  }
+----
+
+As result a link:#consistency-check-info[ConsistencyCheckInfo] entity
+is returned that contains detected consistency problems.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "results": {
+      "account_external_id_result": {
+        "problems": [
+          {
+            "status": "ERROR",
+            "message": "External ID \u0027uuid:ccb8d323-1361-45aa-8874-41987a660c46\u0027 belongs to account that doesn\u0027t exist: 1000012"
+          }
+        ]
+      }
+    }
+  }
+----
+
+[[check-access]]
+=== Check Access
+--
+'POST /config/server/check.access'
+--
+
+Runs access checks for other users.
+
+Input for the access checks that should be run must be provided in
+the request body inside a
+link:#access-check-input[AccessCheckInput] entity.
+
+.Request
+----
+  POST /config/server/check HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "project": "medium",
+    "account": "Kristen.Burns@gerritcodereview.com",
+    "ref": "refs/heads/secret/bla"
+  }
+----
+
+The result is a link:#access-check-info[AccessCheckInfo] entity
+detailing the read access of the given user for the given project (or
+project-ref combination).
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "message": "user Kristen Burns \u003cKristen.Burns@gerritcodereview.com\u003e (1000098) cannot see ref refs/heads/secret/master in project medium",
+    "status": 403
+  }
+----
+
 [[confirm-email]]
 === Confirm Email
 --
@@ -980,6 +1065,7 @@
     "size_bar_in_change_table": true,
     "review_category_strategy": "NONE",
     "mute_common_path_prefixes": true,
+    "publish_comments_on_push": true,
     "my": [
       {
         "url": "#/dashboard/self",
@@ -1062,6 +1148,7 @@
     "size_bar_in_change_table": true,
     "review_category_strategy": "NONE",
     "mute_common_path_prefixes": true,
+    "publish_comments_on_push": true,
     "my": [
       {
         "url": "#/dashboard/self",
@@ -1221,6 +1308,34 @@
 [[json-entities]]
 == JSON Entities
 
+[[access-check-info]]
+=== AccessCheckInfo
+The `AccessCheckInfo` entity is the result of a
+an access check.
+
+[options="header",cols="1,^1,5"]
+|=========================================
+|Field Name                  ||Description
+|`status`||The HTTP status code for the access.
+200 means success, 403 means denied and 404 means the project does not
+exist.
+|`message`|optional|A clarifying message if `status` is not 200.
+=========================================
+
+[[access-check-input]]
+=== AccessCheckInput
+The `AccessCheckInput` entity is a tuple of (account, project)  or
+(account, project, ref) for which we want to check access.
+
+[options="header",cols="1,^1,5"]
+|=========================================
+|Field Name                  ||Description
+|`account`||The account for which to check access
+|`project`||The project for which to check access
+|`ref`|optional|The refname for which to check access
+=========================================
+
+
 [[auth-info]]
 === AuthInfo
 The `AuthInfo` entity contains information about the authentication
@@ -1365,6 +1480,66 @@
 the whole topic is submitted].
 |=============================
 
+[[check-account-external-ids-input]]
+=== CheckAccountExternalIdsInput
+The `CheckAccountExternalIdsInput` entity contains input for the
+account external IDs consistency check.
+
+Currently this entity contains no fields.
+
+[[check-account-external-ids-result-info]]
+=== CheckAccountExternalIdsResultInfo
+The `CheckAccountExternalIdsResultInfo` entity contains the result of
+running the account external IDs consistency check.
+
+[options="header",cols="1,6"]
+|======================
+|Field Name|Description
+|`problems`|A list of link:#consistency-problem-info[
+ConsistencyProblemInfo] entities.
+|======================
+
+[[consistency-check-info]]
+=== ConsistencyCheckInfo
+The `ConsistencyCheckInfo` entity contains the results of running
+consistency checks.
+
+[options="header",cols="1,^1,5"]
+|================================================
+|Field Name                         ||Description
+|`check_account_external_ids_result`|optional|
+The result of running the account external ID consistency check as a
+link:#check-account-external-ids-result-info[
+CheckAccountExternalIdsResultInfo] entity.
+|================================================
+
+[[consistency-check-input]]
+=== ConsistencyCheckInput
+The `ConsistencyCheckInput` entity contains information about which
+consistency checks should be run.
+
+[options="header",cols="1,^1,5"]
+|=========================================
+|Field Name                  ||Description
+|`check_account_external_ids`|optional|
+Input for the account external ID consistency check as
+link:#check-account-external-ids-input[CheckAccountExternalIdsInput]
+entity.
+|=========================================
+
+[[consistency-problem-info]]
+=== ConsistencyProblemInfo
+The `ConsistencyProblemInfo` entity contains information about a
+consistency problem.
+
+[options="header",cols="1,6"]
+|======================
+|Field Name|Description
+|`status`  |The status of the consistency problem. +
+Possible values are `ERROR` and `WARNING`.
+|`message` |Message describing the consistency problem.
+|======================
+
 [[download-info]]
 === DownloadInfo
 The `DownloadInfo` entity contains information about supported download
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 72c6a39..17b0192 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -2060,6 +2060,60 @@
   Ly8gQ29weXJpZ2h0IChDKSAyMDEwIFRoZSBBbmRyb2lkIE9wZW4gU291cmNlIFByb2plY...
 ----
 
+
+[[cherry-pick-commit]]
+=== Cherry Pick Commit
+--
+'POST /projects/link:#project-name[\{project-name\}]/commits/link:#commit-id[\{commit-id\}]/cherrypick'
+--
+
+Cherry-picks a commit of a project to a destination branch.
+
+The destination branch must be provided in the request body inside a
+link:rest-api-changes.html#cherrypick-input[CherryPickInput] entity.
+If the commit message is not set, the commit message of the source
+commit will be used.
+
+.Request
+----
+  POST /projects/work%2Fmy-project/commits/a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96/cherrypick HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "message" : "Implementing Feature X",
+    "destination" : "release-branch"
+  }
+----
+
+As response a link:rest-api-changes.html#change-info[ChangeInfo] entity is returned that
+describes the resulting cherry-picked change.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9941",
+    "project": "myProject",
+    "branch": "release-branch",
+    "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9941",
+    "subject": "Implementing Feature X",
+    "status": "NEW",
+    "created": "2013-02-01 09:59:32.126000000",
+    "updated": "2013-02-21 11:16:36.775000000",
+    "mergeable": true,
+    "insertions": 12,
+    "deletions": 11,
+    "_number": 3965,
+    "owner": {
+      "name": "John Doe"
+    }
+  }
+----
+
 [[dashboard-endpoints]]
 == Dashboard Endpoints
 
diff --git a/Documentation/rest-api.txt b/Documentation/rest-api.txt
index 7928512..8e2bff6 100644
--- a/Documentation/rest-api.txt
+++ b/Documentation/rest-api.txt
@@ -78,6 +78,12 @@
 `Accept-Encoding` request header is set to `gzip`. This may
 save on network transfer time for larger responses.
 
+[[input]]
+=== Input Format
+Unknown JSON parameters will simply be ignored by Gerrit without causing
+an exception. This also applies to case-sensitive parameters, such as
+map keys.
+
 [[timestamp]]
 === Timestamp
 Timestamps are given in UTC and have the format
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 392d5cf..74ae568 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -367,6 +367,22 @@
 Mergeability of abandoned changes is not computed. This operator will
 not find any abandoned but mergeable changes.
 
+[[ignored]]
+is:ignored::
++
+True if the change is ignored. Same as `star:ignore`.
+
+[[private]]
+is:private::
++
+True if the change is private, ie. only visible to owner and its
+reviewers.
+
+[[workInProgress]]
+is:wip::
++
+True if the change is Work In Progress.
+
 [[status]]
 status:open, status:pending::
 +
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index 9efbb21..deec660 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -212,6 +212,42 @@
   git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/experimental -o topic=driver/i42
 ----
 
+[[private]]
+==== Private Changes
+
+To push a private change or to turn a change private on push the `private`
+option can be specified:
+
+----
+  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/master%private
+----
+
+Omitting the `private` option when pushing updates to a private change
+doesn't make change non-private again. To remove the private
+flag from a change on push, explicitly specify the `remove-private` option:
+
+----
+  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/master%remove-private
+----
+
+[[wip]]
+==== Work-In-Progress Changes
+
+To push a wip change or to turn a change to wip the `work-in-progress` (or `wip`)
+option can be specified:
+
+----
+  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/master%wip
+----
+
+Omitting the `wip` option when pushing updates to a wip change
+doesn't make change ready again. To remove the `wip`
+flag from a change on push, explicitly specify the `ready` option:
+
+----
+  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/master%ready
+----
+
 [[message]]
 ==== Message
 
@@ -226,6 +262,21 @@
 git push refs parameter does not allow spaces.  Use the '_' character instead,
 it will then be applied as "This is a rebase on master".
 
+[[publish-comments]]
+==== Publish Draft Comments
+
+If you have draft comments on the change(s) that are updated by the push, the
+`publish-comments` option will cause them to be published:
+
+----
+  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/experimental%publish-comments
+----
+
+The default for this option can be set as a
+link:intro-user.html#publish-comments-on-push[user preference]. If the
+preference is set so the default behavior is to publish, this can be overridden
+with the `no-publish-comments` (or `np`) option.
+
 [[review_labels]]
 ==== Review Labels
 
diff --git a/README.md b/README.md
index 78c8477..1ca01d5 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@
 ## Source
 
 Our canonical Git repository is located on [googlesource.com](https://gerrit.googlesource.com/gerrit).
-There is a mirror of the repository on [Github](https://github.com/gerrit-review/gerrit).
+There is a mirror of the repository on [Github](https://github.com/GerritCodeReview/gerrit).
 
 ## Reporting bugs
 
diff --git a/WORKSPACE b/WORKSPACE
index f63b8b3..8cb061e 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -100,18 +100,18 @@
     sha1 = "5d9e2e895e3111622720157d0aa540066d5fce3a",
 )
 
-GWT_VERS = "2.8.0"
+GWT_VERS = "2.8.1"
 
 maven_jar(
     name = "user",
     artifact = "com.google.gwt:gwt-user:" + GWT_VERS,
-    sha1 = "518579870499e15531f454f35dca0772d7fa31f7",
+    sha1 = "9a13fbee70848f1f1cddd3ae33ad180af3392d9e",
 )
 
 maven_jar(
     name = "dev",
     artifact = "com.google.gwt:gwt-dev:" + GWT_VERS,
-    sha1 = "f160a61272c5ebe805cd2d3d3256ed3ecf14893f",
+    sha1 = "c7e88c07e9cda90cc623b4451d0d9713ae03aa53",
 )
 
 maven_jar(
@@ -194,8 +194,8 @@
 
 maven_jar(
     name = "joda_time",
-    artifact = "joda-time:joda-time:2.9.4",
-    sha1 = "1c295b462f16702ebe720bbb08f62e1ba80da41b",
+    artifact = "joda-time:joda-time:2.9.9",
+    sha1 = "f7b520c458572890807d143670c9b24f4de90897",
 )
 
 maven_jar(
@@ -226,8 +226,8 @@
 
 maven_jar(
     name = "juniversalchardet",
-    artifact = "com.googlecode.juniversalchardet:juniversalchardet:1.0.3",
-    sha1 = "cd49678784c46aa8789c060538e0154013bb421b",
+    artifact = "com.github.albfernandez:juniversalchardet:2.0.0",
+    sha1 = "28c59f58f5adcc307604602e2aa89e2aca14c554",
 )
 
 SLF4J_VERS = "1.7.7"
@@ -282,8 +282,8 @@
 
 maven_jar(
     name = "commons_codec",
-    artifact = "commons-codec:commons-codec:1.4",
-    sha1 = "4216af16d38465bbab0f3dff8efa14204f7a399a",
+    artifact = "commons-codec:commons-codec:1.10",
+    sha1 = "4b95f4897fa13f2cd904aee711aeafc0c5295cd8",
 )
 
 maven_jar(
@@ -294,8 +294,8 @@
 
 maven_jar(
     name = "commons_compress",
-    artifact = "org.apache.commons:commons-compress:1.12",
-    sha1 = "84caa68576e345eb5e7ae61a0e5a9229eb100d7b",
+    artifact = "org.apache.commons:commons-compress:1.13",
+    sha1 = "15c5e9584200122924e50203ae210b57616b75ee",
 )
 
 maven_jar(
@@ -336,8 +336,8 @@
 
 maven_jar(
     name = "commons_validator",
-    artifact = "commons-validator:commons-validator:1.5.1",
-    sha1 = "86d05a46e8f064b300657f751b5a98c62807e2a0",
+    artifact = "commons-validator:commons-validator:1.6",
+    sha1 = "e989d1e87cdd60575df0765ed5bac65c905d7908",
 )
 
 maven_jar(
@@ -348,8 +348,8 @@
 
 maven_jar(
     name = "pegdown",
-    artifact = "org.pegdown:pegdown:1.4.2",
-    sha1 = "d96db502ed832df867ff5d918f05b51ba3879ea7",
+    artifact = "org.pegdown:pegdown:1.6.0",
+    sha1 = "231ae49d913467deb2027d0b8a0b68b231deef4f",
 )
 
 maven_jar(
@@ -434,8 +434,8 @@
 
 maven_jar(
     name = "auto_value",
-    artifact = "com.google.auto.value:auto-value:1.4",
-    sha1 = "6d1448fcd13074bd3658ef915022410b7c48343b",
+    artifact = "com.google.auto.value:auto-value:1.4.1",
+    sha1 = "8172ebbd7970188aff304c8a420b9f17168f6f48",
 )
 
 maven_jar(
@@ -444,84 +444,84 @@
     sha1 = "18a9a2ce6abf32ea1b5fd31dae5210ad93f4e5e3",
 )
 
-LUCENE_VERS = "5.5.2"
+LUCENE_VERS = "5.5.4"
 
 maven_jar(
     name = "lucene_core",
     artifact = "org.apache.lucene:lucene-core:" + LUCENE_VERS,
-    sha1 = "de5e5c3161ea01e89f2a09a14391f9b7ed66cdbb",
+    sha1 = "ab9c77e75cf142aa6e284b310c8395617bd9b19b",
 )
 
 maven_jar(
     name = "lucene_analyzers_common",
     artifact = "org.apache.lucene:lucene-analyzers-common:" + LUCENE_VERS,
-    sha1 = "f0bc3114a6b43f8e64a33c471d5b9e8ddc51564d",
+    sha1 = "08ce9d34c8124c80e176e8332ee947480bbb9576",
 )
 
 maven_jar(
     name = "lucene_codecs",
     artifact = "org.apache.lucene:lucene-codecs:" + LUCENE_VERS,
-    sha1 = "e01fe463d9490bb1b4a6a168e771f7b7255a50b1",
+    sha1 = "afdad570668469b1734fbd32b8f98561561bed48",
 )
 
 maven_jar(
     name = "backward_codecs",
     artifact = "org.apache.lucene:lucene-backward-codecs:" + LUCENE_VERS,
-    sha1 = "c5cfcd7a8cf48a0144b61fb991c8e50a0bf868d5",
+    sha1 = "a933f42e758c54c43083398127ea7342b54d8212",
 )
 
 maven_jar(
     name = "lucene_misc",
     artifact = "org.apache.lucene:lucene-misc:" + LUCENE_VERS,
-    sha1 = "37bbe5a2fb429499dfbe75d750d1778881fff45d",
+    sha1 = "a74388857f73614e528ae44d742c60187cb55a5a",
 )
 
 maven_jar(
     name = "lucene_queryparser",
     artifact = "org.apache.lucene:lucene-queryparser:" + LUCENE_VERS,
-    sha1 = "8ac921563e744463605284c6d9d2d95e1be5b87c",
+    sha1 = "8a06fad4675473d98d93b61fea529e3f464bf69e",
 )
 
 maven_jar(
     name = "lucene_highlighter",
     artifact = "org.apache.lucene:lucene-highlighter:" + LUCENE_VERS,
-    sha1 = "d127ac514e9df965ab0b57d92bbe0c68d3d145b8",
+    sha1 = "433f53f03f1b14337c08d54e507a5410905376fa",
 )
 
 maven_jar(
     name = "lucene_join",
     artifact = "org.apache.lucene:lucene-join:" + LUCENE_VERS,
-    sha1 = "dac1b322508f3f2696ecc49a97311d34d8382054",
+    sha1 = "23f9a909a244ed3b28b37c5bb21a6e33e6c0a339",
 )
 
 maven_jar(
     name = "lucene_memory",
     artifact = "org.apache.lucene:lucene-memory:" + LUCENE_VERS,
-    sha1 = "7409db9863d8fbc265c27793c6cc7511304182c2",
+    sha1 = "4dbdc2e1a24837722294762a9edb479f79092ab9",
 )
 
 maven_jar(
     name = "lucene_sandbox",
     artifact = "org.apache.lucene:lucene-sandbox:" + LUCENE_VERS,
-    sha1 = "30a91f120706ba66732d5a974b56c6971b3c8a16",
+    sha1 = "49498bbb2adc333e98bdca4bf6170ae770cbad11",
 )
 
 maven_jar(
     name = "lucene_spatial",
     artifact = "org.apache.lucene:lucene-spatial:" + LUCENE_VERS,
-    sha1 = "8ed7a9a43d78222038573dd1c295a61f3c0bb0db",
+    sha1 = "0217d302dc0ef4d9b8b475ffe327d83c1e0ceba5",
 )
 
 maven_jar(
     name = "lucene_suggest",
     artifact = "org.apache.lucene:lucene-suggest:" + LUCENE_VERS,
-    sha1 = "e8316b37dddcf2092a54dab2ce6aad0d5ad78585",
+    sha1 = "0f46dbb3229eed62dff10d008172c885e0e028c8",
 )
 
 maven_jar(
     name = "lucene_queries",
     artifact = "org.apache.lucene:lucene-queries:" + LUCENE_VERS,
-    sha1 = "692f1ad887cf4e006a23f45019e6de30f3312d3f",
+    sha1 = "f915357b8b4b43742ab48f1401dedcaa12dfa37a",
 )
 
 maven_jar(
@@ -659,6 +659,9 @@
     sha1 = "fd369423346b2f1525c413e33f8cf95b09c92cbd",
 )
 
+# Note that all of the following org.apache.httpcomponents have newer versions,
+# but 4.4.1 is the only version that is available for all of them.
+# TODO: Check what combination of new versions are compatible.
 HTTPCOMP_VERS = "4.4.1"
 
 maven_jar(
@@ -726,9 +729,10 @@
     sha1 = "2862787ce34cb6f385ada891e36ec7f9e7bd0902",
 )
 
+# When bumping the easymock version number, make sure to also move powermock to a compatible version
 maven_jar(
     name = "easymock",
-    artifact = "org.easymock:easymock:3.1",  # When bumping the version
+    artifact = "org.easymock:easymock:3.1",
     sha1 = "3e127311a86fc2e8f550ef8ee4abe094bbcf7e7e",
 )
 
@@ -915,8 +919,8 @@
 
 maven_jar(
     name = "elasticsearch",
-    artifact = "org.elasticsearch:elasticsearch:2.4.4",
-    sha1 = "e69930bc794c539d34778e665d6f8ccbffd42c6f",
+    artifact = "org.elasticsearch:elasticsearch:2.4.5",
+    sha1 = "daafe48ae06592029a2fedca1fe2ac0f5eec3185",
 )
 
 # Java REST client for Elasticsearch.
@@ -1092,8 +1096,8 @@
 bower_archive(
     name = "polymer",
     package = "polymer/polymer",
-    sha1 = "f2563ed9c8571057814b78d8f6cf275eeb953eeb",
-    version = "1.7.1",
+    sha1 = "2c7dd638d55ea91242525139cba18a308b9426d5",
+    version = "1.9.1",
 )
 
 bower_archive(
@@ -1122,8 +1126,8 @@
 bower_archive(
     name = "web-component-tester",
     package = "web-component-tester",
-    sha1 = "a4a9bc7815a22d143e8f8593e37b3c2028b8c20f",
-    version = "5.0.0",
+    sha1 = "4e778f8b7d784ba2a069d83d0cd146125c5c4fcb",
+    version = "5.0.1",
 )
 
 # Bower component transitive dependencies.
diff --git a/contrib/abandon_stale.py b/contrib/abandon_stale.py
index f62c767..44e7e0e 100755
--- a/contrib/abandon_stale.py
+++ b/contrib/abandon_stale.py
@@ -59,7 +59,11 @@
                       help='gerrit server URL')
     parser.add_option('-b', '--basic-auth', dest='basic_auth',
                       action='store_true',
-                      help='use HTTP basic authentication instead of digest')
+                      help='(deprecated) use HTTP basic authentication instead'
+                      ' of digest')
+    parser.add_option('-d', '--digest-auth', dest='digest_auth',
+                      action='store_true',
+                      help='use HTTP digest authentication instead of basic')
     parser.add_option('-n', '--dry-run', dest='dry_run',
                       action='store_true',
                       help='enable dry-run mode: show stale changes but do '
@@ -115,10 +119,10 @@
     message = "Abandoning after %s %s or more of inactivity." % \
         (match.group(1), match.group(2))
 
-    if options.basic_auth:
-        auth_type = HTTPBasicAuthFromNetrc
-    else:
+    if options.digest_auth:
         auth_type = HTTPDigestAuthFromNetrc
+    else:
+        auth_type = HTTPBasicAuthFromNetrc
 
     try:
         auth = auth_type(url=options.gerrit_url)
diff --git a/contrib/populate-fixture-data.py b/contrib/populate-fixture-data.py
index b77c41a..0e3dffe 100644
--- a/contrib/populate-fixture-data.py
+++ b/contrib/populate-fixture-data.py
@@ -46,7 +46,7 @@
 PLUGINS_URL = BASE_URL + "plugins/"
 PROJECTS_URL = BASE_URL + "projects/"
 
-ADMIN_DIGEST = requests.auth.HTTPDigestAuth("admin", "secret")
+ADMIN_BASIC_AUTH = requests.auth.HTTPBasicAuth("admin", "secret")
 
 # GROUP_ADMIN stores a GroupInfo for the admin group (see Gerrit rest docs)
 # In addition, GROUP_ADMIN["name"] stores the admin group"s name.
@@ -151,8 +151,8 @@
   return json_string
 
 
-def digest_auth(user):
-  return requests.auth.HTTPDigestAuth(user["username"], user["http_password"])
+def basic_auth(user):
+  return requests.auth.HTTPBasicAuth(user["username"], user["http_password"])
 
 
 def fetch_admin_group():
@@ -160,7 +160,7 @@
   # Get admin group
   r = json.loads(clean(requests.get(GROUPS_URL + "?suggest=ad&p=All-Projects",
                                     headers=HEADERS,
-                                    auth=ADMIN_DIGEST).text))
+                                    auth=ADMIN_BASIC_AUTH).text))
   admin_group_name = r.keys()[0]
   GROUP_ADMIN = r[admin_group_name]
   GROUP_ADMIN["name"] = admin_group_name
@@ -225,7 +225,7 @@
     requests.put(GROUPS_URL + g["name"],
                  json.dumps(g),
                  headers=HEADERS,
-                 auth=ADMIN_DIGEST)
+                 auth=ADMIN_BASIC_AUTH)
   return [g["name"] for g in groups]
 
 
@@ -247,7 +247,7 @@
     requests.put(PROJECTS_URL + p["name"],
                  json.dumps(p),
                  headers=HEADERS,
-                 auth=ADMIN_DIGEST)
+                 auth=ADMIN_BASIC_AUTH)
   return [p["name"] for p in projects]
 
 
@@ -256,7 +256,7 @@
     requests.put(ACCOUNTS_URL + user["username"],
                  json.dumps(user),
                  headers=HEADERS,
-                 auth=ADMIN_DIGEST)
+                 auth=ADMIN_BASIC_AUTH)
 
 
 def create_change(user, project_name):
@@ -270,7 +270,7 @@
   requests.post(CHANGES_URL,
                 json.dumps(change),
                 headers=HEADERS,
-                auth=digest_auth(user))
+                auth=basic_auth(user))
 
 
 def clean_up():
diff --git a/gerrit-acceptance-framework/BUILD b/gerrit-acceptance-framework/BUILD
index ca8ffbd..b351c27 100644
--- a/gerrit-acceptance-framework/BUILD
+++ b/gerrit-acceptance-framework/BUILD
@@ -38,8 +38,8 @@
         "//gerrit-pgm:daemon",
         "//gerrit-pgm:http-jetty",
         "//gerrit-pgm:util-nodep",
+        "//gerrit-server:prolog-common",
         "//gerrit-server:testutil",
-        "//gerrit-server/src/main/prolog:common",
         "//lib:jimfs",
         "//lib:truth",
         "//lib:truth-java8-extension",
diff --git a/gerrit-acceptance-framework/pom.xml b/gerrit-acceptance-framework/pom.xml
index efb68ecb..747f5d4 100644
--- a/gerrit-acceptance-framework/pom.xml
+++ b/gerrit-acceptance-framework/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-acceptance-framework</artifactId>
-  <version>2.14.1-SNAPSHOT</version>
+  <version>2.15-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index c4be97c..4d9213e 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -21,6 +21,7 @@
 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 java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.HEAD;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
@@ -64,6 +65,7 @@
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.ChangeFinder;
@@ -88,6 +90,7 @@
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.send.EmailHeader;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -96,6 +99,7 @@
 import com.google.gerrit.server.project.Util;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.FakeEmailSender;
 import com.google.gerrit.testutil.FakeEmailSender.Message;
@@ -107,6 +111,7 @@
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
@@ -150,6 +155,7 @@
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.rules.ExpectedException;
+import org.junit.rules.RuleChain;
 import org.junit.rules.TemporaryFolder;
 import org.junit.rules.TestRule;
 import org.junit.runner.Description;
@@ -161,93 +167,13 @@
   private static GerritServer commonServer;
 
   @ConfigSuite.Parameter public Config baseConfig;
-
   @ConfigSuite.Name private String configName;
 
-  @Inject protected AllProjectsName allProjects;
-
-  @Inject protected AccountCreator accounts;
-
-  @Inject private SchemaFactory<ReviewDb> reviewDbProvider;
-
-  @Inject protected GerritApi gApi;
-
-  @Inject protected AcceptanceTestRequestScope atrScope;
-
-  @Inject protected AccountCache accountCache;
-
-  @Inject protected IdentifiedUser.GenericFactory identifiedUserFactory;
-
-  @Inject protected PushOneCommit.Factory pushFactory;
-
-  @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
-
-  @Inject protected ProjectCache projectCache;
-
-  @Inject protected GroupCache groupCache;
-
-  @Inject protected GitRepositoryManager repoManager;
-
-  @Inject protected ChangeIndexer indexer;
-
-  @Inject protected Provider<InternalChangeQuery> queryProvider;
-
-  @Inject @CanonicalWebUrl protected Provider<String> canonicalWebUrl;
-
-  @Inject @GerritServerConfig protected Config cfg;
-
-  @Inject private InProcessProtocol inProcessProtocol;
-
-  @Inject private Provider<AnonymousUser> anonymousUser;
-
-  @Inject @GerritPersonIdent protected Provider<PersonIdent> serverIdent;
-
-  @Inject protected ChangeData.Factory changeDataFactory;
-
-  @Inject protected PatchSetUtil psUtil;
-
-  @Inject protected ChangeFinder changeFinder;
-
-  @Inject protected Revisions revisions;
-
-  @Inject protected FakeEmailSender sender;
-
-  @Inject protected ChangeNoteUtil changeNoteUtil;
-
-  @Inject protected ChangeResource.Factory changeResourceFactory;
-
-  @Inject protected SystemGroupBackend systemGroupBackend;
-
-  @Inject private EventRecorder.Factory eventRecorderFactory;
-
-  @Inject private ChangeIndexCollection changeIndexes;
-
-  protected TestRepository<InMemoryRepository> testRepo;
-  protected GerritServer server;
-  protected TestAccount admin;
-  protected TestAccount user;
-  protected RestSession adminRestSession;
-  protected RestSession userRestSession;
-  protected SshSession adminSshSession;
-  protected SshSession userSshSession;
-  protected ReviewDb db;
-  protected Project.NameKey project;
-  protected EventRecorder eventRecorder;
-
-  @Inject protected TestNotesMigration notesMigration;
-
-  @Inject protected ChangeNotes.Factory notesFactory;
-
-  @Inject protected Abandon changeAbandoner;
-
   @Rule public ExpectedException exception = ExpectedException.none();
 
-  private String resourcePrefix;
-  private List<Repository> toClose;
-  private boolean useSsh;
+  protected final TemporaryFolder tempSiteDir = new TemporaryFolder();
 
-  @Rule
-  public TestRule testRunner =
+  private final TestRule testRunner =
       new TestRule() {
         @Override
         public Statement apply(final Statement base, final Description description) {
@@ -265,7 +191,58 @@
         }
       };
 
-  @Rule public TemporaryFolder tempSiteDir = new TemporaryFolder();
+  @Rule public RuleChain ruleChain = RuleChain.outerRule(tempSiteDir).around(testRunner);
+
+  @Inject @CanonicalWebUrl protected Provider<String> canonicalWebUrl;
+  @Inject @GerritPersonIdent protected Provider<PersonIdent> serverIdent;
+  @Inject @GerritServerConfig protected Config cfg;
+  @Inject protected AcceptanceTestRequestScope atrScope;
+  @Inject protected AccountCache accountCache;
+  @Inject protected AccountCreator accounts;
+  @Inject protected AllProjectsName allProjects;
+  @Inject protected BatchUpdate.Factory batchUpdateFactory;
+  @Inject protected ChangeData.Factory changeDataFactory;
+  @Inject protected ChangeFinder changeFinder;
+  @Inject protected ChangeIndexer indexer;
+  @Inject protected ChangeNoteUtil changeNoteUtil;
+  @Inject protected ChangeResource.Factory changeResourceFactory;
+  @Inject protected FakeEmailSender sender;
+  @Inject protected GerritApi gApi;
+  @Inject protected GitRepositoryManager repoManager;
+  @Inject protected GroupCache groupCache;
+  @Inject protected IdentifiedUser.GenericFactory identifiedUserFactory;
+  @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
+  @Inject protected PatchSetUtil psUtil;
+  @Inject protected ProjectCache projectCache;
+  @Inject protected Provider<InternalChangeQuery> queryProvider;
+  @Inject protected PushOneCommit.Factory pushFactory;
+  @Inject protected Revisions revisions;
+  @Inject protected SystemGroupBackend systemGroupBackend;
+  @Inject protected TestNotesMigration notesMigration;
+  @Inject protected ChangeNotes.Factory notesFactory;
+  @Inject protected Abandon changeAbandoner;
+
+  protected EventRecorder eventRecorder;
+  protected GerritServer server;
+  protected Project.NameKey project;
+  protected RestSession adminRestSession;
+  protected RestSession userRestSession;
+  protected ReviewDb db;
+  protected SshSession adminSshSession;
+  protected SshSession userSshSession;
+  protected TestAccount admin;
+  protected TestAccount user;
+  protected TestRepository<InMemoryRepository> testRepo;
+
+  @Inject private ChangeIndexCollection changeIndexes;
+  @Inject private EventRecorder.Factory eventRecorderFactory;
+  @Inject private InProcessProtocol inProcessProtocol;
+  @Inject private Provider<AnonymousUser> anonymousUser;
+  @Inject private SchemaFactory<ReviewDb> reviewDbProvider;
+
+  private List<Repository> toClose;
+  private String resourcePrefix;
+  private boolean useSsh;
 
   @Before
   public void clearSender() {
@@ -346,7 +323,6 @@
     }
 
     server.getTestInjector().injectMembers(this);
-    notesMigration.setFromEnv();
     Transport.register(inProcessProtocol);
     toClose = Collections.synchronizedList(new ArrayList<Repository>());
     admin = accounts.admin();
@@ -492,12 +468,22 @@
 
   protected TestRepository<InMemoryRepository> cloneProject(
       Project.NameKey p, TestAccount testAccount) throws Exception {
+    return GitUtil.cloneProject(p, registerRepoConnection(p, testAccount));
+  }
+
+  /**
+   * Register a repository connection over the test protocol.
+   *
+   * @return a URI string that can be used to connect to this repository for both fetch and push.
+   */
+  protected String registerRepoConnection(Project.NameKey p, TestAccount testAccount)
+      throws Exception {
     InProcessProtocol.Context ctx =
         new InProcessProtocol.Context(
             reviewDbProvider, identifiedUserFactory, testAccount.getId(), p);
     Repository repo = repoManager.openRepository(p);
     toClose.add(repo);
-    return GitUtil.cloneProject(p, inProcessProtocol.register(ctx, repo).toString());
+    return inProcessProtocol.register(ctx, repo).toString();
   }
 
   protected void afterTest() throws Exception {
@@ -516,6 +502,7 @@
       server.stop();
       server = null;
     }
+    notesMigration.resetFromEnv();
   }
 
   protected TestRepository<?>.CommitBuilder commitBuilder() throws Exception {
@@ -594,6 +581,10 @@
     return pushTo("refs/drafts/master");
   }
 
+  protected PushOneCommit.Result createWorkInProgressChange() throws Exception {
+    return pushTo("refs/for/master%wip");
+  }
+
   protected PushOneCommit.Result createChange(String subject, String fileName, String content)
       throws Exception {
     PushOneCommit push =
@@ -820,24 +811,24 @@
     }
   }
 
-  protected void deny(String permission, AccountGroup.UUID id, String ref) throws Exception {
-    deny(project, permission, id, ref);
+  protected void deny(String ref, String permission, AccountGroup.UUID id) throws Exception {
+    deny(project, ref, permission, id);
   }
 
-  protected void deny(Project.NameKey p, String permission, AccountGroup.UUID id, String ref)
+  protected void deny(Project.NameKey p, String ref, String permission, AccountGroup.UUID id)
       throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
     Util.deny(cfg, permission, id, ref);
     saveProjectConfig(p, cfg);
   }
 
-  protected PermissionRule block(String permission, AccountGroup.UUID id, String ref)
+  protected PermissionRule block(String ref, String permission, AccountGroup.UUID id)
       throws Exception {
-    return block(permission, id, ref, project);
+    return block(project, ref, permission, id);
   }
 
   protected PermissionRule block(
-      String permission, AccountGroup.UUID id, String ref, Project.NameKey project)
+      Project.NameKey project, String ref, String permission, AccountGroup.UUID id)
       throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
     PermissionRule rule = Util.block(cfg, permission, id, ref);
@@ -857,21 +848,21 @@
     saveProjectConfig(project, cfg);
   }
 
-  protected void grant(String permission, Project.NameKey project, String ref)
+  protected void grant(Project.NameKey project, String ref, String permission)
       throws RepositoryNotFoundException, IOException, ConfigInvalidException {
-    grant(permission, project, ref, false);
+    grant(project, ref, permission, false);
   }
 
-  protected void grant(String permission, Project.NameKey project, String ref, boolean force)
+  protected void grant(Project.NameKey project, String ref, String permission, boolean force)
       throws RepositoryNotFoundException, IOException, ConfigInvalidException {
     AccountGroup adminGroup = groupCache.get(new AccountGroup.NameKey("Administrators"));
-    grant(permission, project, ref, force, adminGroup.getGroupUUID());
+    grant(project, ref, permission, force, adminGroup.getGroupUUID());
   }
 
   protected void grant(
-      String permission,
       Project.NameKey project,
       String ref,
+      String permission,
       boolean force,
       AccountGroup.UUID groupUUID)
       throws RepositoryNotFoundException, IOException, ConfigInvalidException {
@@ -888,7 +879,7 @@
     }
   }
 
-  protected void removePermission(String permission, Project.NameKey project, String ref)
+  protected void removePermission(Project.NameKey project, String ref, String permission)
       throws IOException, ConfigInvalidException {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
       md.setMessage(String.format("Remove %s on %s", permission, ref));
@@ -902,7 +893,7 @@
   }
 
   protected void blockRead(String ref) throws Exception {
-    block(Permission.READ, REGISTERED_USERS, ref);
+    block(ref, Permission.READ, REGISTERED_USERS);
   }
 
   protected void blockForgeCommitter(Project.NameKey project, String ref) throws Exception {
@@ -1016,10 +1007,10 @@
   }
 
   protected void grantTagPermissions() throws Exception {
-    grant(Permission.CREATE, project, R_TAGS + "*");
-    grant(Permission.DELETE, project, R_TAGS + "");
-    grant(Permission.CREATE_TAG, project, R_TAGS + "*");
-    grant(Permission.CREATE_SIGNED_TAG, project, R_TAGS + "*");
+    grant(project, R_TAGS + "*", Permission.CREATE);
+    grant(project, R_TAGS + "", Permission.DELETE);
+    grant(project, R_TAGS + "*", Permission.CREATE_TAG);
+    grant(project, R_TAGS + "*", Permission.CREATE_SIGNED_TAG);
   }
 
   protected void assertMailReplyTo(Message message, String email) throws Exception {
@@ -1071,6 +1062,8 @@
   /**
    * Fetches each bundle into a newly cloned repository, then it applies the bundle, and returns the
    * resulting tree id.
+   *
+   * <p>Omits NoteDb meta refs.
    */
   protected Map<Branch.NameKey, ObjectId> fetchFromBundles(BinaryResult bundles) throws Exception {
     assertThat(bundles.getContentType()).isEqualTo("application/x-zip");
@@ -1104,11 +1097,12 @@
                   NullProgressMonitor.INSTANCE,
                   Arrays.asList(new RefSpec("refs/*:refs/preview/*")));
           for (Ref r : fr.getAdvertisedRefs()) {
-            String branchName = r.getName();
-            Branch.NameKey n = new Branch.NameKey(proj, branchName);
-
+            String refName = r.getName();
+            if (RefNames.isNoteDbMetaRef(refName)) {
+              continue;
+            }
             RevCommit c = localRepo.getRevWalk().parseCommit(r.getObjectId());
-            ret.put(n, c.getTree().copy());
+            ret.put(new Branch.NameKey(proj, refName), c.getTree().copy());
           }
         }
       }
@@ -1184,8 +1178,8 @@
   protected TestRepository<?> createProjectWithPush(
       String name, @Nullable Project.NameKey parent, SubmitType submitType) throws Exception {
     Project.NameKey project = createProject(name, parent, true, submitType);
-    grant(Permission.PUSH, project, "refs/heads/*");
-    grant(Permission.SUBMIT, project, "refs/for/refs/heads/*");
+    grant(project, "refs/heads/*", Permission.PUSH);
+    grant(project, "refs/for/refs/heads/*", Permission.SUBMIT);
     return cloneProject(project);
   }
 
@@ -1201,21 +1195,29 @@
   }
 
   protected void assertNotifyTo(TestAccount expected) {
+    assertNotifyTo(expected.emailAddress);
+  }
+
+  protected void assertNotifyTo(Address expected) {
     assertThat(sender.getMessages()).hasSize(1);
     Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(expected.emailAddress);
+    assertThat(m.rcpt()).containsExactly(expected);
     assertThat(((EmailHeader.AddressList) m.headers().get("To")).getAddressList())
-        .containsExactly(expected.emailAddress);
+        .containsExactly(expected);
     assertThat(m.headers().get("CC").isEmpty()).isTrue();
   }
 
   protected void assertNotifyCc(TestAccount expected) {
+    assertNotifyCc(expected.emailAddress);
+  }
+
+  protected void assertNotifyCc(Address expected) {
     assertThat(sender.getMessages()).hasSize(1);
     Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(expected.emailAddress);
+    assertThat(m.rcpt()).containsExactly(expected);
     assertThat(m.headers().get("To").isEmpty()).isTrue();
     assertThat(((EmailHeader.AddressList) m.headers().get("CC")).getAddressList())
-        .containsExactly(expected.emailAddress);
+        .containsExactly(expected);
   }
 
   protected void assertNotifyBcc(TestAccount expected) {
@@ -1237,4 +1239,18 @@
     projectsToWatch.add(pwi);
     gApi.accounts().self().setWatchedProjects(projectsToWatch);
   }
+
+  protected void assertContent(PushOneCommit.Result pushResult, String path, String expectedContent)
+      throws Exception {
+    BinaryResult bin =
+        gApi.changes()
+            .id(pushResult.getChangeId())
+            .revision(pushResult.getCommit().name())
+            .file(path)
+            .content();
+    ByteArrayOutputStream os = new ByteArrayOutputStream();
+    bin.writeTo(os);
+    String res = new String(os.toByteArray(), UTF_8);
+    assertThat(res).isEqualTo(expectedContent);
+  }
 }
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 e136bb3..d0f998c 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
@@ -18,6 +18,7 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.US_ASCII;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -25,10 +26,11 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountByEmailCache;
 import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIdsUpdate;
+import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.gerrit.testutil.SshMode;
@@ -51,6 +53,7 @@
   private final Map<String, TestAccount> accounts;
 
   private final SchemaFactory<ReviewDb> reviewDbProvider;
+  private final AccountsUpdate.Server accountsUpdate;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final GroupCache groupCache;
   private final SshKeyCache sshKeyCache;
@@ -62,6 +65,7 @@
   @Inject
   AccountCreator(
       SchemaFactory<ReviewDb> schema,
+      AccountsUpdate.Server accountsUpdate,
       VersionedAuthorizedKeys.Accessor authorizedKeys,
       GroupCache groupCache,
       SshKeyCache sshKeyCache,
@@ -71,6 +75,7 @@
       ExternalIdsUpdate.Server externalIdsUpdate) {
     accounts = new HashMap<>();
     reviewDbProvider = schema;
+    this.accountsUpdate = accountsUpdate;
     this.authorizedKeys = authorizedKeys;
     this.groupCache = groupCache;
     this.sshKeyCache = sshKeyCache;
@@ -81,7 +86,12 @@
   }
 
   public synchronized TestAccount create(
-      String username, String email, String fullName, String... groups) throws Exception {
+      @Nullable String username,
+      @Nullable String email,
+      @Nullable String fullName,
+      String... groups)
+      throws Exception {
+
     TestAccount account = accounts.get(username);
     if (account != null) {
       return account;
@@ -90,18 +100,21 @@
       Account.Id id = new Account.Id(db.nextAccountId());
 
       List<ExternalId> extIds = new ArrayList<>(2);
-      String httpPass = "http-pass";
-      extIds.add(ExternalId.createUsername(username, id, httpPass));
+      String httpPass = null;
+      if (username != null) {
+        httpPass = "http-pass";
+        extIds.add(ExternalId.createUsername(username, id, httpPass));
+      }
 
       if (email != null) {
         extIds.add(ExternalId.createEmail(id, email));
       }
-      externalIdsUpdate.create().insert(db, extIds);
+      externalIdsUpdate.create().insert(extIds);
 
       Account a = new Account(id, TimeUtil.nowTs());
       a.setFullName(fullName);
       a.setPreferredEmail(email);
-      db.accounts().insert(Collections.singleton(a));
+      accountsUpdate.create().insert(db, a);
 
       if (groups != null) {
         for (String n : groups) {
@@ -114,28 +127,36 @@
       }
 
       KeyPair sshKey = null;
-      if (SshMode.useSsh()) {
+      if (SshMode.useSsh() && username != null) {
         sshKey = genSshKey();
         authorizedKeys.addKey(id, publicKey(sshKey, email));
         sshKeyCache.evict(username);
       }
 
-      accountCache.evictByUsername(username);
+      if (username != null) {
+        accountCache.evictByUsername(username);
+      }
       byEmailCache.evict(email);
 
       indexer.index(id);
 
       account = new TestAccount(id, username, email, fullName, sshKey, httpPass);
-      accounts.put(username, account);
+      if (username != null) {
+        accounts.put(username, account);
+      }
       return account;
     }
   }
 
-  public TestAccount create(String username, String group) throws Exception {
+  public TestAccount create(@Nullable String username, String group) throws Exception {
     return create(username, null, username, group);
   }
 
-  public TestAccount create(String username) throws Exception {
+  public TestAccount create() throws Exception {
+    return create(null);
+  }
+
+  public TestAccount create(@Nullable String username) throws Exception {
     return create(username, null, username, (String[]) null);
   }
 
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritServer.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritServer.java
index 58cdf96..1e741a8 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritServer.java
@@ -45,7 +45,6 @@
 import java.net.URI;
 import java.nio.file.Paths;
 import java.util.concurrent.BrokenBarrierException;
-import java.util.concurrent.Callable;
 import java.util.concurrent.CyclicBarrier;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
@@ -133,17 +132,14 @@
   static GerritServer start(Description desc, Config baseConfig) throws Exception {
     Config cfg = desc.buildConfig(baseConfig);
     Logger.getLogger("com.google.gerrit").setLevel(Level.DEBUG);
-    final CyclicBarrier serverStarted = new CyclicBarrier(2);
-    final Daemon daemon =
+    CyclicBarrier serverStarted = new CyclicBarrier(2);
+    Daemon daemon =
         new Daemon(
-            new Runnable() {
-              @Override
-              public void run() {
-                try {
-                  serverStarted.await();
-                } catch (InterruptedException | BrokenBarrierException e) {
-                  throw new RuntimeException(e);
-                }
+            () -> {
+              try {
+                serverStarted.await();
+              } catch (InterruptedException | BrokenBarrierException e) {
+                throw new RuntimeException(e);
               }
             },
             Paths.get(baseConfig.getString("gerrit", null, "tempSiteDir")));
@@ -173,24 +169,17 @@
       @SuppressWarnings("unused")
       Future<?> possiblyIgnoredError =
           daemonService.submit(
-              new Callable<Void>() {
-                @Override
-                public Void call() throws Exception {
-                  int rc =
-                      daemon.main(
-                          new String[] {
-                            "-d",
-                            site.getPath(),
-                            "--headless",
-                            "--console-log",
-                            "--show-stack-trace",
-                          });
-                  if (rc != 0) {
-                    System.err.println("Failed to start Gerrit daemon");
-                    serverStarted.reset();
-                  }
-                  return null;
+              () -> {
+                int rc =
+                    daemon.main(
+                        new String[] {
+                          "-d", site.getPath(), "--headless", "--console-log", "--show-stack-trace",
+                        });
+                if (rc != 0) {
+                  System.err.println("Failed to start Gerrit daemon");
+                  serverStarted.reset();
                 }
+                return null;
               });
       serverStarted.await();
       System.out.println("Gerrit Server Started");
@@ -239,6 +228,7 @@
     cfg.setInt("sshd", null, "commandStartThreads", 1);
     cfg.setInt("receive", null, "threadPoolSize", 1);
     cfg.setInt("index", null, "threads", 1);
+    cfg.setBoolean("index", null, "reindexAfterRefUpdate", false);
   }
 
   private static Injector createTestInjector(Daemon daemon) throws Exception {
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestAccount.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestAccount.java
index 5117328..3ab4a88 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestAccount.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/TestAccount.java
@@ -16,12 +16,15 @@
 
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.net.InetAddresses;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.mail.Address;
 import com.jcraft.jsch.KeyPair;
 import java.io.ByteArrayOutputStream;
+import java.net.InetSocketAddress;
 import java.util.Arrays;
 import java.util.List;
+import org.apache.http.client.utils.URIBuilder;
 import org.eclipse.jgit.lib.PersonIdent;
 
 public class TestAccount {
@@ -77,12 +80,13 @@
   }
 
   public String getHttpUrl(GerritServer server) {
-    return String.format(
-        "http://%s:%s@%s:%d",
-        username,
-        httpPassword,
-        server.getHttpAddress().getAddress().getHostAddress(),
-        server.getHttpAddress().getPort());
+    InetSocketAddress addr = server.getHttpAddress();
+    return new URIBuilder()
+        .setScheme("http")
+        .setUserInfo(username, httpPassword)
+        .setHost(InetAddresses.toUriString(addr.getAddress()))
+        .setPort(addr.getPort())
+        .toString();
   }
 
   public Account.Id getId() {
diff --git a/gerrit-acceptance-tests/BUILD b/gerrit-acceptance-tests/BUILD
index 3875dc5..0ad9002 100644
--- a/gerrit-acceptance-tests/BUILD
+++ b/gerrit-acceptance-tests/BUILD
@@ -1,7 +1,10 @@
+RESOURCES = glob(["src/test/resources/**/*"])
+
 java_library(
     name = "lib",
     testonly = 1,
     srcs = ["src/test/java/com/google/gerrit/acceptance/Dummy.java"],
+    resources = RESOURCES,
     visibility = ["//visibility:public"],
     exports = [
         "//gerrit-acceptance-framework:lib",
@@ -16,9 +19,9 @@
         "//gerrit-pgm:pgm",
         "//gerrit-pgm:util",
         "//gerrit-reviewdb:server",
+        "//gerrit-server:prolog-common",
         "//gerrit-server:server",
         "//gerrit-server:testutil",
-        "//gerrit-server/src/main/prolog:common",
         "//gerrit-sshd:sshd",
         "//gerrit-test-util:test_util",
         "//lib:args4j",
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 2afc8c4..9ac5a70 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.acceptance.GitUtil.deleteRef;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
 import static com.google.gerrit.gpg.PublicKeyStore.REFS_GPG_KEYS;
 import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
@@ -26,6 +27,7 @@
 import static com.google.gerrit.gpg.testutil.TestKeys.validKeyWithoutExpiration;
 import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
 import static com.google.gerrit.server.StarredChangesUtil.IGNORE_LABEL;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -37,21 +39,26 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.io.BaseEncoding;
+import com.google.common.util.concurrent.AtomicLongMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AccountCreator;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.StarsInput;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.common.SshKeyInfo;
+import com.google.gerrit.extensions.events.AccountIndexedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -59,21 +66,23 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.gpg.Fingerprint;
 import com.google.gerrit.gpg.PublicKeyStore;
-import com.google.gerrit.gpg.server.GpgKeys;
 import com.google.gerrit.gpg.testutil.TestKey;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.account.AccountByEmailCache;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIdsUpdate;
 import com.google.gerrit.server.account.WatchConfig;
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
 import com.google.gerrit.server.project.RefPattern;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.FakeEmailSender.Message;
+import com.google.gerrit.testutil.SshMode;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.ByteArrayOutputStream;
@@ -96,7 +105,11 @@
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushCertificateIdent;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.junit.After;
 import org.junit.Assert;
 import org.junit.Before;
@@ -116,12 +129,31 @@
 
   @Inject private AccountByEmailCache byEmailCache;
 
+  @Inject private ExternalIds externalIds;
+
   @Inject private ExternalIdsUpdate.User externalIdsUpdateFactory;
 
+  @Inject private DynamicSet<AccountIndexedListener> accountIndexedListeners;
+
+  private AccountIndexedCounter accountIndexedCounter;
+  private RegistrationHandle accountIndexEventCounterHandle;
   private ExternalIdsUpdate externalIdsUpdate;
   private List<ExternalId> savedExternalIds;
 
   @Before
+  public void addAccountIndexEventCounter() {
+    accountIndexedCounter = new AccountIndexedCounter();
+    accountIndexEventCounterHandle = accountIndexedListeners.add(accountIndexedCounter);
+  }
+
+  @After
+  public void removeAccountIndexEventCounter() {
+    if (accountIndexEventCounterHandle != null) {
+      accountIndexEventCounterHandle.remove();
+    }
+  }
+
+  @Before
   public void saveExternalIds() throws Exception {
     externalIdsUpdate = externalIdsUpdateFactory.create();
 
@@ -136,9 +168,9 @@
       // savedExternalIds is null when we don't run SSH tests and the assume in
       // @Before in AbstractDaemonTest prevents this class' @Before method from
       // being executed.
-      externalIdsUpdate.delete(db, getExternalIds(admin));
-      externalIdsUpdate.delete(db, getExternalIds(user));
-      externalIdsUpdate.insert(db, savedExternalIds);
+      externalIdsUpdate.delete(getExternalIds(admin));
+      externalIdsUpdate.delete(getExternalIds(user));
+      externalIdsUpdate.insert(savedExternalIds);
     }
     accountCache.evict(admin.getId());
     accountCache.evict(user.getId());
@@ -176,11 +208,37 @@
   }
 
   @Test
+  public void create() throws Exception {
+    TestAccount foo = accounts.create("foo");
+    AccountInfo info = gApi.accounts().id(foo.id.get()).get();
+    assertThat(info.username).isEqualTo("foo");
+    if (SshMode.useSsh()) {
+      accountIndexedCounter.assertReindexOf(foo, 2); // account creation + adding SSH keys
+    } else {
+      accountIndexedCounter.assertReindexOf(foo, 1); // account creation
+    }
+
+    // check user branch
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      Ref ref = repo.exactRef(RefNames.refsUsers(foo.getId()));
+      assertThat(ref).isNotNull();
+      RevCommit c = rw.parseCommit(ref.getObjectId());
+      long timestampDiffMs =
+          Math.abs(
+              c.getCommitTime() * 1000L
+                  - accountCache.get(foo.getId()).getAccount().getRegisteredOn().getTime());
+      assertThat(timestampDiffMs).isAtMost(ChangeRebuilderImpl.MAX_WINDOW_MS);
+    }
+  }
+
+  @Test
   public void get() throws Exception {
     AccountInfo info = gApi.accounts().id("admin").get();
     assertThat(info.name).isEqualTo("Administrator");
     assertThat(info.email).isEqualTo("admin@example.com");
     assertThat(info.username).isEqualTo("admin");
+    accountIndexedCounter.assertNoReindex();
   }
 
   @Test
@@ -188,6 +246,7 @@
     AccountInfo info = gApi.accounts().id("admin").get();
     AccountInfo infoByIntId = gApi.accounts().id(info._accountId).get();
     assertThat(info.name).isEqualTo(infoByIntId.name);
+    accountIndexedCounter.assertNoReindex();
   }
 
   @Test
@@ -197,6 +256,7 @@
 
     info = gApi.accounts().id("self").get();
     assertUser(info, admin);
+    accountIndexedCounter.assertNoReindex();
   }
 
   @Test
@@ -204,8 +264,11 @@
     assertThat(gApi.accounts().id("user").getActive()).isTrue();
     gApi.accounts().id("user").setActive(false);
     assertThat(gApi.accounts().id("user").getActive()).isFalse();
+    accountIndexedCounter.assertReindexOf(user);
+
     gApi.accounts().id("user").setActive(true);
     assertThat(gApi.accounts().id("user").getActive()).isTrue();
+    accountIndexedCounter.assertReindexOf(user);
   }
 
   @Test
@@ -242,6 +305,7 @@
     change = info(triplet);
     assertThat(change.starred).isNull();
     assertThat(change.stars).isNull();
+    accountIndexedCounter.assertNoReindex();
   }
 
   @Test
@@ -282,6 +346,7 @@
     assertThat(starredChange._number).isEqualTo(r.getChange().getId().get());
     assertThat(starredChange.starred).isNull();
     assertThat(starredChange.stars).containsExactly("red", "yellow").inOrder();
+    accountIndexedCounter.assertNoReindex();
 
     setApiUser(user);
     exception.expect(AuthException.class);
@@ -322,13 +387,15 @@
 
   @Test
   public void ignoreChange() throws Exception {
+    TestAccount user2 = accounts.user2();
+    accountIndexedCounter.clear();
+
     PushOneCommit.Result r = createChange();
 
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = user.email;
     gApi.changes().id(r.getChangeId()).addReviewer(in);
 
-    TestAccount user2 = accounts.user2();
     in = new AddReviewerInput();
     in.reviewer = user2.email;
     gApi.changes().id(r.getChangeId()).addReviewer(in);
@@ -342,6 +409,7 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     assertThat(messages.get(0).rcpt()).containsExactly(user2.emailAddress);
+    accountIndexedCounter.assertNoReindex();
   }
 
   @Test
@@ -362,6 +430,7 @@
     Message message = messages.get(0);
     assertThat(message.rcpt()).containsExactly(user.emailAddress);
     assertMailReplyTo(message, admin.email);
+    accountIndexedCounter.assertNoReindex();
   }
 
   @Test
@@ -376,17 +445,12 @@
 
     List<AccountInfo> emptyResult = gApi.accounts().suggestAccounts("unknown").get();
     assertThat(emptyResult).isEmpty();
+    accountIndexedCounter.assertNoReindex();
   }
 
   @Test
   public void addEmail() throws Exception {
-    List<String> emails =
-        ImmutableList.of(
-            "new.email@example.com",
-            "new.email@example.systems",
-
-            // Not in the list of TLDs but added to override in OutgoingEmailValidator
-            "new.email@example.local");
+    List<String> emails = ImmutableList.of("new.email@example.com", "new.email@example.systems");
     Set<String> currentEmails = getEmails();
     for (String email : emails) {
       assertThat(currentEmails).doesNotContain(email);
@@ -394,6 +458,7 @@
       input.email = email;
       input.noConfirmation = true;
       gApi.accounts().self().addEmail(input);
+      accountIndexedCounter.assertReindexOf(admin);
     }
 
     resetCurrentApiUser();
@@ -414,7 +479,7 @@
             "@example.com",
 
             // Non-supported TLD  (see tlds-alpha-by-domain.txt)
-            "new.email@example.blog");
+            "new.email@example.africa");
     for (String email : emails) {
       EmailInput input = new EmailInput();
       input.email = email;
@@ -426,6 +491,18 @@
         assertThat(e).hasMessageThat().isEqualTo("invalid email address");
       }
     }
+    accountIndexedCounter.assertNoReindex();
+  }
+
+  @Test
+  public void cannotAddNonConfirmedEmailWithoutModifyAccountPermission() throws Exception {
+    TestAccount account = accounts.create(name("user"));
+    EmailInput input = new EmailInput();
+    input.email = "test@test.com";
+    input.noConfirmation = true;
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    gApi.accounts().id(account.username).addEmail(input);
   }
 
   @Test
@@ -439,7 +516,9 @@
     resetCurrentApiUser();
     assertThat(getEmails()).contains(email);
 
+    accountIndexedCounter.clear();
     gApi.accounts().self().deleteEmail(input.email);
+    accountIndexedCounter.assertReindexOf(admin);
 
     resetCurrentApiUser();
     assertThat(getEmails()).doesNotContain(email);
@@ -454,8 +533,9 @@
         ImmutableList.of(
             ExternalId.createWithEmail(ExternalId.Key.parse(extId1), admin.id, email),
             ExternalId.createWithEmail(ExternalId.Key.parse(extId2), admin.id, email));
-    externalIdsUpdateFactory.create().insert(db, extIds);
+    externalIdsUpdateFactory.create().insert(extIds);
     accountCache.evict(admin.id);
+    accountIndexedCounter.assertReindexOf(admin);
     assertThat(
             gApi.accounts().self().getExternalIds().stream().map(e -> e.identity).collect(toSet()))
         .containsAllOf(extId1, extId2);
@@ -464,6 +544,7 @@
     assertThat(getEmails()).contains(email);
 
     gApi.accounts().self().deleteEmail(email);
+    accountIndexedCounter.assertReindexOf(admin, 2); // for each deleted external ID once
 
     resetCurrentApiUser();
     assertThat(getEmails()).doesNotContain(email);
@@ -479,6 +560,7 @@
     input.email = email;
     input.noConfirmation = true;
     gApi.accounts().id(user.id.get()).addEmail(input);
+    accountIndexedCounter.assertReindexOf(user);
 
     setApiUser(user);
     assertThat(getEmails()).contains(email);
@@ -486,13 +568,14 @@
     // admin can delete email of user
     setApiUser(admin);
     gApi.accounts().id(user.id.get()).deleteEmail(email);
+    accountIndexedCounter.assertReindexOf(user);
 
     setApiUser(user);
     assertThat(getEmails()).doesNotContain(email);
 
     // user cannot delete email of admin
     exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to delete email address");
+    exception.expectMessage("modify account not permitted");
     gApi.accounts().id(admin.id.get()).deleteEmail(admin.email);
   }
 
@@ -505,7 +588,7 @@
     String email = "foo.bar@example.com";
     externalIdsUpdateFactory
         .create()
-        .insert(db, ExternalId.createWithEmail(ExternalId.Key.parse("foo:bar"), admin.id, email));
+        .insert(ExternalId.createWithEmail(ExternalId.Key.parse("foo:bar"), admin.id, email));
     accountCache.evict(admin.id);
     assertEmail(byEmailCache.get(email), admin);
 
@@ -528,17 +611,14 @@
       admin.status = status;
       info = gApi.accounts().self().get();
       assertUser(info, admin);
+      accountIndexedCounter.assertReindexOf(admin);
     }
   }
 
   @Test
+  @Sandboxed
   public void fetchUserBranch() throws Exception {
-    // change something in the user preferences to ensure that the user branch
-    // is created
     setApiUser(user);
-    GeneralPreferencesInfo input = new GeneralPreferencesInfo();
-    input.changesPerPage = GeneralPreferencesInfo.defaults().changesPerPage + 10;
-    gApi.accounts().self().setPreferences(input);
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, user);
     String userRefName = RefNames.refsUsers(user.id);
@@ -550,7 +630,7 @@
     saveProjectConfig(allUsers, cfg);
 
     // deny READ permission that is inherited from All-Projects
-    deny(allUsers, Permission.READ, ANONYMOUS_USERS, RefNames.REFS + "*");
+    deny(allUsers, RefNames.REFS + "*", Permission.READ, ANONYMOUS_USERS);
 
     // fetching user branch without READ permission fails
     try {
@@ -562,9 +642,9 @@
 
     // allow each user to read its own user branch
     grant(
-        Permission.READ,
         allUsers,
         RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
+        Permission.READ,
         false,
         REGISTERED_USERS);
 
@@ -579,6 +659,8 @@
     assertThat(userSelfRef).isNotNull();
     assertThat(userSelfRef.getObjectId()).isEqualTo(userRef.getObjectId());
 
+    accountIndexedCounter.assertNoReindex();
+
     // fetching user branch of another user fails
     String otherUserRefName = RefNames.refsUsers(admin.id);
     exception.expect(TransportException.class);
@@ -588,30 +670,20 @@
 
   @Test
   public void pushToUserBranch() throws Exception {
-    // change something in the user preferences to ensure that the user branch
-    // is created
-    GeneralPreferencesInfo input = new GeneralPreferencesInfo();
-    input.changesPerPage = GeneralPreferencesInfo.defaults().changesPerPage + 10;
-    gApi.accounts().self().setPreferences(input);
-
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
     allUsersRepo.reset("userRef");
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
     push.to(RefNames.refsUsers(admin.id)).assertOkStatus();
+    accountIndexedCounter.assertReindexOf(admin);
 
     push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
     push.to(RefNames.REFS_USERS_SELF).assertOkStatus();
+    accountIndexedCounter.assertReindexOf(admin);
   }
 
   @Test
   public void pushToUserBranchForReview() throws Exception {
-    // change something in the user preferences to ensure that the user branch
-    // is created
-    GeneralPreferencesInfo input = new GeneralPreferencesInfo();
-    input.changesPerPage = GeneralPreferencesInfo.defaults().changesPerPage + 10;
-    gApi.accounts().self().setPreferences(input);
-
     String userRefName = RefNames.refsUsers(admin.id);
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, userRefName + ":userRef");
@@ -619,26 +691,24 @@
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
     PushOneCommit.Result r = push.to(MagicBranch.NEW_CHANGE + userRefName);
     r.assertOkStatus();
+    accountIndexedCounter.assertNoReindex();
     assertThat(r.getChange().change().getDest().get()).isEqualTo(userRefName);
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(r.getChangeId()).current().submit();
+    accountIndexedCounter.assertReindexOf(admin);
 
     push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
     r = push.to(MagicBranch.NEW_CHANGE + RefNames.REFS_USERS_SELF);
     r.assertOkStatus();
+    accountIndexedCounter.assertNoReindex();
     assertThat(r.getChange().change().getDest().get()).isEqualTo(userRefName);
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(r.getChangeId()).current().submit();
+    accountIndexedCounter.assertReindexOf(admin);
   }
 
   @Test
   public void pushWatchConfigToUserBranch() throws Exception {
-    // change something in the user preferences to ensure that the user branch
-    // is created
-    GeneralPreferencesInfo input = new GeneralPreferencesInfo();
-    input.changesPerPage = GeneralPreferencesInfo.defaults().changesPerPage + 10;
-    gApi.accounts().self().setPreferences(input);
-
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
     allUsersRepo.reset("userRef");
@@ -658,6 +728,7 @@
             WatchConfig.WATCH_CONFIG,
             wc.toText());
     push.to(RefNames.REFS_USERS_SELF).assertOkStatus();
+    accountIndexedCounter.assertReindexOf(admin);
 
     String invalidNotifyValue = "]invalid[";
     wc.setString(WatchConfig.PROJECT, project.get(), WatchConfig.KEY_NOTIFY, invalidNotifyValue);
@@ -678,6 +749,52 @@
   }
 
   @Test
+  @Sandboxed
+  public void cannotDeleteUserBranch() throws Exception {
+    grant(
+        allUsers,
+        RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
+        Permission.DELETE,
+        true,
+        REGISTERED_USERS);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    String userRef = RefNames.refsUsers(admin.id);
+    PushResult r = deleteRef(allUsersRepo, userRef);
+    RemoteRefUpdate refUpdate = r.getRemoteUpdate(userRef);
+    assertThat(refUpdate.getStatus()).isEqualTo(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
+    assertThat(refUpdate.getMessage()).contains("Not allowed to delete user branch.");
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(userRef)).isNotNull();
+    }
+  }
+
+  @Test
+  @Sandboxed
+  public void deleteUserBranchWithAccessDatabaseCapability() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    grant(
+        allUsers,
+        RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
+        Permission.DELETE,
+        true,
+        REGISTERED_USERS);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    String userRef = RefNames.refsUsers(admin.id);
+    PushResult r = deleteRef(allUsersRepo, userRef);
+    RemoteRefUpdate refUpdate = r.getRemoteUpdate(userRef);
+    assertThat(refUpdate.getStatus()).isEqualTo(RemoteRefUpdate.Status.OK);
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(repo.exactRef(userRef)).isNull();
+    }
+
+    // TODO(ekempin): assert that account was deleted from cache and index
+  }
+
+  @Test
   public void addGpgKey() throws Exception {
     TestKey key = validKeyWithoutExpiration();
     String id = key.getKeyIdString();
@@ -713,8 +830,9 @@
   public void addOtherUsersGpgKey_Conflict() throws Exception {
     // Both users have a matching external ID for this key.
     addExternalIdEmail(admin, "test5@example.com");
-    externalIdsUpdate.insert(db, ExternalId.create("foo", "myId", user.getId()));
+    externalIdsUpdate.insert(ExternalId.create("foo", "myId", user.getId()));
     accountCache.evict(user.getId());
+    accountIndexedCounter.assertReindexOf(user);
 
     TestKey key = validKeyWithSecondUserId();
     addGpgKey(key.getPublicKeyArmored());
@@ -735,6 +853,7 @@
     }
     gApi.accounts().self().putGpgKeys(toAdd, ImmutableList.<String>of());
     assertKeys(keys);
+    accountIndexedCounter.assertReindexOf(admin);
   }
 
   @Test
@@ -746,6 +865,7 @@
     assertKeys(key);
 
     gApi.accounts().self().gpgKey(id).delete();
+    accountIndexedCounter.assertReindexOf(admin);
     assertKeys();
 
     exception.expect(ResourceNotFoundException.class);
@@ -770,6 +890,7 @@
                 ImmutableList.of(key5.getKeyIdString()));
     assertThat(infos.keySet()).containsExactly(key1.getKeyIdString(), key2.getKeyIdString());
     assertKeys(key1, key2);
+    accountIndexedCounter.assertReindexOf(admin);
 
     infos =
         gApi.accounts()
@@ -781,6 +902,7 @@
     assertKeyMapContains(key5, infos);
     assertThat(infos.get(key1.getKeyIdString()).key).isNull();
     assertKeys(key2, key5);
+    accountIndexedCounter.assertReindexOf(admin);
 
     exception.expect(BadRequestException.class);
     exception.expectMessage("Cannot both add and delete key: " + keyToString(key2.getPublicKey()));
@@ -803,6 +925,7 @@
     SshKeyInfo key = info.get(0);
     String inital = AccountCreator.publicKey(admin.sshKey, admin.email);
     assertThat(key.sshPublicKey).isEqualTo(inital);
+    accountIndexedCounter.assertNoReindex();
 
     // Add a new key
     String newKey = AccountCreator.publicKey(AccountCreator.genSshKey(), admin.email);
@@ -810,12 +933,14 @@
     info = gApi.accounts().self().listSshKeys();
     assertThat(info).hasSize(2);
     assertSequenceNumbers(info);
+    accountIndexedCounter.assertReindexOf(admin);
 
     // Add an existing key (the request succeeds, but the key isn't added again)
     gApi.accounts().self().addSshKey(inital);
     info = gApi.accounts().self().listSshKeys();
     assertThat(info).hasSize(2);
     assertSequenceNumbers(info);
+    accountIndexedCounter.assertNoReindex();
 
     // Add another new key
     String newKey2 = AccountCreator.publicKey(AccountCreator.genSshKey(), admin.email);
@@ -823,6 +948,7 @@
     info = gApi.accounts().self().listSshKeys();
     assertThat(info).hasSize(3);
     assertSequenceNumbers(info);
+    accountIndexedCounter.assertReindexOf(admin);
 
     // Delete second key
     gApi.accounts().self().deleteSshKey(2);
@@ -830,6 +956,7 @@
     assertThat(info).hasSize(2);
     assertThat(info.get(0).seq).isEqualTo(1);
     assertThat(info.get(1).seq).isEqualTo(3);
+    accountIndexedCounter.assertReindexOf(admin);
   }
 
   // reindex is tested by {@link AbstractQueryAccountsTest#reindex}
@@ -838,14 +965,16 @@
     // admin can reindex any account
     setApiUser(admin);
     gApi.accounts().id(user.username).index();
+    accountIndexedCounter.assertReindexOf(user);
 
     // user can reindex own account
     setApiUser(user);
     gApi.accounts().self().index();
+    accountIndexedCounter.assertReindexOf(user);
 
     // user cannot reindex any account
     exception.expect(AuthException.class);
-    exception.expectMessage("not allowed to index account");
+    exception.expectMessage("modify account not permitted");
     gApi.accounts().id(admin.username).index();
   }
 
@@ -913,7 +1042,11 @@
     Iterable<String> expectedFps =
         expected.transform(k -> BaseEncoding.base16().encode(k.getPublicKey().getFingerprint()));
     Iterable<String> actualFps =
-        GpgKeys.getGpgExtIds(db, currAccountId).transform(e -> e.key().id());
+        externalIds
+            .byAccount(currAccountId, SCHEME_GPGKEY)
+            .stream()
+            .map(e -> e.key().id())
+            .collect(toSet());
     assertThat(actualFps).named("external IDs in database").containsExactlyElementsIn(expectedFps);
 
     // Check raw stored keys.
@@ -939,14 +1072,18 @@
   private void addExternalIdEmail(TestAccount account, String email) throws Exception {
     checkNotNull(email);
     externalIdsUpdate.insert(
-        db, ExternalId.createWithEmail(name("test"), email, account.getId(), email));
+        ExternalId.createWithEmail(name("test"), email, account.getId(), email));
     // Clear saved AccountState and ExternalIds.
     accountCache.evict(account.getId());
+    accountIndexedCounter.assertReindexOf(account);
     setApiUser(account);
   }
 
   private Map<String, GpgKeyInfo> addGpgKey(String armored) throws Exception {
-    return gApi.accounts().self().putGpgKeys(ImmutableList.of(armored), ImmutableList.<String>of());
+    Map<String, GpgKeyInfo> gpgKeys =
+        gApi.accounts().self().putGpgKeys(ImmutableList.of(armored), ImmutableList.<String>of());
+    accountIndexedCounter.assertReindexOf(gApi.accounts().self().get());
+    return gpgKeys;
   }
 
   private void assertUser(AccountInfo info, TestAccount account) throws Exception {
@@ -964,4 +1101,44 @@
     assertThat(accounts).hasSize(1);
     assertThat(Iterables.getOnlyElement(accounts)).isEqualTo(expectedAccount.getId());
   }
+
+  private static class AccountIndexedCounter implements AccountIndexedListener {
+    private final AtomicLongMap<Integer> countsByAccount = AtomicLongMap.create();
+
+    @Override
+    public void onAccountIndexed(int id) {
+      countsByAccount.incrementAndGet(id);
+    }
+
+    void clear() {
+      countsByAccount.clear();
+    }
+
+    long getCount(Account.Id accountId) {
+      return countsByAccount.get(accountId.get());
+    }
+
+    void assertReindexOf(TestAccount testAccount) {
+      assertReindexOf(testAccount, 1);
+    }
+
+    void assertReindexOf(AccountInfo accountInfo) {
+      assertReindexOf(new Account.Id(accountInfo._accountId), 1);
+    }
+
+    void assertReindexOf(TestAccount testAccount, int expectedCount) {
+      assertThat(getCount(testAccount.id)).isEqualTo(expectedCount);
+      assertThat(countsByAccount).hasSize(1);
+      clear();
+    }
+
+    void assertReindexOf(Account.Id accountId, int expectedCount) {
+      assertThat(getCount(accountId)).isEqualTo(expectedCount);
+      countsByAccount.remove(accountId.get());
+    }
+
+    void assertNoReindex() {
+      assertThat(countsByAccount).isEmpty();
+    }
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/AbandonIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/AbandonIT.java
new file mode 100644
index 0000000..cb9d705
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/AbandonIT.java
@@ -0,0 +1,203 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.util.concurrent.TimeUnit.HOURS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
+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.common.data.Permission;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.change.AbandonUtil;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.testutil.TestTimeUtil;
+import com.google.inject.Inject;
+import java.util.List;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.Test;
+
+public class AbandonIT extends AbstractDaemonTest {
+  @Inject private AbandonUtil abandonUtil;
+
+  @Test
+  public void abandon() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+    gApi.changes().id(changeId).abandon();
+    ChangeInfo info = get(changeId);
+    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("change is abandoned");
+    gApi.changes().id(changeId).abandon();
+  }
+
+  @Test
+  public void batchAbandon() throws Exception {
+    CurrentUser user = atrScope.get().getUser();
+    PushOneCommit.Result a = createChange();
+    List<ChangeControl> controlA = changeFinder.find(a.getChangeId(), user);
+    assertThat(controlA).hasSize(1);
+    PushOneCommit.Result b = createChange();
+    List<ChangeControl> controlB = changeFinder.find(b.getChangeId(), user);
+    assertThat(controlB).hasSize(1);
+    List<ChangeControl> list = ImmutableList.of(controlA.get(0), controlB.get(0));
+    changeAbandoner.batchAbandon(
+        batchUpdateFactory, controlA.get(0).getProject().getNameKey(), user, list, "deadbeef");
+
+    ChangeInfo info = get(a.getChangeId());
+    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("deadbeef");
+
+    info = get(b.getChangeId());
+    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("deadbeef");
+  }
+
+  @Test
+  public void batchAbandonChangeProject() throws Exception {
+    String project1Name = name("Project1");
+    String project2Name = name("Project2");
+    gApi.projects().create(project1Name);
+    gApi.projects().create(project2Name);
+    TestRepository<InMemoryRepository> project1 = cloneProject(new Project.NameKey(project1Name));
+    TestRepository<InMemoryRepository> project2 = cloneProject(new Project.NameKey(project2Name));
+
+    CurrentUser user = atrScope.get().getUser();
+    PushOneCommit.Result a = createChange(project1, "master", "x", "x", "x", "");
+    List<ChangeControl> controlA = changeFinder.find(a.getChangeId(), user);
+    assertThat(controlA).hasSize(1);
+    PushOneCommit.Result b = createChange(project2, "master", "x", "x", "x", "");
+    List<ChangeControl> controlB = changeFinder.find(b.getChangeId(), user);
+    assertThat(controlB).hasSize(1);
+    List<ChangeControl> list = ImmutableList.of(controlA.get(0), controlB.get(0));
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage(
+        String.format("Project name \"%s\" doesn't match \"%s\"", project2Name, project1Name));
+    changeAbandoner.batchAbandon(batchUpdateFactory, new Project.NameKey(project1Name), user, list);
+  }
+
+  @Test
+  public void abandonDraft() throws Exception {
+    PushOneCommit.Result r = createDraftChange();
+    String changeId = r.getChangeId();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.DRAFT);
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("draft changes cannot be abandoned");
+    gApi.changes().id(changeId).abandon();
+  }
+
+  @Test
+  @GerritConfig(name = "changeCleanup.abandonAfter", value = "1w")
+  public void abandonInactiveOpenChanges() throws Exception {
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+
+    // create 2 changes which will be abandoned ...
+    int id1 = createChange().getChange().getId().get();
+    int id2 = createChange().getChange().getId().get();
+
+    // ... because they are older than 1 week
+    TestTimeUtil.incrementClock(7 * 24, HOURS);
+
+    // create 1 new change that will not be abandoned
+    ChangeData cd = createChange().getChange();
+    int id3 = cd.getId().get();
+
+    assertThat(toChangeNumbers(query("is:open"))).containsExactly(id1, id2, id3);
+    assertThat(query("is:abandoned")).isEmpty();
+
+    abandonUtil.abandonInactiveOpenChanges(batchUpdateFactory);
+    assertThat(toChangeNumbers(query("is:open"))).containsExactly(id3);
+    assertThat(toChangeNumbers(query("is:abandoned"))).containsExactly(id1, id2);
+  }
+
+  @Test
+  public void abandonNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("abandon not permitted");
+    gApi.changes().id(changeId).abandon();
+  }
+
+  @Test
+  public void abandonAndRestoreAllowedWithPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+    grant(project, "refs/heads/master", Permission.ABANDON, false, REGISTERED_USERS);
+    setApiUser(user);
+    gApi.changes().id(changeId).abandon();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.ABANDONED);
+    gApi.changes().id(changeId).restore();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+  }
+
+  @Test
+  public void restore() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+    gApi.changes().id(changeId).abandon();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.ABANDONED);
+
+    gApi.changes().id(changeId).restore();
+    ChangeInfo info = get(changeId);
+    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("restored");
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("change is new");
+    gApi.changes().id(changeId).restore();
+  }
+
+  @Test
+  public void restoreNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+    gApi.changes().id(changeId).abandon();
+    setApiUser(user);
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.ABANDONED);
+    exception.expect(AuthException.class);
+    exception.expectMessage("restore not permitted");
+    gApi.changes().id(changeId).restore();
+  }
+
+  private List<Integer> toChangeNumbers(List<ChangeInfo> changes) {
+    return changes.stream().map(i -> i._number).collect(toList());
+  }
+}
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 c92006b..dbe4a6c 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
@@ -54,6 +54,7 @@
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -64,9 +65,11 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.Comment.Range;
+import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
@@ -90,7 +93,6 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 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.AccountGroup;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -100,7 +102,7 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 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.ChangeMessagesUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
 import com.google.gerrit.server.git.ChangeMessageModifier;
@@ -142,8 +144,6 @@
 public class ChangeIT extends AbstractDaemonTest {
   private String systemTimeZone;
 
-  @Inject private BatchUpdate.Factory updateFactory;
-
   @Inject private DynamicSet<ChangeMessageModifier> changeMessageModifiers;
 
   @Before
@@ -181,6 +181,223 @@
   }
 
   @Test
+  public void setPrivateByOwner() throws Exception {
+    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
+    PushOneCommit.Result result =
+        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
+
+    setApiUser(user);
+    String changeId = result.getChangeId();
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+
+    gApi.changes().id(changeId).setPrivate(true, null);
+    ChangeInfo info = gApi.changes().id(changeId).get();
+    assertThat(info.isPrivate).isTrue();
+    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Set private");
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_PRIVATE);
+
+    gApi.changes().id(changeId).setPrivate(false, null);
+    info = gApi.changes().id(changeId).get();
+    assertThat(info.isPrivate).isNull();
+    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Unset private");
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_UNSET_PRIVATE);
+
+    String msg = "This is a security fix that must not be public.";
+    gApi.changes().id(changeId).setPrivate(true, msg);
+    info = gApi.changes().id(changeId).get();
+    assertThat(info.isPrivate).isTrue();
+    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Set private\n\n" + msg);
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_PRIVATE);
+
+    msg = "After this security fix has been released we can make it public now.";
+    gApi.changes().id(changeId).setPrivate(false, msg);
+    info = gApi.changes().id(changeId).get();
+    assertThat(info.isPrivate).isNull();
+    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Unset private\n\n" + msg);
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_UNSET_PRIVATE);
+  }
+
+  @Test
+  public void administratorCanSetUserChangePrivate() throws Exception {
+    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
+    PushOneCommit.Result result =
+        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
+
+    String changeId = result.getChangeId();
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+
+    gApi.changes().id(changeId).setPrivate(true, null);
+    setApiUser(user);
+    ChangeInfo info = gApi.changes().id(changeId).get();
+    assertThat(info.isPrivate).isTrue();
+  }
+
+  @Test
+  public void cannotSetOtherUsersChangePrivate() throws Exception {
+    PushOneCommit.Result result = createChange();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to mark private");
+    gApi.changes().id(result.getChangeId()).setPrivate(true, null);
+  }
+
+  @Test
+  public void accessPrivate() throws Exception {
+    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
+    PushOneCommit.Result result =
+        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
+
+    setApiUser(user);
+    gApi.changes().id(result.getChangeId()).setPrivate(true, null);
+    // Owner can always access its private changes.
+    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
+
+    // Add admin as a reviewer.
+    gApi.changes().id(result.getChangeId()).addReviewer(admin.getId().toString());
+
+    // This change should be visible for admin as a reviewer.
+    setApiUser(admin);
+    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
+
+    // Remove admin from reviewers.
+    gApi.changes().id(result.getChangeId()).reviewer(admin.getId().toString()).remove();
+
+    // This change should not be visible for admin anymore.
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage("Not found: " + result.getChangeId());
+    gApi.changes().id(result.getChangeId());
+  }
+
+  @Test
+  public void privateChangeOfOtherUserCanBeAccessedWithPermission() throws Exception {
+    PushOneCommit.Result result = createChange();
+    gApi.changes().id(result.getChangeId()).setPrivate(true, null);
+
+    allow(Permission.VIEW_PRIVATE_CHANGES, REGISTERED_USERS, "refs/*");
+    setApiUser(user);
+    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
+  }
+
+  @Test
+  public void administratorCanUnmarkPrivateAfterMerging() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    gApi.changes().id(changeId).setPrivate(true, null);
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isTrue();
+    merge(result);
+    gApi.changes().id(changeId).setPrivate(false, null);
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+  }
+
+  @Test
+  public void administratorCanMarkPrivateAfterMerging() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+    merge(result);
+    gApi.changes().id(changeId).setPrivate(true, null);
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isTrue();
+  }
+
+  @Test
+  public void userCannotMarkPrivateAfterMerging() throws Exception {
+    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
+    PushOneCommit.Result result =
+        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
+
+    String changeId = result.getChangeId();
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+
+    merge(result);
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to mark private");
+    gApi.changes().id(changeId).setPrivate(true, null);
+  }
+
+  @Test
+  public void userCannotUnmarkPrivateAfterMerging() throws Exception {
+    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
+    PushOneCommit.Result result =
+        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
+
+    String changeId = result.getChangeId();
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isNull();
+    gApi.changes().id(changeId).addReviewer(admin.getId().toString());
+    gApi.changes().id(changeId).setPrivate(true, null);
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isTrue();
+
+    merge(result);
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to unmark private");
+    gApi.changes().id(changeId).setPrivate(false, null);
+  }
+
+  @Test
+  public void setWorkInProgressNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result rwip = createChange();
+    String changeId = rwip.getChangeId();
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to set work in progress");
+    gApi.changes().id(changeId).setWorkInProgress();
+  }
+
+  @Test
+  public void setReadyForReviewNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result rready = createChange();
+    String changeId = rready.getChangeId();
+    gApi.changes().id(changeId).setWorkInProgress();
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to set ready for review");
+    gApi.changes().id(changeId).setReadyForReview();
+  }
+
+  @Test
+  public void toggleWorkInProgressState() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // With message
+    gApi.changes().id(changeId).setWorkInProgress("Needs some refactoring");
+
+    ChangeInfo info = gApi.changes().id(changeId).get();
+
+    assertThat(info.workInProgress).isTrue();
+    assertThat(Iterables.getLast(info.messages).message).contains("Needs some refactoring");
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_WIP);
+
+    gApi.changes().id(changeId).setReadyForReview("PTAL");
+
+    info = gApi.changes().id(changeId).get();
+    assertThat(info.workInProgress).isNull();
+    assertThat(Iterables.getLast(info.messages).message).contains("PTAL");
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_READY);
+
+    // No message
+    gApi.changes().id(changeId).setWorkInProgress();
+
+    info = gApi.changes().id(changeId).get();
+
+    assertThat(info.workInProgress).isTrue();
+    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Set Work In Progress");
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_WIP);
+
+    gApi.changes().id(changeId).setReadyForReview();
+
+    info = gApi.changes().id(changeId).get();
+    assertThat(info.workInProgress).isNull();
+    assertThat(Iterables.getLast(info.messages).message).isEqualTo("Set Ready For Review");
+    assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_READY);
+  }
+
+  @Test
   public void getAmbiguous() throws Exception {
     PushOneCommit.Result r1 = createChange();
     String changeId = r1.getChangeId();
@@ -208,96 +425,6 @@
   }
 
   @Test
-  public void abandon() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-    gApi.changes().id(changeId).abandon();
-    ChangeInfo info = get(changeId);
-    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
-    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("change is abandoned");
-    gApi.changes().id(changeId).abandon();
-  }
-
-  @Test
-  public void batchAbandon() throws Exception {
-    CurrentUser user = atrScope.get().getUser();
-    PushOneCommit.Result a = createChange();
-    List<ChangeControl> controlA = changeFinder.find(a.getChangeId(), user);
-    assertThat(controlA).hasSize(1);
-    PushOneCommit.Result b = createChange();
-    List<ChangeControl> controlB = changeFinder.find(b.getChangeId(), user);
-    assertThat(controlB).hasSize(1);
-    List<ChangeControl> list = ImmutableList.of(controlA.get(0), controlB.get(0));
-    changeAbandoner.batchAbandon(controlA.get(0).getProject().getNameKey(), user, list, "deadbeef");
-
-    ChangeInfo info = get(a.getChangeId());
-    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
-    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
-    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("deadbeef");
-
-    info = get(b.getChangeId());
-    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
-    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
-    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("deadbeef");
-  }
-
-  @Test
-  public void batchAbandonChangeProject() throws Exception {
-    String project1Name = name("Project1");
-    String project2Name = name("Project2");
-    gApi.projects().create(project1Name);
-    gApi.projects().create(project2Name);
-    TestRepository<InMemoryRepository> project1 = cloneProject(new Project.NameKey(project1Name));
-    TestRepository<InMemoryRepository> project2 = cloneProject(new Project.NameKey(project2Name));
-
-    CurrentUser user = atrScope.get().getUser();
-    PushOneCommit.Result a = createChange(project1, "master", "x", "x", "x", "");
-    List<ChangeControl> controlA = changeFinder.find(a.getChangeId(), user);
-    assertThat(controlA).hasSize(1);
-    PushOneCommit.Result b = createChange(project2, "master", "x", "x", "x", "");
-    List<ChangeControl> controlB = changeFinder.find(b.getChangeId(), user);
-    assertThat(controlB).hasSize(1);
-    List<ChangeControl> list = ImmutableList.of(controlA.get(0), controlB.get(0));
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage(
-        String.format("Project name \"%s\" doesn't match \"%s\"", project2Name, project1Name));
-    changeAbandoner.batchAbandon(new Project.NameKey(project1Name), user, list);
-  }
-
-  @Test
-  public void abandonDraft() throws Exception {
-    PushOneCommit.Result r = createDraftChange();
-    String changeId = r.getChangeId();
-    assertThat(info(changeId).status).isEqualTo(ChangeStatus.DRAFT);
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("draft changes cannot be abandoned");
-    gApi.changes().id(changeId).abandon();
-  }
-
-  @Test
-  public void restore() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-    gApi.changes().id(changeId).abandon();
-    assertThat(info(changeId).status).isEqualTo(ChangeStatus.ABANDONED);
-
-    gApi.changes().id(changeId).restore();
-    ChangeInfo info = get(changeId);
-    assertThat(info.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("restored");
-
-    exception.expect(ResourceConflictException.class);
-    exception.expectMessage("change is new");
-    gApi.changes().id(changeId).restore();
-  }
-
-  @Test
   public void revert() throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
@@ -415,7 +542,7 @@
     revision.review(ReviewInput.approve());
     revision.submit();
 
-    grant(Permission.REBASE, project, "refs/heads/master", false, REGISTERED_USERS);
+    grant(project, "refs/heads/master", Permission.REBASE, false, REGISTERED_USERS);
 
     // Rebase the second
     String changeId = r2.getChangeId();
@@ -424,6 +551,50 @@
   }
 
   @Test
+  public void rebaseNotAllowedWithoutPushPermission() throws Exception {
+    // Create two changes both with the same parent
+    PushOneCommit.Result r = createChange();
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
+
+    // Approve and submit the first change
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    revision.review(ReviewInput.approve());
+    revision.submit();
+
+    grant(project, "refs/heads/master", Permission.REBASE, false, REGISTERED_USERS);
+    block("refs/for/*", Permission.PUSH, REGISTERED_USERS);
+
+    // Rebase the second
+    String changeId = r2.getChangeId();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("rebase not permitted");
+    gApi.changes().id(changeId).rebase();
+  }
+
+  @Test
+  public void rebaseNotAllowedForOwnerWithoutPushPermission() throws Exception {
+    // Create two changes both with the same parent
+    PushOneCommit.Result r = createChange();
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
+
+    // Approve and submit the first change
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    revision.review(ReviewInput.approve());
+    revision.submit();
+
+    block("refs/for/*", Permission.PUSH, REGISTERED_USERS);
+
+    // Rebase the second
+    String changeId = r2.getChangeId();
+    exception.expect(AuthException.class);
+    exception.expectMessage("rebase not permitted");
+    gApi.changes().id(changeId).rebase();
+  }
+
+  @Test
   public void publish() throws Exception {
     PushOneCommit.Result r = createChange("refs/drafts/master");
     assertThat(info(r.getChangeId()).status).isEqualTo(ChangeStatus.DRAFT);
@@ -456,11 +627,10 @@
     PushOneCommit.Result changeResult =
         pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
     String changeId = changeResult.getChangeId();
-    Change.Id id = changeResult.getChange().getId();
 
     setApiUser(user);
     exception.expect(AuthException.class);
-    exception.expectMessage(String.format("Deleting change %s is not permitted", id));
+    exception.expectMessage("delete not permitted");
     gApi.changes().id(changeId).delete();
   }
 
@@ -473,13 +643,18 @@
       PushOneCommit.Result changeResult =
           pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
       String changeId = changeResult.getChangeId();
+      int id = changeResult.getChange().getId().id;
+      RevCommit commit = changeResult.getCommit();
 
       setApiUser(user);
       gApi.changes().id(changeId).delete();
 
       assertThat(query(changeId)).isEmpty();
+
+      String ref = new Change.Id(id).toRefPrefix() + "1";
+      eventRecorder.assertRefUpdatedEvents(project.get(), ref, null, commit, commit, null);
     } finally {
-      removePermission(Permission.DELETE_OWN_CHANGES, project, "refs/*");
+      removePermission(project, "refs/*", Permission.DELETE_OWN_CHANGES);
     }
   }
 
@@ -504,14 +679,13 @@
     try {
       PushOneCommit.Result changeResult = createChange();
       String changeId = changeResult.getChangeId();
-      Change.Id id = changeResult.getChange().getId();
 
       setApiUser(user);
       exception.expect(AuthException.class);
-      exception.expectMessage(String.format("Deleting change %s is not permitted", id));
+      exception.expectMessage("delete not permitted");
       gApi.changes().id(changeId).delete();
     } finally {
-      removePermission(Permission.DELETE_OWN_CHANGES, project, "refs/*");
+      removePermission(project, "refs/*", Permission.DELETE_OWN_CHANGES);
     }
   }
 
@@ -532,13 +706,12 @@
     PushOneCommit.Result changeResult =
         pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
     String changeId = changeResult.getChangeId();
-    Change.Id id = changeResult.getChange().getId();
 
     setApiUser(user);
     gApi.changes().id(changeId).abandon();
 
     exception.expect(AuthException.class);
-    exception.expectMessage(String.format("Deleting change %s is not permitted", id));
+    exception.expectMessage("delete not permitted");
     gApi.changes().id(changeId).delete();
   }
 
@@ -560,12 +733,11 @@
   public void deleteMergedChange() throws Exception {
     PushOneCommit.Result changeResult = createChange();
     String changeId = changeResult.getChangeId();
-    Change.Id id = changeResult.getChange().getId();
 
     merge(changeResult);
 
     exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage(String.format("Deleting merged change %s is not allowed", id));
+    exception.expectMessage("delete not permitted");
     gApi.changes().id(changeId).delete();
   }
 
@@ -578,16 +750,15 @@
       PushOneCommit.Result changeResult =
           pushFactory.create(db, user.getIdent(), testRepo).to("refs/for/master");
       String changeId = changeResult.getChangeId();
-      Change.Id id = changeResult.getChange().getId();
 
       merge(changeResult);
 
       setApiUser(user);
       exception.expect(MethodNotAllowedException.class);
-      exception.expectMessage(String.format("Deleting merged change %s is not allowed", id));
+      exception.expectMessage("delete not permitted");
       gApi.changes().id(changeId).delete();
     } finally {
-      removePermission(Permission.DELETE_OWN_CHANGES, project, "refs/*");
+      removePermission(project, "refs/*", Permission.DELETE_OWN_CHANGES);
     }
   }
 
@@ -961,23 +1132,52 @@
     setApiUser(admin);
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = user.email;
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("Change not visible to " + user.email);
-    gApi.changes().id(result.getChangeId()).addReviewer(in);
+    AddReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
+
+    assertThat(r.input).isEqualTo(user.email);
+    assertThat(r.error).contains("does not have permission to see this change");
+    assertThat(r.reviewers).isNull();
   }
 
   @Test
   public void addReviewerThatIsInactive() throws Exception {
-    PushOneCommit.Result r = createChange();
+    PushOneCommit.Result result = createChange();
 
     String username = name("new-user");
     gApi.accounts().create(username).setActive(false);
 
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = username;
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("Account of " + username + " is inactive.");
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
+    AddReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
+
+    assertThat(r.input).isEqualTo(username);
+    assertThat(r.error).contains("identifies an inactive account");
+    assertThat(r.reviewers).isNull();
+  }
+
+  @Test
+  public void addReviewerThatIsInactiveEmailFallback() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(conf);
+
+    PushOneCommit.Result result = createChange();
+
+    String username = "user@domain.com";
+    gApi.accounts().create(username).setActive(false);
+
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = username;
+    in.state = ReviewerState.CC;
+    AddReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
+
+    assertThat(r.input).isEqualTo(username);
+    assertThat(r.error).isNull();
+    // When adding by email, the reviewers field is also empty because we can't
+    // render a ReviewerInfo object for a non-account.
+    assertThat(r.reviewers).isNull();
   }
 
   @Test
@@ -1603,7 +1803,7 @@
             Iterables.getOnlyElement(query("project:{" + project.get() + "} owner:self")).changeId)
         .isEqualTo(r.getChangeId());
     setApiUser(user);
-    assertThat(query("owner:self")).isEmpty();
+    assertThat(query("owner:self project:{" + project.get() + "}")).isEmpty();
   }
 
   @Test
@@ -1631,6 +1831,26 @@
   }
 
   @Test
+  public void editTopicWithoutPermissionNotAllowed() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("");
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("edit topic name not permitted");
+    gApi.changes().id(r.getChangeId()).topic("mytopic");
+  }
+
+  @Test
+  public void editTopicWithPermissionAllowed() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("");
+    grant(project, "refs/heads/master", Permission.EDIT_TOPIC_NAME, false, REGISTERED_USERS);
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).topic("mytopic");
+    assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("mytopic");
+  }
+
+  @Test
   public void submitted() throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
@@ -1657,6 +1877,26 @@
   }
 
   @Test
+  public void submitNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("submit not permitted");
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+  }
+
+  @Test
+  public void submitAllowedWithPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    grant(project, "refs/heads/master", Permission.SUBMIT, false, REGISTERED_USERS);
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+    assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
   public void check() throws Exception {
     // TODO(dborowitz): Re-enable when ConsistencyChecker supports NoteDb.
     assume().that(notesMigration.enabled()).isFalse();
@@ -1999,7 +2239,7 @@
     TestRepository<InMemoryRepository> userTestRepo = cloneProject(p, user);
 
     // Block default permission
-    block(Permission.ADD_PATCH_SET, REGISTERED_USERS, "refs/for/*", p);
+    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
 
     // Create change as admin
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), adminTestRepo);
@@ -2043,7 +2283,7 @@
     TestRepository<?> adminTestRepo = cloneProject(project, admin);
 
     // Block default permission
-    block(Permission.ADD_PATCH_SET, REGISTERED_USERS, "refs/for/*", p);
+    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
 
     // Create change as admin
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), adminTestRepo);
@@ -2094,7 +2334,7 @@
     TestRepository<?> userTestRepo = cloneProject(p, user);
 
     // Block default permission
-    block(Permission.ADD_PATCH_SET, REGISTERED_USERS, "refs/for/*", p);
+    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
 
     // Create change as admin
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), adminTestRepo);
@@ -2542,7 +2782,7 @@
 
   private void setChangeStatus(Change.Id id, Change.Status newStatus) throws Exception {
     try (BatchUpdate batchUpdate =
-        updateFactory.create(db, project, atrScope.get().getUser(), TimeUtil.nowTs())) {
+        batchUpdateFactory.create(db, project, atrScope.get().getUser(), TimeUtil.nowTs())) {
       batchUpdate.addOp(id, new ChangeStatusUpdateOp(newStatus));
       batchUpdate.execute();
     }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/ProjectIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/ProjectIT.java
index e30e9b3..8688409 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -25,7 +25,10 @@
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.DescriptionInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.client.SubmitType;
+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.reviewdb.client.RefNames;
@@ -106,7 +109,7 @@
   }
 
   @Test
-  public void description() throws Exception {
+  public void descriptionChangeCausesRefUpdate() throws Exception {
     RevCommit initialHead = getRemoteHead(project, RefNames.REFS_CONFIG);
     assertThat(gApi.projects().name(project.get()).description()).isEmpty();
     DescriptionInput in = new DescriptionInput();
@@ -120,7 +123,19 @@
   }
 
   @Test
-  public void config() throws Exception {
+  public void descriptionIsDeletedWhenNotSpecified() throws Exception {
+    assertThat(gApi.projects().name(project.get()).description()).isEmpty();
+    DescriptionInput in = new DescriptionInput();
+    in.description = "new project description";
+    gApi.projects().name(project.get()).description(in);
+    assertThat(gApi.projects().name(project.get()).description()).isEqualTo(in.description);
+    in.description = null;
+    gApi.projects().name(project.get()).description(in);
+    assertThat(gApi.projects().name(project.get()).description()).isEmpty();
+  }
+
+  @Test
+  public void configChangeCausesRefUpdate() throws Exception {
     RevCommit initialHead = getRemoteHead(project, RefNames.REFS_CONFIG);
 
     ConfigInfo info = gApi.projects().name(project.get()).config();
@@ -136,4 +151,77 @@
     eventRecorder.assertRefUpdatedEvents(
         project.get(), RefNames.REFS_CONFIG, initialHead, updatedHead);
   }
+
+  @Test
+  public void setConfig() throws Exception {
+    ConfigInput input = createTestConfigInput();
+    ConfigInfo info = gApi.projects().name(project.get()).config(input);
+    assertThat(info.description).isEqualTo(input.description);
+    assertThat(info.useContributorAgreements.configuredValue)
+        .isEqualTo(input.useContributorAgreements);
+    assertThat(info.useContentMerge.configuredValue).isEqualTo(input.useContentMerge);
+    assertThat(info.useSignedOffBy.configuredValue).isEqualTo(input.useSignedOffBy);
+    assertThat(info.createNewChangeForAllNotInTarget.configuredValue)
+        .isEqualTo(input.createNewChangeForAllNotInTarget);
+    assertThat(info.requireChangeId.configuredValue).isEqualTo(input.requireChangeId);
+    assertThat(info.rejectImplicitMerges.configuredValue).isEqualTo(input.rejectImplicitMerges);
+    assertThat(info.enableReviewerByEmail.configuredValue).isEqualTo(input.enableReviewerByEmail);
+    assertThat(info.createNewChangeForAllNotInTarget.configuredValue)
+        .isEqualTo(input.createNewChangeForAllNotInTarget);
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo(input.maxObjectSizeLimit);
+    assertThat(info.submitType).isEqualTo(input.submitType);
+    assertThat(info.state).isEqualTo(input.state);
+  }
+
+  @Test
+  public void setPartialConfig() throws Exception {
+    ConfigInput input = createTestConfigInput();
+    ConfigInfo info = gApi.projects().name(project.get()).config(input);
+
+    ConfigInput partialInput = new ConfigInput();
+    partialInput.useContributorAgreements = InheritableBoolean.FALSE;
+    info = gApi.projects().name(project.get()).config(partialInput);
+
+    assertThat(info.description).isNull();
+    assertThat(info.useContributorAgreements.configuredValue)
+        .isEqualTo(partialInput.useContributorAgreements);
+    assertThat(info.useContentMerge.configuredValue).isEqualTo(input.useContentMerge);
+    assertThat(info.useSignedOffBy.configuredValue).isEqualTo(input.useSignedOffBy);
+    assertThat(info.createNewChangeForAllNotInTarget.configuredValue)
+        .isEqualTo(input.createNewChangeForAllNotInTarget);
+    assertThat(info.requireChangeId.configuredValue).isEqualTo(input.requireChangeId);
+    assertThat(info.rejectImplicitMerges.configuredValue).isEqualTo(input.rejectImplicitMerges);
+    assertThat(info.enableReviewerByEmail.configuredValue).isEqualTo(input.enableReviewerByEmail);
+    assertThat(info.createNewChangeForAllNotInTarget.configuredValue)
+        .isEqualTo(input.createNewChangeForAllNotInTarget);
+    assertThat(info.maxObjectSizeLimit.configuredValue).isEqualTo(input.maxObjectSizeLimit);
+    assertThat(info.submitType).isEqualTo(input.submitType);
+    assertThat(info.state).isEqualTo(input.state);
+  }
+
+  @Test
+  public void nonOwnerCannotSetConfig() throws Exception {
+    ConfigInput input = createTestConfigInput();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("restricted to project owner");
+    gApi.projects().name(project.get()).config(input);
+  }
+
+  private ConfigInput createTestConfigInput() {
+    ConfigInput input = new ConfigInput();
+    input.description = "some description";
+    input.useContributorAgreements = InheritableBoolean.TRUE;
+    input.useContentMerge = InheritableBoolean.TRUE;
+    input.useSignedOffBy = InheritableBoolean.TRUE;
+    input.createNewChangeForAllNotInTarget = InheritableBoolean.TRUE;
+    input.requireChangeId = InheritableBoolean.TRUE;
+    input.rejectImplicitMerges = InheritableBoolean.TRUE;
+    input.enableReviewerByEmail = InheritableBoolean.TRUE;
+    input.createNewChangeForAllNotInTarget = InheritableBoolean.TRUE;
+    input.maxObjectSizeLimit = "5m";
+    input.submitType = SubmitType.CHERRY_PICK;
+    input.state = ProjectState.HIDDEN;
+    return input;
+  }
 }
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 577634e..dd44cb9a6 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
@@ -24,6 +24,7 @@
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 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;
@@ -37,11 +38,16 @@
 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.acceptance.TestProjectInput;
+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;
 import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.NotifyInfo;
+import com.google.gerrit.extensions.api.changes.RecipientType;
 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;
@@ -59,6 +65,7 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 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;
@@ -67,6 +74,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Branch.NameKey;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.server.change.GetRevisionActions;
@@ -282,6 +290,15 @@
   }
 
   @Test
+  public void voteNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("is restricted");
+    gApi.changes().id(r.getChange().getId().get()).current().review(ReviewInput.approve());
+  }
+
+  @Test
   public void deleteDraft() throws Exception {
     PushOneCommit.Result r = createDraft();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).delete();
@@ -326,6 +343,28 @@
   }
 
   @Test
+  public void cherryPickSetChangeId() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "foo";
+    String id = "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbe3f";
+    in.message = "it goes to foo branch\n\nChange-Id: " + id;
+
+    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
+    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());
+
+    assertThat(orig.get().messages).hasSize(1);
+    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
+
+    ChangeInfo changeInfo = cherry.get();
+
+    // The cherry-pick honors the ChangeId specified in the input message:
+    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    assertThat(revInfo).isNotNull();
+    assertThat(revInfo.commit.message).endsWith(id + "\n");
+  }
+
+  @Test
   public void cherryPickwithNoTopic() throws Exception {
     PushOneCommit.Result r = pushTo("refs/for/master");
     CherryPickInput in = new CherryPickInput();
@@ -589,6 +628,47 @@
   }
 
   @Test
+  public void cherryPickNotify() throws Exception {
+    createBranch(new NameKey(project, "branch-1"));
+    createBranch(new NameKey(project, "branch-2"));
+    createBranch(new NameKey(project, "branch-3"));
+
+    // Creates a change for 'admin'.
+    PushOneCommit.Result result = createChange();
+    String changeId = project.get() + "~master~" + result.getChangeId();
+
+    // 'user' cherry-picks the change to a new branch, the source change's author/committer('admin')
+    // will be added as a reviewer of the newly created change.
+    setApiUser(user);
+    CherryPickInput input = new CherryPickInput();
+    input.message = "it goes to a new branch";
+
+    // Enable the notification. 'admin' as a reviewer should be notified.
+    input.destination = "branch-1";
+    input.notify = NotifyHandling.ALL;
+    sender.clear();
+    gApi.changes().id(changeId).current().cherryPick(input);
+    assertNotifyCc(admin);
+
+    // Disable the notification. 'admin' as a reviewer should not be notified any more.
+    input.destination = "branch-2";
+    input.notify = NotifyHandling.NONE;
+    sender.clear();
+    gApi.changes().id(changeId).current().cherryPick(input);
+    assertThat(sender.getMessages()).hasSize(0);
+
+    // Disable the notification. The user provided in the 'notifyDetails' should still be notified.
+    TestAccount userToNotify = accounts.user2();
+    input.destination = "branch-3";
+    input.notify = NotifyHandling.NONE;
+    input.notifyDetails =
+        ImmutableMap.of(RecipientType.TO, new NotifyInfo(ImmutableList.of(userToNotify.email)));
+    sender.clear();
+    gApi.changes().id(changeId).current().cherryPick(input);
+    assertNotifyTo(userToNotify);
+  }
+
+  @Test
   public void canRebase() throws Exception {
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
     PushOneCommit.Result r1 = push.to("refs/for/master");
@@ -733,14 +813,36 @@
   @Test
   public void description() throws Exception {
     PushOneCommit.Result r = createChange();
-    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description())
-        .isEqualTo("");
+    assertDescription(r, "");
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("test");
-    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description())
-        .isEqualTo("test");
+    assertDescription(r, "test");
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("");
+    assertDescription(r, "");
+  }
+
+  @Test
+  public void setDescriptionNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertDescription(r, "");
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("edit description not permitted");
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("test");
+  }
+
+  @Test
+  public void setDescriptionAllowedWithPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertDescription(r, "");
+    grant(project, "refs/heads/master", Permission.OWNER, false, REGISTERED_USERS);
+    setApiUser(user);
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("test");
+    assertDescription(r, "test");
+  }
+
+  private void assertDescription(PushOneCommit.Result r, String expected) throws Exception {
     assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description())
-        .isEqualTo("");
+        .isEqualTo(expected);
   }
 
   @Test
@@ -940,9 +1042,9 @@
     recommend(r.getChangeId());
 
     // check if it's blocked to delete a vote on a non-current patch set.
+    setApiUser(admin);
     exception.expect(MethodNotAllowedException.class);
     exception.expectMessage("Cannot access on non-current patch set");
-    setApiUser(admin);
     gApi.changes()
         .id(r.getChangeId())
         .revision(r.getCommit().getName())
@@ -1006,20 +1108,6 @@
     return eTag;
   }
 
-  private void assertContent(PushOneCommit.Result pushResult, String path, String expectedContent)
-      throws Exception {
-    BinaryResult bin =
-        gApi.changes()
-            .id(pushResult.getChangeId())
-            .revision(pushResult.getCommit().name())
-            .file(path)
-            .content();
-    ByteArrayOutputStream os = new ByteArrayOutputStream();
-    bin.writeTo(os);
-    String res = new String(os.toByteArray(), UTF_8);
-    assertThat(res).isEqualTo(expectedContent);
-  }
-
   private void assertDiffForNewFile(
       PushOneCommit.Result pushResult, String path, String expectedContentSideB) throws Exception {
     DiffInfo diff =
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 11df473..c440d90 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
@@ -16,34 +16,51 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.extensions.common.EditInfoSubject.assertThat;
 import static com.google.gerrit.extensions.common.RobotCommentInfoSubject.assertThatList;
+import static java.util.stream.Collectors.toList;
 
+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.AcceptanceTestRequestScope;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.FixReplacementInfo;
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.BinaryResultSubject;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import org.hamcrest.core.StringContains;
+import java.util.Objects;
+import java.util.Optional;
 import org.junit.Before;
 import org.junit.Test;
 
 public class RobotCommentsIT extends AbstractDaemonTest {
+  private static final String FILE_NAME = "file_to_fix.txt";
+  private static final String FILE_NAME2 = "another_file_to_fix.txt";
+  private static final String FILE_CONTENT =
+      "First line\nSecond line\nThird line\nFourth line\nFifth line\nSixth line"
+          + "\nSeventh line\nEighth line\nNinth line\nTenth line\n";
+  private static final String FILE_CONTENT2 = "1st line\n2nd line\n3rd line\n";
+
   private String changeId;
   private FixReplacementInfo fixReplacementInfo;
   private FixSuggestionInfo fixSuggestionInfo;
@@ -51,7 +68,14 @@
 
   @Before
   public void setUp() throws Exception {
-    PushOneCommit.Result changeResult = createChange();
+    PushOneCommit push =
+        pushFactory.create(
+            db,
+            admin.getIdent(),
+            testRepo,
+            "Provide files which can be used for fixes",
+            ImmutableMap.of(FILE_NAME, FILE_CONTENT, FILE_NAME2, FILE_CONTENT2));
+    PushOneCommit.Result changeResult = push.to("refs/for/master");
     changeId = changeResult.getChangeId();
 
     fixReplacementInfo = createFixReplacementInfo();
@@ -61,7 +85,7 @@
 
   @Test
   public void retrievingRobotCommentsBeforeAddingAnyDoesNotRaiseAnException() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     Map<String, List<RobotCommentInfo>> robotComments =
         gApi.changes().id(changeId).current().robotComments();
@@ -72,7 +96,7 @@
 
   @Test
   public void addedRobotCommentsCanBeRetrieved() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     RobotCommentInput in = createRobotCommentInput();
     addRobotComment(changeId, in);
@@ -86,7 +110,7 @@
 
   @Test
   public void addedRobotCommentsCanBeRetrievedByChange() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     RobotCommentInput in = createRobotCommentInput();
     addRobotComment(changeId, in);
@@ -109,7 +133,7 @@
 
   @Test
   public void robotCommentsCanBeRetrievedAsList() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     RobotCommentInput robotCommentInput = createRobotCommentInput();
     addRobotComment(changeId, robotCommentInput);
@@ -124,7 +148,7 @@
 
   @Test
   public void specificRobotCommentCanBeRetrieved() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     RobotCommentInput robotCommentInput = createRobotCommentInput();
     addRobotComment(changeId, robotCommentInput);
@@ -139,7 +163,7 @@
 
   @Test
   public void robotCommentWithoutOptionalFieldsCanBeAdded() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     RobotCommentInput in = createRobotCommentInputWithMandatoryFields();
     addRobotComment(changeId, in);
@@ -151,8 +175,77 @@
   }
 
   @Test
+  public void hugeRobotCommentIsRejected() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    int defaultSizeLimit = 1024 * 1024;
+    int sizeOfRest = 451;
+    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit - sizeOfRest + 1);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("limit");
+    addRobotComment(changeId, withFixRobotCommentInput);
+  }
+
+  @Test
+  public void reasonablyLargeRobotCommentIsAccepted() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    int defaultSizeLimit = 1024 * 1024;
+    int sizeOfRest = 451;
+    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit - sizeOfRest);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    assertThat(robotCommentInfos).hasSize(1);
+  }
+
+  @Test
+  @GerritConfig(name = "change.robotCommentSizeLimit", value = "10k")
+  public void maximumAllowedSizeOfRobotCommentCanBeAdjusted() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    int sizeLimit = 10 * 1024;
+    fixReplacementInfo.replacement = getStringFor(sizeLimit);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("limit");
+    addRobotComment(changeId, withFixRobotCommentInput);
+  }
+
+  @Test
+  @GerritConfig(name = "change.robotCommentSizeLimit", value = "0")
+  public void zeroForMaximumAllowedSizeOfRobotCommentRemovesRestriction() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    int defaultSizeLimit = 1024 * 1024;
+    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    assertThat(robotCommentInfos).hasSize(1);
+  }
+
+  @Test
+  @GerritConfig(name = "change.robotCommentSizeLimit", value = "-1")
+  public void negativeValueForMaximumAllowedSizeOfRobotCommentRemovesRestriction()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    int defaultSizeLimit = 1024 * 1024;
+    fixReplacementInfo.replacement = getStringFor(defaultSizeLimit);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    assertThat(robotCommentInfos).hasSize(1);
+  }
+
+  @Test
   public void addedFixSuggestionCanBeRetrieved() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
@@ -162,7 +255,7 @@
 
   @Test
   public void fixIdIsGeneratedForFixSuggestion() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
@@ -177,7 +270,7 @@
 
   @Test
   public void descriptionOfFixSuggestionIsAcceptedAsIs() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
@@ -191,7 +284,7 @@
 
   @Test
   public void descriptionOfFixSuggestionIsMandatory() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     fixSuggestionInfo.description = null;
 
@@ -205,7 +298,7 @@
 
   @Test
   public void addedFixReplacementCanBeRetrieved() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     addRobotComment(changeId, withFixRobotCommentInput);
     List<RobotCommentInfo> robotCommentInfos = getRobotComments();
@@ -219,7 +312,7 @@
 
   @Test
   public void fixReplacementsAreMandatory() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     fixSuggestionInfo.replacements = Collections.emptyList();
 
@@ -234,7 +327,7 @@
 
   @Test
   public void pathOfFixReplacementIsAcceptedAsIs() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     addRobotComment(changeId, withFixRobotCommentInput);
 
@@ -250,7 +343,7 @@
 
   @Test
   public void pathOfFixReplacementIsMandatory() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     fixReplacementInfo.path = null;
 
@@ -263,23 +356,8 @@
   }
 
   @Test
-  public void pathOfFixReplacementMustReferToFileOfComment() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
-
-    fixReplacementInfo.path = "anotherFile.txt";
-
-    exception.expect(BadRequestException.class);
-    exception.expectMessage(
-        String.format(
-            "Replacements may only be specified "
-                + "for the file %s on which the robot comment was added",
-            withFixRobotCommentInput.path));
-    addRobotComment(changeId, withFixRobotCommentInput);
-  }
-
-  @Test
   public void rangeOfFixReplacementIsAcceptedAsIs() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     addRobotComment(changeId, withFixRobotCommentInput);
 
@@ -295,7 +373,7 @@
 
   @Test
   public void rangeOfFixReplacementIsMandatory() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     fixReplacementInfo.range = null;
 
@@ -309,17 +387,121 @@
 
   @Test
   public void rangeOfFixReplacementNeedsToBeValid() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     fixReplacementInfo.range = createRange(13, 9, 5, 10);
     exception.expect(BadRequestException.class);
-    exception.expectMessage(new StringContains("Range (13:9 - 5:10)"));
+    exception.expectMessage("Range (13:9 - 5:10)");
     addRobotComment(changeId, withFixRobotCommentInput);
   }
 
   @Test
+  public void rangesOfFixReplacementsOfSameFixSuggestionForSameFileMayNotOverlap()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Second modification\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
+    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("overlap");
+    addRobotComment(changeId, withFixRobotCommentInput);
+  }
+
+  @Test
+  public void rangesOfFixReplacementsOfSameFixSuggestionForDifferentFileMayOverlap()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME2;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Second modification\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
+    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    assertThatList(robotCommentInfos).onlyElement().fixSuggestions().hasSize(1);
+  }
+
+  @Test
+  public void rangesOfFixReplacementsOfDifferentFixSuggestionsForSameFileMayOverlap()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
+    fixReplacementInfo1.replacement = "First modification\n";
+    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Second modification\n";
+    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
+
+    withFixRobotCommentInput.fixSuggestions =
+        ImmutableList.of(fixSuggestionInfo1, fixSuggestionInfo2);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    assertThatList(robotCommentInfos).onlyElement().fixSuggestions().hasSize(2);
+  }
+
+  @Test
+  public void fixReplacementsDoNotNeedToBeOrderedAccordingToRange() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Second modification\n";
+
+    FixReplacementInfo fixReplacementInfo3 = new FixReplacementInfo();
+    fixReplacementInfo3.path = FILE_NAME;
+    fixReplacementInfo3.range = createRange(4, 0, 5, 0);
+    fixReplacementInfo3.replacement = "Third modification\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo2, fixReplacementInfo1, fixReplacementInfo3);
+    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    assertThatList(robotCommentInfos).onlyElement().onlyFixSuggestion().replacements().hasSize(3);
+  }
+
+  @Test
   public void replacementStringOfFixReplacementIsAcceptedAsIs() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     addRobotComment(changeId, withFixRobotCommentInput);
 
@@ -335,7 +517,7 @@
 
   @Test
   public void replacementStringOfFixReplacementIsMandatory() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     fixReplacementInfo.replacement = null;
 
@@ -349,13 +531,490 @@
   }
 
   @Test
+  public void fixWithinALineCanBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nSecond line\nTModified contentrd line\nFourth line\nFifth line\n"
+                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void fixSpanningMultipleLinesCanBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content\n5";
+    fixReplacementInfo.range = createRange(3, 2, 5, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nSecond line\nThModified content\n5th line\nSixth line\nSeventh line\n"
+                + "Eighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void fixWithTwoCloseReplacementsOnSameFileCanBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
+    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nSome other modified content\nFourth line\nFifth line\n"
+                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void twoFixesOnSameFileCanBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(8, 0, 9, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
+
+    RobotCommentInput robotCommentInput1 = createRobotCommentInput(fixSuggestionInfo1);
+    RobotCommentInput robotCommentInput2 = createRobotCommentInput(fixSuggestionInfo2);
+    addRobotComment(changeId, robotCommentInput1);
+    addRobotComment(changeId, robotCommentInput2);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(0));
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(1));
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nThird line\nFourth line\nFifth line\nSixth line\n"
+                + "Seventh line\nSome other modified content\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void twoConflictingFixesOnSameFileCannotBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
+    fixReplacementInfo1.replacement = "First modification\n";
+    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
+
+    RobotCommentInput robotCommentInput1 = createRobotCommentInput(fixSuggestionInfo1);
+    RobotCommentInput robotCommentInput2 = createRobotCommentInput(fixSuggestionInfo2);
+    addRobotComment(changeId, robotCommentInput1);
+    addRobotComment(changeId, robotCommentInput2);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(0));
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("merge");
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(1));
+  }
+
+  @Test
+  public void twoFixesOfSameRobotCommentCanBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+    FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(8, 0, 9, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+    FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
+
+    withFixRobotCommentInput.fixSuggestions =
+        ImmutableList.of(fixSuggestionInfo1, fixSuggestionInfo2);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(0));
+    gApi.changes().id(changeId).current().applyFix(fixIds.get(1));
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nThird line\nFourth line\nFifth line\nSixth line\n"
+                + "Seventh line\nSome other modified content\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void fixReferringToDifferentFileThanRobotCommentCanBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME2;
+    fixReplacementInfo.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo.replacement = "Modified content\n";
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME2);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo("1st line\nModified content\n3rd line\n");
+  }
+
+  @Test
+  public void fixInvolvingTwoFilesCanBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME2;
+    fixReplacementInfo2.range = createRange(1, 0, 2, 0);
+    fixReplacementInfo2.replacement = "Different file modification\n";
+
+    FixSuggestionInfo fixSuggestionInfo =
+        createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
+    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nThird line\nFourth line\nFifth line\nSixth line\n"
+                + "Seventh line\nEighth line\nNinth line\nTenth line\n");
+    Optional<BinaryResult> file2 = gApi.changes().id(changeId).edit().getFile(FILE_NAME2);
+    BinaryResultSubject.assertThat(file2)
+        .value()
+        .asString()
+        .isEqualTo("Different file modification\n2nd line\n3rd line\n");
+  }
+
+  @Test
+  public void fixReferringToNonExistentFileCannotBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.path = "a_non_existent_file.txt";
+    fixReplacementInfo.range = createRange(1, 0, 2, 0);
+    fixReplacementInfo.replacement = "Modified content\n";
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    exception.expect(ResourceNotFoundException.class);
+    gApi.changes().id(changeId).current().applyFix(fixId);
+  }
+
+  @Test
+  public void fixOnPreviousPatchSetWithoutChangeEditCannotBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    // Remember patch set and add another one.
+    String previousRevision = gApi.changes().id(changeId).get().currentRevision;
+    amendChange(changeId);
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("current");
+    gApi.changes().id(changeId).revision(previousRevision).applyFix(fixId);
+  }
+
+  @Test
+  public void fixOnPreviousPatchSetWithExistingChangeEditCanBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    // Create an empty change edit.
+    gApi.changes().id(changeId).edit().create();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    // Remember patch set and add another one.
+    String previousRevision = gApi.changes().id(changeId).get().currentRevision;
+    amendChange(changeId);
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    EditInfo editInfo = gApi.changes().id(changeId).revision(previousRevision).applyFix(fixId);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nSecond line\nTModified contentrd line\nFourth line\nFifth line\n"
+                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
+    assertThat(editInfo).baseRevision().isEqualTo(previousRevision);
+  }
+
+  @Test
+  public void fixOnCurrentPatchSetWithChangeEditOnPreviousPatchSetCannotBeApplied()
+      throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    // Create an empty change edit.
+    gApi.changes().id(changeId).edit().create();
+
+    // Add another patch set.
+    amendChange(changeId);
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("based");
+    gApi.changes().id(changeId).current().applyFix(fixId);
+  }
+
+  @Test
+  public void fixDoesNotModifyCommitMessageOfChangeEdit() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    String changeEditCommitMessage = "This is the commit message of the change edit.\n";
+    gApi.changes().id(changeId).edit().modifyCommitMessage(changeEditCommitMessage);
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage).isEqualTo(changeEditCommitMessage);
+  }
+
+  @Test
+  public void applyingFixTwiceIsIdempotent() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    gApi.changes().id(changeId).current().applyFix(fixId);
+    String expectedEditCommit =
+        gApi.changes().id(changeId).edit().get().map(edit -> edit.commit.commit).orElse("");
+
+    // Apply the fix again.
+    gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<EditInfo> editInfo = gApi.changes().id(changeId).edit().get();
+    assertThat(editInfo).value().commit().commit().isEqualTo(expectedEditCommit);
+  }
+
+  @Test
+  public void nonExistentFixCannotBeApplied() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+    String nonExistentFixId = fixId + "_non-existent";
+
+    exception.expect(ResourceNotFoundException.class);
+    gApi.changes().id(changeId).current().applyFix(nonExistentFixId);
+  }
+
+  @Test
+  public void applyingFixReturnsEditInfoForCreatedChangeEdit() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    EditInfo editInfo = gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<EditInfo> expectedEditInfo = gApi.changes().id(changeId).edit().get();
+    String expectedEditCommit = expectedEditInfo.map(edit -> edit.commit.commit).orElse("");
+    assertThat(editInfo).commit().commit().isEqualTo(expectedEditCommit);
+    String expectedBaseRevision = expectedEditInfo.map(edit -> edit.baseRevision).orElse("");
+    assertThat(editInfo).baseRevision().isEqualTo(expectedBaseRevision);
+  }
+
+  @Test
+  public void applyingFixOnTopOfChangeEditReturnsEditInfoForUpdatedChangeEdit() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    gApi.changes().id(changeId).edit().create();
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    EditInfo editInfo = gApi.changes().id(changeId).current().applyFix(fixId);
+
+    Optional<EditInfo> expectedEditInfo = gApi.changes().id(changeId).edit().get();
+    String expectedEditCommit = expectedEditInfo.map(edit -> edit.commit.commit).orElse("");
+    assertThat(editInfo).commit().commit().isEqualTo(expectedEditCommit);
+    String expectedBaseRevision = expectedEditInfo.map(edit -> edit.baseRevision).orElse("");
+    assertThat(editInfo).baseRevision().isEqualTo(expectedBaseRevision);
+  }
+
+  @Test
+  public void createdChangeEditIsBasedOnCurrentPatchSet() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    String currentRevision = gApi.changes().id(changeId).get().currentRevision;
+
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+
+    addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotCommentInfos = getRobotComments();
+
+    List<String> fixIds = getFixIds(robotCommentInfos);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    EditInfo editInfo = gApi.changes().id(changeId).current().applyFix(fixId);
+
+    assertThat(editInfo).baseRevision().isEqualTo(currentRevision);
+  }
+
+  @Test
   public void robotCommentsNotSupportedWithoutNoteDb() throws Exception {
-    assume().that(notesMigration.enabled()).isFalse();
+    assume().that(notesMigration.readChanges()).isFalse();
 
     RobotCommentInput in = createRobotCommentInput();
     ReviewInput reviewInput = new ReviewInput();
     Map<String, List<RobotCommentInput>> robotComments = new HashMap<>();
-    robotComments.put(FILE_NAME, Collections.singletonList(in));
+    robotComments.put(in.path, ImmutableList.of(in));
     reviewInput.robotComments = robotComments;
     reviewInput.message = "comment test";
 
@@ -366,7 +1025,7 @@
 
   @Test
   public void queryChangesWithUnresolvedCommentCount() throws Exception {
-    assume().that(notesMigration.enabled()).isTrue();
+    assume().that(notesMigration.readChanges()).isTrue();
 
     PushOneCommit.Result r1 = createChange();
     PushOneCommit.Result r2 =
@@ -389,7 +1048,7 @@
     }
   }
 
-  private RobotCommentInput createRobotCommentInputWithMandatoryFields() {
+  private static RobotCommentInput createRobotCommentInputWithMandatoryFields() {
     RobotCommentInput in = new RobotCommentInput();
     in.robotId = "happyRobot";
     in.robotRunId = "1";
@@ -399,7 +1058,8 @@
     return in;
   }
 
-  private RobotCommentInput createRobotCommentInput(FixSuggestionInfo... fixSuggestionInfos) {
+  private static RobotCommentInput createRobotCommentInput(
+      FixSuggestionInfo... fixSuggestionInfos) {
     RobotCommentInput in = createRobotCommentInputWithMandatoryFields();
     in.url = "http://www.happy-robot.com";
     in.properties = new HashMap<>();
@@ -409,7 +1069,8 @@
     return in;
   }
 
-  private FixSuggestionInfo createFixSuggestionInfo(FixReplacementInfo... fixReplacementInfos) {
+  private static FixSuggestionInfo createFixSuggestionInfo(
+      FixReplacementInfo... fixReplacementInfos) {
     FixSuggestionInfo newFixSuggestionInfo = new FixSuggestionInfo();
     newFixSuggestionInfo.fixId = "An ID which must be overwritten.";
     newFixSuggestionInfo.description = "A description for a suggested fix.";
@@ -417,15 +1078,15 @@
     return newFixSuggestionInfo;
   }
 
-  private FixReplacementInfo createFixReplacementInfo() {
+  private static FixReplacementInfo createFixReplacementInfo() {
     FixReplacementInfo newFixReplacementInfo = new FixReplacementInfo();
     newFixReplacementInfo.path = FILE_NAME;
     newFixReplacementInfo.replacement = "some replacement code";
-    newFixReplacementInfo.range = createRange(3, 12, 15, 4);
+    newFixReplacementInfo.range = createRange(3, 9, 8, 4);
     return newFixReplacementInfo;
   }
 
-  private Comment.Range createRange(
+  private static Comment.Range createRange(
       int startLine, int startCharacter, int endLine, int endCharacter) {
     Comment.Range range = new Comment.Range();
     range.startLine = startLine;
@@ -439,8 +1100,7 @@
       throws Exception {
     ReviewInput reviewInput = new ReviewInput();
     reviewInput.robotComments =
-        Collections.singletonMap(
-            robotCommentInput.path, Collections.singletonList(robotCommentInput));
+        Collections.singletonMap(robotCommentInput.path, ImmutableList.of(robotCommentInput));
     reviewInput.message = "robot comment test";
     gApi.changes().id(targetChangeId).current().review(reviewInput);
   }
@@ -470,4 +1130,22 @@
       assertThat(c.path).isNull();
     }
   }
+
+  private static String getStringFor(int numberOfBytes) {
+    char[] chars = new char[numberOfBytes];
+    // 'a' will require one byte even when mapped to a JSON string
+    Arrays.fill(chars, 'a');
+    return new String(chars);
+  }
+
+  private static List<String> getFixIds(List<RobotCommentInfo> robotComments) {
+    assertThatList(robotComments).isNotNull();
+    return robotComments
+        .stream()
+        .map(robotCommentInfo -> robotCommentInfo.fixSuggestions)
+        .filter(Objects::nonNull)
+        .flatMap(List::stream)
+        .map(fixSuggestionInfo -> fixSuggestionInfo.fixId)
+        .collect(toList());
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 9cb0b31..4447c78 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -671,7 +671,7 @@
     TestRepository<InMemoryRepository> userTestRepo = cloneProject(p, user);
 
     // Block default permission
-    block(Permission.ADD_PATCH_SET, REGISTERED_USERS, "refs/for/*", p);
+    block(p, "refs/for/*", Permission.ADD_PATCH_SET, REGISTERED_USERS);
 
     // Create change as user
     PushOneCommit push = pushFactory.create(db, user.getIdent(), userTestRepo);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 31ca9df..b22daba 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -15,21 +15,25 @@
 package com.google.gerrit.acceptance.git;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.assertPushRejected;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
 import static com.google.gerrit.extensions.common.EditInfoSubject.assertThat;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.project.Util.category;
 import static com.google.gerrit.server.project.Util.value;
 import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -37,16 +41,20 @@
 import com.google.gerrit.acceptance.TestProjectInput;
 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.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
@@ -61,12 +69,14 @@
 import com.google.gerrit.testutil.TestTimeUtil;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Comparator;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.regex.Pattern;
 import org.eclipse.jgit.api.errors.GitAPIException;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
@@ -75,6 +85,7 @@
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.junit.After;
 import org.junit.AfterClass;
 import org.junit.Before;
 import org.junit.BeforeClass;
@@ -108,7 +119,15 @@
     Util.allow(
         cfg, Permission.forLabel(patchSetLock.getName()), 0, 1, anonymousUsers, "refs/heads/*");
     saveProjectConfig(cfg);
-    grant(Permission.LABEL + "Patch-Set-Lock", project, "refs/heads/*");
+    grant(project, "refs/heads/*", Permission.LABEL + "Patch-Set-Lock");
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    setApiUser(admin);
+    GeneralPreferencesInfo prefs = gApi.accounts().id(admin.id.get()).getPreferences();
+    prefs.publishCommentsOnPush = false;
+    gApi.accounts().id(admin.id.get()).setPreferences(prefs);
   }
 
   protected void selectProtocol(Protocol p) throws Exception {
@@ -376,6 +395,70 @@
   }
 
   @Test
+  public void pushPrivateChange() throws Exception {
+    // Push a private change.
+    PushOneCommit.Result r = pushTo("refs/for/master%private");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isPrivate()).isTrue();
+
+    // Pushing a new patch set without --private doesn't remove the privacy flag from the change.
+    r = amendChange(r.getChangeId(), "refs/for/master");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isPrivate()).isTrue();
+
+    // Remove the privacy flag from the change.
+    r = amendChange(r.getChangeId(), "refs/for/master%remove-private");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isPrivate()).isFalse();
+
+    // Normal push: privacy flag is not added back.
+    r = amendChange(r.getChangeId(), "refs/for/master");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isPrivate()).isFalse();
+
+    // Make the change private again.
+    r = pushTo("refs/for/master%private");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isPrivate()).isTrue();
+
+    // Can't use --private and --remove-private together.
+    r = pushTo("refs/for/master%private,remove-private");
+    r.assertErrorStatus();
+  }
+
+  @Test
+  public void pushWorkInProgressChange() throws Exception {
+    // Push a work-in-progress change.
+    PushOneCommit.Result r = pushTo("refs/for/master%wip");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isWorkInProgress()).isTrue();
+
+    // Pushing a new patch set without --wip doesn't remove the wip flag from the change.
+    r = amendChange(r.getChangeId(), "refs/for/master");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isWorkInProgress()).isTrue();
+
+    // Remove the wip flag from the change.
+    r = amendChange(r.getChangeId(), "refs/for/master%ready");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isWorkInProgress()).isFalse();
+
+    // Normal push: wip flag is not added back.
+    r = amendChange(r.getChangeId(), "refs/for/master");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isWorkInProgress()).isFalse();
+
+    // Make the change work-in-progress again.
+    r = pushTo("refs/for/master%wip");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isWorkInProgress()).isTrue();
+
+    // Can't use --wip and --ready together.
+    r = pushTo("refs/for/master%wip,ready");
+    r.assertErrorStatus();
+  }
+
+  @Test
   public void pushForMasterAsDraft() throws Exception {
     // create draft by pushing to 'refs/drafts/'
     PushOneCommit.Result r = pushTo("refs/drafts/master");
@@ -849,7 +932,7 @@
 
   @Test
   public void pushSameCommitTwiceUsingMagicBranchBaseOption() throws Exception {
-    grant(Permission.PUSH, project, "refs/heads/master");
+    grant(project, "refs/heads/master", Permission.PUSH);
     PushOneCommit.Result rBase = pushTo("refs/heads/master");
     rBase.assertOkStatus();
 
@@ -864,8 +947,10 @@
 
     PushResult pr =
         GitUtil.pushHead(testRepo, "refs/for/foo%base=" + rBase.getCommit().name(), false, false);
-    assertThat(pr.getMessages()).contains("changes: new: 1, refs: 1, done");
 
+    // BatchUpdate implementations differ in how they hook into progress monitors. We mostly just
+    // care that there is a new change.
+    assertThat(pr.getMessages()).containsMatch("changes: new: 1,( refs: 1)? done");
     assertTwoChangesWithSameRevision(r);
   }
 
@@ -1271,7 +1356,7 @@
   @Test
   public void createChangeForMergedCommit() throws Exception {
     String master = "refs/heads/master";
-    grant(Permission.PUSH, project, master, true);
+    grant(project, master, Permission.PUSH, true);
 
     // Update master with a direct push.
     RevCommit c1 = testRepo.commit().message("Non-change 1").create();
@@ -1370,7 +1455,7 @@
   @Test
   public void mergedOptionWithExistingChangeInsertsPatchSet() throws Exception {
     String master = "refs/heads/master";
-    grant(Permission.PUSH, project, master, true);
+    grant(project, master, Permission.PUSH, true);
 
     PushOneCommit.Result r = pushTo("refs/for/master");
     r.assertOkStatus();
@@ -1403,6 +1488,193 @@
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
   }
 
+  @Test
+  public void publishCommentsOnPushPublishesDraftsOnAllRevisions() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String rev1 = r.getCommit().name();
+    CommentInfo c1 = addDraft(r.getChangeId(), rev1, newDraft(FILE_NAME, 1, "comment1"));
+    CommentInfo c2 = addDraft(r.getChangeId(), rev1, newDraft(FILE_NAME, 1, "comment2"));
+
+    r = amendChange(r.getChangeId());
+    String rev2 = r.getCommit().name();
+    CommentInfo c3 = addDraft(r.getChangeId(), rev2, newDraft(FILE_NAME, 1, "comment3"));
+
+    assertThat(getPublishedComments(r.getChangeId())).isEmpty();
+
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email);
+    sender.clear();
+    amendChange(r.getChangeId(), "refs/for/master%publish-comments");
+
+    Collection<CommentInfo> comments = getPublishedComments(r.getChangeId());
+    assertThat(comments.stream().map(c -> c.id)).containsExactly(c1.id, c2.id, c3.id);
+    assertThat(comments.stream().map(c -> c.message))
+        .containsExactly("comment1", "comment2", "comment3");
+    assertThat(getLastMessage(r.getChangeId())).isEqualTo("Uploaded patch set 3.\n\n(3 comments)");
+
+    List<String> messages =
+        sender
+            .getMessages()
+            .stream()
+            .map(m -> m.body())
+            .sorted(Comparator.comparingInt(m -> m.contains("reexamine") ? 0 : 1))
+            .collect(toList());
+    assertThat(messages).hasSize(2);
+
+    assertThat(messages.get(0)).contains("Gerrit-MessageType: newpatchset");
+    assertThat(messages.get(0)).contains("I'd like you to reexamine a change");
+    assertThat(messages.get(0)).doesNotContain("Uploaded patch set 3");
+
+    assertThat(messages.get(1)).contains("Gerrit-MessageType: comment");
+    assertThat(messages.get(1))
+        .containsMatch(
+            Pattern.compile(
+                // A little weird that the comment email contains this text, but it's actually
+                // what's in the ChangeMessage. Really we should fuse the emails into one, but until
+                // then, this test documents the current behavior.
+                "Uploaded patch set 3\\.\n"
+                    + "\n"
+                    + "\\(3 comments\\)\\n.*"
+                    + "PS1, Line 1:.*"
+                    + "comment1\\n.*"
+                    + "PS1, Line 1:.*"
+                    + "comment2\\n.*"
+                    + "PS2, Line 1:.*"
+                    + "comment3\\n",
+                Pattern.DOTALL));
+  }
+
+  @Test
+  public void publishCommentsOnPushWithMessage() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String rev = r.getCommit().name();
+    addDraft(r.getChangeId(), rev, newDraft(FILE_NAME, 1, "comment1"));
+
+    r = amendChange(r.getChangeId(), "refs/for/master%publish-comments,m=The_message");
+
+    Collection<CommentInfo> comments = getPublishedComments(r.getChangeId());
+    assertThat(comments.stream().map(c -> c.message)).containsExactly("comment1");
+    assertThat(getLastMessage(r.getChangeId()))
+        .isEqualTo("Uploaded patch set 2.\n\n(1 comment)\n\nThe message");
+  }
+
+  @Test
+  public void publishCommentsOnPushPublishesDraftsOnMultipleChanges() throws Exception {
+    ObjectId initialHead = testRepo.getRepository().resolve("HEAD");
+    List<RevCommit> commits = createChanges(2, "refs/for/master");
+    String id1 = byCommit(commits.get(0)).change().getKey().get();
+    String id2 = byCommit(commits.get(1)).change().getKey().get();
+    CommentInfo c1 = addDraft(id1, commits.get(0).name(), newDraft(FILE_NAME, 1, "comment1"));
+    CommentInfo c2 = addDraft(id2, commits.get(1).name(), newDraft(FILE_NAME, 1, "comment2"));
+
+    assertThat(getPublishedComments(id1)).isEmpty();
+    assertThat(getPublishedComments(id2)).isEmpty();
+
+    amendChanges(initialHead, commits, "refs/for/master%publish-comments");
+
+    Collection<CommentInfo> cs1 = getPublishedComments(id1);
+    assertThat(cs1.stream().map(c -> c.message)).containsExactly("comment1");
+    assertThat(cs1.stream().map(c -> c.id)).containsExactly(c1.id);
+    assertThat(getLastMessage(id1))
+        .isEqualTo("Uploaded patch set 2: Commit message was updated.\n\n(1 comment)");
+
+    Collection<CommentInfo> cs2 = getPublishedComments(id2);
+    assertThat(cs2.stream().map(c -> c.message)).containsExactly("comment2");
+    assertThat(cs2.stream().map(c -> c.id)).containsExactly(c2.id);
+    assertThat(getLastMessage(id2))
+        .isEqualTo("Uploaded patch set 2: Commit message was updated.\n\n(1 comment)");
+  }
+
+  @Test
+  public void publishCommentsOnPushOnlyPublishesDraftsOnUpdatedChanges() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = createChange();
+    String id1 = r1.getChangeId();
+    String id2 = r2.getChangeId();
+    addDraft(id1, r1.getCommit().name(), newDraft(FILE_NAME, 1, "comment1"));
+    CommentInfo c2 = addDraft(id2, r2.getCommit().name(), newDraft(FILE_NAME, 1, "comment2"));
+
+    assertThat(getPublishedComments(id1)).isEmpty();
+    assertThat(getPublishedComments(id2)).isEmpty();
+
+    r2 = amendChange(id2, "refs/for/master%publish-comments");
+
+    assertThat(getPublishedComments(id1)).isEmpty();
+    assertThat(gApi.changes().id(id1).drafts()).hasSize(1);
+
+    Collection<CommentInfo> cs2 = getPublishedComments(id2);
+    assertThat(cs2.stream().map(c -> c.message)).containsExactly("comment2");
+    assertThat(cs2.stream().map(c -> c.id)).containsExactly(c2.id);
+
+    assertThat(getLastMessage(id1)).doesNotMatch("[Cc]omment");
+    assertThat(getLastMessage(id2)).isEqualTo("Uploaded patch set 2.\n\n(1 comment)");
+  }
+
+  @Test
+  public void publishCommentsOnPushWithPreference() throws Exception {
+    PushOneCommit.Result r = createChange();
+    addDraft(r.getChangeId(), r.getCommit().name(), newDraft(FILE_NAME, 1, "comment1"));
+    r = amendChange(r.getChangeId());
+
+    assertThat(getPublishedComments(r.getChangeId())).isEmpty();
+
+    GeneralPreferencesInfo prefs = gApi.accounts().id(admin.id.get()).getPreferences();
+    prefs.publishCommentsOnPush = true;
+    gApi.accounts().id(admin.id.get()).setPreferences(prefs);
+
+    r = amendChange(r.getChangeId());
+    assertThat(getPublishedComments(r.getChangeId()).stream().map(c -> c.message))
+        .containsExactly("comment1");
+  }
+
+  @Test
+  public void publishCommentsOnPushOverridingPreference() throws Exception {
+    PushOneCommit.Result r = createChange();
+    addDraft(r.getChangeId(), r.getCommit().name(), newDraft(FILE_NAME, 1, "comment1"));
+
+    GeneralPreferencesInfo prefs = gApi.accounts().id(admin.id.get()).getPreferences();
+    prefs.publishCommentsOnPush = true;
+    gApi.accounts().id(admin.id.get()).setPreferences(prefs);
+
+    r = amendChange(r.getChangeId(), "refs/for/master%no-publish-comments");
+
+    assertThat(getPublishedComments(r.getChangeId())).isEmpty();
+  }
+
+  private DraftInput newDraft(String path, int line, String message) {
+    DraftInput d = new DraftInput();
+    d.path = path;
+    d.side = Side.REVISION;
+    d.line = line;
+    d.message = message;
+    d.unresolved = true;
+    return d;
+  }
+
+  private CommentInfo addDraft(String changeId, String revId, DraftInput in) throws Exception {
+    return gApi.changes().id(changeId).revision(revId).createDraft(in).get();
+  }
+
+  private Collection<CommentInfo> getPublishedComments(String changeId) throws Exception {
+    return gApi.changes()
+        .id(changeId)
+        .comments()
+        .values()
+        .stream()
+        .flatMap(cs -> cs.stream())
+        .collect(toList());
+  }
+
+  private String getLastMessage(String changeId) throws Exception {
+    return Streams.findLast(
+            gApi.changes()
+                .id(changeId)
+                .get(EnumSet.of(ListChangesOption.MESSAGES))
+                .messages
+                .stream()
+                .map(m -> m.message))
+        .get();
+  }
+
   private void assertThatUserIsOnlyReviewer(ChangeInfo ci, TestAccount reviewer) {
     assertThat(ci.reviewers).isNotNull();
     assertThat(ci.reviewers.keySet()).containsExactly(ReviewerState.REVIEWER);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
index 42463c7..d8b82e3 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
@@ -87,8 +87,8 @@
       SubmitType submitType)
       throws Exception {
     Project.NameKey project = createProject(name, parent, createEmptyCommit, submitType);
-    grant(Permission.PUSH, project, "refs/heads/*");
-    grant(Permission.SUBMIT, project, "refs/for/refs/heads/*");
+    grant(project, "refs/heads/*", Permission.PUSH);
+    grant(project, "refs/for/refs/heads/*", Permission.SUBMIT);
     return cloneProject(project);
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/DraftChangeBlockedIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/DraftChangeBlockedIT.java
index f2dc8d5..527f37d 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/DraftChangeBlockedIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/DraftChangeBlockedIT.java
@@ -28,7 +28,7 @@
 
   @Before
   public void setUp() throws Exception {
-    block(Permission.PUSH, ANONYMOUS_USERS, "refs/drafts/*");
+    block("refs/drafts/*", Permission.PUSH, ANONYMOUS_USERS);
   }
 
   @Test
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/ForcePushIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/ForcePushIT.java
index 6aaf12c..17fb08ab 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/ForcePushIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/ForcePushIT.java
@@ -49,7 +49,7 @@
   @Test
   public void forcePushAllowed() throws Exception {
     ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
-    grant(Permission.PUSH, project, "refs/*", true);
+    grant(project, "refs/*", Permission.PUSH, true);
     PushOneCommit push1 =
         pushFactory.create(db, admin.getIdent(), testRepo, "change1", "a.txt", "content");
     PushOneCommit.Result r1 = push1.to("refs/heads/master");
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index b900cc7..f327f63 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -18,6 +18,7 @@
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.util.stream.Collectors.toList;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
@@ -54,6 +55,8 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.LsRemoteCommand;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -203,7 +206,7 @@
   @Test
   public void uploadPackSubsetOfBranchesVisibleIncludingHead() throws Exception {
     allow(Permission.READ, REGISTERED_USERS, "refs/heads/master");
-    deny(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
+    deny("refs/heads/branch", Permission.READ, REGISTERED_USERS);
 
     setApiUser(user);
     assertUploadPackRefs(
@@ -218,7 +221,7 @@
 
   @Test
   public void uploadPackSubsetOfBranchesVisibleNotIncludingHead() throws Exception {
-    deny(Permission.READ, REGISTERED_USERS, "refs/heads/master");
+    deny("refs/heads/master", Permission.READ, REGISTERED_USERS);
     allow(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
 
     setApiUser(user);
@@ -237,7 +240,6 @@
   @Test
   public void uploadPackSubsetOfBranchesVisibleWithEdit() throws Exception {
     allow(Permission.READ, REGISTERED_USERS, "refs/heads/master");
-    deny(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
 
     Change c = notesFactory.createChecked(db, project, c1.getId()).getChange();
     String changeId = c.getKey().get();
@@ -262,10 +264,43 @@
   }
 
   @Test
+  public void uploadPackSubsetOfBranchesAndEditsVisibleWithViewPrivateChanges() throws Exception {
+    allow(Permission.READ, REGISTERED_USERS, "refs/heads/master");
+    allow(Permission.VIEW_PRIVATE_CHANGES, REGISTERED_USERS, "refs/*");
+
+    Change change1 = notesFactory.createChecked(db, project, c1.getId()).getChange();
+    String changeId1 = change1.getKey().get();
+    Change change2 = notesFactory.createChecked(db, project, c2.getId()).getChange();
+    String changeId2 = change2.getKey().get();
+
+    // Admin's edit on change1 is visible.
+    setApiUser(admin);
+    gApi.changes().id(changeId1).edit().create();
+
+    // Admin's edit on change2 is not visible since user cannot see the change.
+    gApi.changes().id(changeId2).edit().create();
+
+    // User's edit is visible.
+    setApiUser(user);
+    gApi.changes().id(changeId1).edit().create();
+
+    assertUploadPackRefs(
+        "HEAD",
+        r1 + "1",
+        r1 + "meta",
+        r3 + "1",
+        r3 + "meta",
+        "refs/heads/master",
+        "refs/tags/master-tag",
+        "refs/users/00/1000000/edit-" + c1.getId() + "/1",
+        "refs/users/01/1000001/edit-" + c1.getId() + "/1");
+  }
+
+  @Test
   public void uploadPackSubsetOfRefsVisibleWithAccessDatabase() throws Exception {
     allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
     try {
-      deny(Permission.READ, REGISTERED_USERS, "refs/heads/master");
+      deny("refs/heads/master", Permission.READ, REGISTERED_USERS);
       allow(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
 
       String changeId = c1.change().getKey().get();
@@ -405,7 +440,7 @@
   @Test
   public void receivePackRespectsVisibilityOfOpenChanges() throws Exception {
     allow(Permission.READ, REGISTERED_USERS, "refs/heads/master");
-    deny(Permission.READ, REGISTERED_USERS, "refs/heads/branch");
+    deny("refs/heads/branch", Permission.READ, REGISTERED_USERS);
     setApiUser(user);
 
     assertThat(getReceivePackRefs().additionalHaves()).containsExactly(obj(c3, 1));
@@ -470,6 +505,46 @@
     assertThat(getReceivePackRefs().additionalHaves()).containsExactly(obj(c4, 1));
   }
 
+  @Test
+  public void advertisedReferencesOmitPrivateChangesOfOtherUsers() throws Exception {
+    allow(Permission.READ, REGISTERED_USERS, "refs/heads/master");
+
+    TestRepository<?> userTestRepository = cloneProject(project, user);
+    try (Git git = userTestRepository.git()) {
+      LsRemoteCommand lsRemoteCommand = git.lsRemote();
+      String change3RefName = c3.currentPatchSet().getRefName();
+
+      List<String> initialRefNames =
+          lsRemoteCommand.call().stream().map(Ref::getName).collect(toList());
+      assertWithMessage("Precondition violated").that(initialRefNames).contains(change3RefName);
+
+      gApi.changes().id(c3.getId().get()).setPrivate(true, null);
+
+      List<String> refNames = lsRemoteCommand.call().stream().map(Ref::getName).collect(toList());
+      assertThat(refNames).doesNotContain(change3RefName);
+    }
+  }
+
+  @Test
+  public void advertisedReferencesIncludePrivateChangesWhenAllRefsMayBeRead() throws Exception {
+    allow(Permission.READ, REGISTERED_USERS, "refs/*");
+
+    TestRepository<?> userTestRepository = cloneProject(project, user);
+    try (Git git = userTestRepository.git()) {
+      LsRemoteCommand lsRemoteCommand = git.lsRemote();
+      String change3RefName = c3.currentPatchSet().getRefName();
+
+      List<String> initialRefNames =
+          lsRemoteCommand.call().stream().map(Ref::getName).collect(toList());
+      assertWithMessage("Precondition violated").that(initialRefNames).contains(change3RefName);
+
+      gApi.changes().id(c3.getId().get()).setPrivate(true, null);
+
+      List<String> refNames = lsRemoteCommand.call().stream().map(Ref::getName).collect(toList());
+      assertThat(refNames).contains(change3RefName);
+    }
+  }
+
   /**
    * Assert that refs seen by a non-admin user match expected.
    *
@@ -500,7 +575,7 @@
       throws Exception {
     List<String> expected = new ArrayList<>(expectedWithMeta.length);
     for (String r : expectedWithMeta) {
-      if (notesMigration.writeChanges() || !r.endsWith(RefNames.META_SUFFIX)) {
+      if (notesMigration.commitChangeWrites() || !r.endsWith(RefNames.META_SUFFIX)) {
         expected.add(r);
       }
     }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmitOnPushIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
index 2f9d501..6c5dabd 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/SubmitOnPushIT.java
@@ -49,7 +49,7 @@
 
   @Test
   public void submitOnPush() throws Exception {
-    grant(Permission.SUBMIT, project, "refs/for/refs/heads/master");
+    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
     PushOneCommit.Result r = pushTo("refs/for/master%submit");
     r.assertOkStatus();
     r.assertChange(Change.Status.MERGED, null, admin);
@@ -59,9 +59,9 @@
 
   @Test
   public void submitOnPushWithTag() throws Exception {
-    grant(Permission.SUBMIT, project, "refs/for/refs/heads/master");
-    grant(Permission.CREATE, project, "refs/tags/*");
-    grant(Permission.PUSH, project, "refs/tags/*");
+    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
+    grant(project, "refs/tags/*", Permission.CREATE);
+    grant(project, "refs/tags/*", Permission.PUSH);
     PushOneCommit.Tag tag = new PushOneCommit.Tag("v1.0");
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
     push.setTag(tag);
@@ -75,8 +75,8 @@
 
   @Test
   public void submitOnPushWithAnnotatedTag() throws Exception {
-    grant(Permission.SUBMIT, project, "refs/for/refs/heads/master");
-    grant(Permission.PUSH, project, "refs/tags/*");
+    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
+    grant(project, "refs/tags/*", Permission.PUSH);
     PushOneCommit.AnnotatedTag tag =
         new PushOneCommit.AnnotatedTag("v1.0", "annotation", admin.getIdent());
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
@@ -91,7 +91,7 @@
 
   @Test
   public void submitOnPushToRefsMetaConfig() throws Exception {
-    grant(Permission.SUBMIT, project, "refs/for/refs/meta/config");
+    grant(project, "refs/for/refs/meta/config", Permission.SUBMIT);
 
     git().fetch().setRefSpecs(new RefSpec("refs/meta/config:refs/meta/config")).call();
     testRepo.reset(RefNames.REFS_CONFIG);
@@ -109,7 +109,7 @@
     push("refs/heads/master", "one change", "a.txt", "some content");
     testRepo.reset(objectId);
 
-    grant(Permission.SUBMIT, project, "refs/for/refs/heads/master");
+    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
     PushOneCommit.Result r =
         push("refs/for/master%submit", "other change", "a.txt", "other content");
     r.assertErrorStatus();
@@ -125,7 +125,7 @@
     push(master, "one change", "a.txt", "some content");
     testRepo.reset(objectId);
 
-    grant(Permission.SUBMIT, project, "refs/for/refs/heads/master");
+    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
     PushOneCommit.Result r =
         push("refs/for/master%submit", "other change", "b.txt", "other content");
     r.assertOkStatus();
@@ -138,7 +138,7 @@
     PushOneCommit.Result r =
         push("refs/for/master", PushOneCommit.SUBJECT, "a.txt", "some content");
 
-    grant(Permission.SUBMIT, project, "refs/for/refs/heads/master");
+    grant(project, "refs/for/refs/heads/master", Permission.SUBMIT);
     r =
         push(
             "refs/for/master%submit",
@@ -184,7 +184,7 @@
 
   @Test
   public void mergeOnPushToBranch() throws Exception {
-    grant(Permission.PUSH, project, "refs/heads/master");
+    grant(project, "refs/heads/master", Permission.PUSH);
     PushOneCommit.Result r =
         push("refs/for/master", PushOneCommit.SUBJECT, "a.txt", "some content");
     r.assertOkStatus();
@@ -206,7 +206,7 @@
 
   @Test
   public void mergeOnPushToBranchWithNewPatchset() throws Exception {
-    grant(Permission.PUSH, project, "refs/heads/master");
+    grant(project, "refs/heads/master", Permission.PUSH);
     PushOneCommit.Result r = pushTo("refs/for/master");
     r.assertOkStatus();
     RevCommit c1 = r.getCommit();
@@ -241,7 +241,7 @@
 
   @Test
   public void mergeOnPushToBranchWithOldPatchset() throws Exception {
-    grant(Permission.PUSH, project, "refs/heads/master");
+    grant(project, "refs/heads/master", Permission.PUSH);
     PushOneCommit.Result r = pushTo("refs/for/master");
     r.assertOkStatus();
     RevCommit c1 = r.getCommit();
@@ -268,7 +268,7 @@
 
   @Test
   public void mergeMultipleOnPushToBranchWithNewPatchset() throws Exception {
-    grant(Permission.PUSH, project, "refs/heads/master");
+    grant(project, "refs/heads/master", Permission.PUSH);
 
     // Create 2 changes.
     ObjectId initialHead = getRemoteHead();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/CheckAccessIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/CheckAccessIT.java
new file mode 100644
index 0000000..c16f932
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/CheckAccessIT.java
@@ -0,0 +1,139 @@
+// Copyright (C) 2017 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 org.junit.Assert.fail;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.config.AccessCheckInfo;
+import com.google.gerrit.extensions.api.config.AccessCheckInput;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import java.util.List;
+import java.util.Map;
+import org.junit.Before;
+import org.junit.Test;
+
+public class CheckAccessIT extends AbstractDaemonTest {
+
+  private Project.NameKey normalProject;
+  private Project.NameKey secretProject;
+  private Project.NameKey secretRefProject;
+  private TestAccount privilegedUser;
+  private AccountGroup privilegedGroup;
+
+  @Before
+  public void setUp() throws Exception {
+    normalProject = createProject("normal");
+    secretProject = createProject("secret");
+    secretRefProject = createProject("secretRef");
+    privilegedGroup = groupCache.get(new AccountGroup.NameKey(createGroup("privilegedGroup")));
+
+    privilegedUser = accounts.create("privilegedUser", "snowden@nsa.gov", "Ed Snowden");
+    gApi.groups().id(privilegedGroup.getGroupUUID().get()).addMembers(privilegedUser.username);
+
+    assertThat(gApi.groups().id(privilegedGroup.getGroupUUID().get()).members().get(0).email)
+        .contains("snowden");
+
+    grant(secretProject, "refs/*", Permission.READ, false, privilegedGroup.getGroupUUID());
+    block(secretProject, "refs/*", Permission.READ, SystemGroupBackend.REGISTERED_USERS);
+
+    // deny/grant/block arg ordering is screwy.
+    deny(secretRefProject, "refs/*", Permission.READ, SystemGroupBackend.ANONYMOUS_USERS);
+    grant(
+        secretRefProject,
+        "refs/heads/secret/*",
+        Permission.READ,
+        false,
+        privilegedGroup.getGroupUUID());
+    block(
+        secretRefProject,
+        "refs/heads/secret/*",
+        Permission.READ,
+        SystemGroupBackend.REGISTERED_USERS);
+    grant(
+        secretRefProject,
+        "refs/heads/*",
+        Permission.READ,
+        false,
+        SystemGroupBackend.REGISTERED_USERS);
+  }
+
+  @Test
+  public void invalidInputs() {
+    List<AccessCheckInput> inputs =
+        ImmutableList.of(
+            new AccessCheckInput(),
+            new AccessCheckInput(user.email, null, null),
+            new AccessCheckInput(null, normalProject.toString(), null),
+            new AccessCheckInput("doesnotexist@invalid.com", normalProject.toString(), null));
+    for (AccessCheckInput input : inputs) {
+      try {
+        gApi.config().server().checkAccess(input);
+        fail(String.format("want RestApiException for %s", newGson().toJson(input)));
+      } catch (RestApiException e) {
+
+      }
+    }
+  }
+
+  @Test
+  public void accessible() {
+    Map<AccessCheckInput, Integer> inputs =
+        ImmutableMap.of(
+            new AccessCheckInput(user.email, normalProject.get(), null), 200,
+            new AccessCheckInput(user.email, secretProject.get(), null), 403,
+            new AccessCheckInput(user.email, "nonexistent", null), 404,
+            new AccessCheckInput(privilegedUser.email, normalProject.get(), null), 200,
+            new AccessCheckInput(privilegedUser.email, secretProject.get(), null), 200);
+
+    for (Map.Entry<AccessCheckInput, Integer> entry : inputs.entrySet()) {
+      String in = newGson().toJson(entry.getKey());
+      AccessCheckInfo info = null;
+
+      try {
+        info = gApi.config().server().checkAccess(entry.getKey());
+      } catch (RestApiException e) {
+        fail(String.format("check.check(%s): exception %s", in, e));
+      }
+
+      int want = entry.getValue();
+      if (want != info.status) {
+        fail(String.format("check.access(%s) = %d, want %d", in, info.status, want));
+      }
+
+      switch (want) {
+        case 403:
+          assertThat(info.message).contains("cannot see");
+          break;
+        case 404:
+          assertThat(info.message).contains("does not exist");
+          break;
+        case 200:
+          assertThat(info.message).isNull();
+          break;
+        default:
+          fail(String.format("unknown code %d", want));
+      }
+    }
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index 06b8f68..110b0ac 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -16,64 +16,88 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_UUID;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 import static org.junit.Assert.fail;
 
 import com.github.rholder.retry.BlockStrategy;
 import com.github.rholder.retry.Retryer;
 import com.github.rholder.retry.RetryerBuilder;
 import com.github.rholder.retry.StopStrategies;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+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.Sandboxed;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInput.CheckAccountExternalIdsInput;
 import com.google.gerrit.extensions.common.AccountExternalIdInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIds;
-import com.google.gerrit.server.account.ExternalIdsUpdate;
+import com.google.gerrit.server.account.externalids.DisabledExternalIdCache;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdReader;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate.RefsMetaExternalIdsUpdate;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.LockFailureException;
 import com.google.gson.reflect.TypeToken;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Locale;
+import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.api.errors.TransportException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
+import org.eclipse.jgit.util.MutableInteger;
 import org.junit.Test;
 
 @Sandboxed
 public class ExternalIdIT extends AbstractDaemonTest {
   @Inject private AllUsersName allUsers;
-
   @Inject private ExternalIdsUpdate.Server extIdsUpdate;
-
   @Inject private ExternalIds externalIds;
+  @Inject private ExternalIdReader externalIdReader;
+  @Inject private MetricMaker metricMaker;
 
   @Test
-  public void getExternalIDs() throws Exception {
+  public void getExternalIds() throws Exception {
     Collection<ExternalId> expectedIds = accountCache.get(user.getId()).getExternalIds();
 
-    List<AccountExternalIdInfo> expectedIdInfos = new ArrayList<>();
-    for (ExternalId id : expectedIds) {
-      AccountExternalIdInfo info = new AccountExternalIdInfo();
-      info.identity = id.key().get();
-      info.emailAddress = id.email();
-      info.canDelete = !id.isScheme(SCHEME_USERNAME) ? true : null;
-      info.trusted = true;
-      expectedIdInfos.add(info);
-    }
+    List<AccountExternalIdInfo> expectedIdInfos = toExternalIdInfos(expectedIds);
 
     RestResponse response = userRestSession.get("/accounts/self/external.ids");
     response.assertOK();
@@ -89,7 +113,7 @@
   }
 
   @Test
-  public void deleteExternalIDs() throws Exception {
+  public void deleteExternalIds() throws Exception {
     setApiUser(user);
     List<AccountExternalIdInfo> externalIds = gApi.accounts().self().getExternalIds();
 
@@ -115,7 +139,19 @@
   }
 
   @Test
-  public void deleteExternalIDs_Conflict() throws Exception {
+  public void deleteExternalIdOfPreferredEmail() throws Exception {
+    String preferredEmail = gApi.accounts().self().get().email;
+    assertThat(preferredEmail).isNotNull();
+
+    gApi.accounts()
+        .self()
+        .deleteExternalIds(
+            ImmutableList.of(ExternalId.Key.create(SCHEME_MAILTO, preferredEmail).get()));
+    assertThat(gApi.accounts().self().get().email).isNull();
+  }
+
+  @Test
+  public void deleteExternalIds_Conflict() throws Exception {
     List<String> toDelete = new ArrayList<>();
     String externalIdStr = "username:" + user.username;
     toDelete.add(externalIdStr);
@@ -126,7 +162,7 @@
   }
 
   @Test
-  public void deleteExternalIDs_UnprocessableEntity() throws Exception {
+  public void deleteExternalIds_UnprocessableEntity() throws Exception {
     List<String> toDelete = new ArrayList<>();
     String externalIdStr = "mailto:user@domain.com";
     toDelete.add(externalIdStr);
@@ -160,20 +196,397 @@
 
   @Test
   public void pushToExternalIdsBranch() throws Exception {
-    grant(Permission.READ, allUsers, RefNames.REFS_EXTERNAL_IDS);
-    grant(Permission.PUSH, allUsers, RefNames.REFS_EXTERNAL_IDS);
-
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
-    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":externalIds");
-    allUsersRepo.reset("externalIds");
-    PushOneCommit push = pushFactory.create(db, admin.getIdent(), allUsersRepo);
-    push.to(RefNames.REFS_EXTERNAL_IDS)
-        .assertErrorStatus("not allowed to update " + RefNames.REFS_EXTERNAL_IDS);
+    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    // different case email is allowed
+    ExternalId newExtId = createExternalIdWithOtherCaseEmail("foo:bar");
+    addExtId(allUsersRepo, newExtId);
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    List<AccountExternalIdInfo> extIdsBefore = gApi.accounts().self().getExternalIds();
+
+    allowPushOfExternalIds();
+    PushResult r = pushHead(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+    assertThat(r.getRemoteUpdate(RefNames.REFS_EXTERNAL_IDS).getStatus()).isEqualTo(Status.OK);
+
+    List<AccountExternalIdInfo> extIdsAfter = gApi.accounts().self().getExternalIds();
+    assertThat(extIdsAfter)
+        .containsExactlyElementsIn(
+            Iterables.concat(extIdsBefore, ImmutableSet.of(toExternalIdInfo(newExtId))));
+  }
+
+  @Test
+  public void pushToExternalIdsBranchRejectsExternalIdWithoutAccountId() throws Exception {
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    insertExternalIdWithoutAccountId(
+        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), "foo:bar");
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    allowPushOfExternalIds();
+    PushResult r = pushHead(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+    assertRefUpdateFailure(r.getRemoteUpdate(RefNames.REFS_EXTERNAL_IDS), "invalid external IDs");
+  }
+
+  @Test
+  public void pushToExternalIdsBranchRejectsExternalIdWithKeyThatDoesntMatchTheNoteId()
+      throws Exception {
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    insertExternalIdWithKeyThatDoesntMatchNoteId(
+        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), "foo:bar");
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    allowPushOfExternalIds();
+    PushResult r = pushHead(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+    assertRefUpdateFailure(r.getRemoteUpdate(RefNames.REFS_EXTERNAL_IDS), "invalid external IDs");
+  }
+
+  @Test
+  public void pushToExternalIdsBranchRejectsExternalIdWithInvalidConfig() throws Exception {
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    insertExternalIdWithInvalidConfig(
+        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), "foo:bar");
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    allowPushOfExternalIds();
+    PushResult r = pushHead(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+    assertRefUpdateFailure(r.getRemoteUpdate(RefNames.REFS_EXTERNAL_IDS), "invalid external IDs");
+  }
+
+  @Test
+  public void pushToExternalIdsBranchRejectsExternalIdWithEmptyNote() throws Exception {
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    insertExternalIdWithEmptyNote(
+        allUsersRepo.getRepository(), allUsersRepo.getRevWalk(), "foo:bar");
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    allowPushOfExternalIds();
+    PushResult r = pushHead(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+    assertRefUpdateFailure(r.getRemoteUpdate(RefNames.REFS_EXTERNAL_IDS), "invalid external IDs");
+  }
+
+  @Test
+  public void pushToExternalIdsBranchRejectsExternalIdForNonExistingAccount() throws Exception {
+    testPushToExternalIdsBranchRejectsInvalidExternalId(
+        createExternalIdForNonExistingAccount("foo:bar"));
+  }
+
+  @Test
+  public void pushToExternalIdsBranchRejectsExternalIdWithInvalidEmail() throws Exception {
+    testPushToExternalIdsBranchRejectsInvalidExternalId(
+        createExternalIdWithInvalidEmail("foo:bar"));
+  }
+
+  @Test
+  public void pushToExternalIdsBranchRejectsDuplicateEmails() throws Exception {
+    testPushToExternalIdsBranchRejectsInvalidExternalId(
+        createExternalIdWithDuplicateEmail("foo:bar"));
+  }
+
+  @Test
+  public void pushToExternalIdsBranchRejectsBadPassword() throws Exception {
+    testPushToExternalIdsBranchRejectsInvalidExternalId(createExternalIdWithBadPassword("foo"));
+  }
+
+  private void testPushToExternalIdsBranchRejectsInvalidExternalId(ExternalId invalidExtId)
+      throws Exception {
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    addExtId(allUsersRepo, invalidExtId);
+    allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
+
+    allowPushOfExternalIds();
+    PushResult r = pushHead(allUsersRepo, RefNames.REFS_EXTERNAL_IDS);
+    assertRefUpdateFailure(r.getRemoteUpdate(RefNames.REFS_EXTERNAL_IDS), "invalid external IDs");
+  }
+
+  @Test
+  public void readExternalIdsWhenInvalidExternalIdsExist() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    resetCurrentApiUser();
+
+    insertValidExternalIds();
+    insertInvalidButParsableExternalIds();
+
+    Set<ExternalId> parseableExtIds = externalIds.all();
+
+    insertNonParsableExternalIds();
+
+    Set<ExternalId> extIds = externalIds.all();
+    assertThat(extIds).containsExactlyElementsIn(parseableExtIds);
+
+    for (ExternalId parseableExtId : parseableExtIds) {
+      ExternalId extId = externalIds.get(parseableExtId.key());
+      assertThat(extId).isEqualTo(parseableExtId);
+    }
+  }
+
+  @Test
+  public void checkConsistency() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    resetCurrentApiUser();
+
+    insertValidExternalIds();
+
+    ConsistencyCheckInput input = new ConsistencyCheckInput();
+    input.checkAccountExternalIds = new CheckAccountExternalIdsInput();
+    ConsistencyCheckInfo checkInfo = gApi.config().server().checkConsistency(input);
+    assertThat(checkInfo.checkAccountExternalIdsResult.problems).isEmpty();
+
+    Set<ConsistencyProblemInfo> expectedProblems = new HashSet<>();
+    expectedProblems.addAll(insertInvalidButParsableExternalIds());
+    expectedProblems.addAll(insertNonParsableExternalIds());
+
+    checkInfo = gApi.config().server().checkConsistency(input);
+    assertThat(checkInfo.checkAccountExternalIdsResult.problems).hasSize(expectedProblems.size());
+    assertThat(checkInfo.checkAccountExternalIdsResult.problems)
+        .containsExactlyElementsIn(expectedProblems);
+  }
+
+  @Test
+  public void checkConsistencyNotAllowed() throws Exception {
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to run consistency checks");
+    gApi.config().server().checkConsistency(new ConsistencyCheckInput());
+  }
+
+  private ConsistencyProblemInfo consistencyError(String message) {
+    return new ConsistencyProblemInfo(ConsistencyProblemInfo.Status.ERROR, message);
+  }
+
+  private void insertValidExternalIds() throws IOException, ConfigInvalidException, OrmException {
+    MutableInteger i = new MutableInteger();
+    String scheme = "valid";
+    ExternalIdsUpdate u = extIdsUpdate.create();
+
+    // create valid external IDs
+    u.insert(
+        ExternalId.createWithPassword(
+            ExternalId.Key.parse(nextId(scheme, i)),
+            admin.id,
+            "admin.other@example.com",
+            "secret-password"));
+    u.insert(createExternalIdWithOtherCaseEmail(nextId(scheme, i)));
+  }
+
+  private Set<ConsistencyProblemInfo> insertInvalidButParsableExternalIds()
+      throws IOException, ConfigInvalidException, OrmException {
+    MutableInteger i = new MutableInteger();
+    String scheme = "invalid";
+    ExternalIdsUpdate u = extIdsUpdate.create();
+
+    Set<ConsistencyProblemInfo> expectedProblems = new HashSet<>();
+    ExternalId extIdForNonExistingAccount =
+        createExternalIdForNonExistingAccount(nextId(scheme, i));
+    u.insert(extIdForNonExistingAccount);
+    expectedProblems.add(
+        consistencyError(
+            "External ID '"
+                + extIdForNonExistingAccount.key().get()
+                + "' belongs to account that doesn't exist: "
+                + extIdForNonExistingAccount.accountId().get()));
+
+    ExternalId extIdWithInvalidEmail = createExternalIdWithInvalidEmail(nextId(scheme, i));
+    u.insert(extIdWithInvalidEmail);
+    expectedProblems.add(
+        consistencyError(
+            "External ID '"
+                + extIdWithInvalidEmail.key().get()
+                + "' has an invalid email: "
+                + extIdWithInvalidEmail.email()));
+
+    ExternalId extIdWithDuplicateEmail = createExternalIdWithDuplicateEmail(nextId(scheme, i));
+    u.insert(extIdWithDuplicateEmail);
+    expectedProblems.add(
+        consistencyError(
+            "Email '"
+                + extIdWithDuplicateEmail.email()
+                + "' is not unique, it's used by the following external IDs: '"
+                + extIdWithDuplicateEmail.key().get()
+                + "', 'mailto:"
+                + extIdWithDuplicateEmail.email()
+                + "'"));
+
+    ExternalId extIdWithBadPassword = createExternalIdWithBadPassword("admin-username");
+    u.insert(extIdWithBadPassword);
+    expectedProblems.add(
+        consistencyError(
+            "External ID '"
+                + extIdWithBadPassword.key().get()
+                + "' has an invalid password: unrecognized algorithm"));
+
+    return expectedProblems;
+  }
+
+  private Set<ConsistencyProblemInfo> insertNonParsableExternalIds() throws IOException {
+    MutableInteger i = new MutableInteger();
+    String scheme = "corrupt";
+
+    Set<ConsistencyProblemInfo> expectedProblems = new HashSet<>();
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      String externalId = nextId(scheme, i);
+      String noteId = insertExternalIdWithoutAccountId(repo, rw, externalId);
+      expectedProblems.add(
+          consistencyError(
+              "Invalid external ID config for note '"
+                  + noteId
+                  + "': Value for 'externalId."
+                  + externalId
+                  + ".accountId' is missing, expected account ID"));
+
+      externalId = nextId(scheme, i);
+      noteId = insertExternalIdWithKeyThatDoesntMatchNoteId(repo, rw, externalId);
+      expectedProblems.add(
+          consistencyError(
+              "Invalid external ID config for note '"
+                  + noteId
+                  + "': SHA1 of external ID '"
+                  + externalId
+                  + "' does not match note ID '"
+                  + noteId
+                  + "'"));
+
+      noteId = insertExternalIdWithInvalidConfig(repo, rw, nextId(scheme, i));
+      expectedProblems.add(
+          consistencyError(
+              "Invalid external ID config for note '" + noteId + "': Invalid line in config file"));
+
+      noteId = insertExternalIdWithEmptyNote(repo, rw, nextId(scheme, i));
+      expectedProblems.add(
+          consistencyError(
+              "Invalid external ID config for note '"
+                  + noteId
+                  + "': Expected exactly 1 'externalId' section, found 0"));
+    }
+
+    return expectedProblems;
+  }
+
+  private ExternalId createExternalIdWithOtherCaseEmail(String externalId) {
+    return ExternalId.createWithPassword(
+        ExternalId.Key.parse(externalId), admin.id, admin.email.toUpperCase(Locale.US), "password");
+  }
+
+  private String insertExternalIdWithoutAccountId(Repository repo, RevWalk rw, String externalId)
+      throws IOException {
+    ObjectId rev = ExternalIdReader.readRevision(repo);
+    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+
+    ExternalId extId = ExternalId.create(ExternalId.Key.parse(externalId), admin.id);
+
+    try (ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId noteId = extId.key().sha1();
+      Config c = new Config();
+      extId.writeToConfig(c);
+      c.unset("externalId", extId.key().get(), "accountId");
+      byte[] raw = c.toText().getBytes(UTF_8);
+      ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+      noteMap.set(noteId, dataBlob);
+
+      ExternalIdsUpdate.commit(
+          repo, rw, ins, rev, noteMap, "Add external ID", admin.getIdent(), admin.getIdent());
+      return noteId.getName();
+    }
+  }
+
+  private String insertExternalIdWithKeyThatDoesntMatchNoteId(
+      Repository repo, RevWalk rw, String externalId) throws IOException {
+    ObjectId rev = ExternalIdReader.readRevision(repo);
+    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+
+    ExternalId extId = ExternalId.create(ExternalId.Key.parse(externalId), admin.id);
+
+    try (ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId noteId = ExternalId.Key.parse(externalId + "x").sha1();
+      Config c = new Config();
+      extId.writeToConfig(c);
+      byte[] raw = c.toText().getBytes(UTF_8);
+      ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+      noteMap.set(noteId, dataBlob);
+
+      ExternalIdsUpdate.commit(
+          repo, rw, ins, rev, noteMap, "Add external ID", admin.getIdent(), admin.getIdent());
+      return noteId.getName();
+    }
+  }
+
+  private String insertExternalIdWithInvalidConfig(Repository repo, RevWalk rw, String externalId)
+      throws IOException {
+    ObjectId rev = ExternalIdReader.readRevision(repo);
+    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+
+    try (ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId noteId = ExternalId.Key.parse(externalId).sha1();
+      byte[] raw = "bad-config".getBytes(UTF_8);
+      ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+      noteMap.set(noteId, dataBlob);
+
+      ExternalIdsUpdate.commit(
+          repo, rw, ins, rev, noteMap, "Add external ID", admin.getIdent(), admin.getIdent());
+      return noteId.getName();
+    }
+  }
+
+  private String insertExternalIdWithEmptyNote(Repository repo, RevWalk rw, String externalId)
+      throws IOException {
+    ObjectId rev = ExternalIdReader.readRevision(repo);
+    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+
+    try (ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId noteId = ExternalId.Key.parse(externalId).sha1();
+      byte[] raw = "".getBytes(UTF_8);
+      ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+      noteMap.set(noteId, dataBlob);
+
+      ExternalIdsUpdate.commit(
+          repo, rw, ins, rev, noteMap, "Add external ID", admin.getIdent(), admin.getIdent());
+      return noteId.getName();
+    }
+  }
+
+  private ExternalId createExternalIdForNonExistingAccount(String externalId) {
+    return ExternalId.create(ExternalId.Key.parse(externalId), new Account.Id(1));
+  }
+
+  private ExternalId createExternalIdWithInvalidEmail(String externalId) {
+    return ExternalId.createWithEmail(ExternalId.Key.parse(externalId), admin.id, "invalid-email");
+  }
+
+  private ExternalId createExternalIdWithDuplicateEmail(String externalId) {
+    return ExternalId.createWithEmail(ExternalId.Key.parse(externalId), admin.id, admin.email);
+  }
+
+  private ExternalId createExternalIdWithBadPassword(String username) {
+    return ExternalId.create(
+        ExternalId.Key.create(SCHEME_USERNAME, username),
+        admin.id,
+        null,
+        "non-hashed-password-is-not-allowed");
+  }
+
+  private static String nextId(String scheme, MutableInteger i) {
+    return scheme + ":foo" + ++i.value;
   }
 
   @Test
   public void retryOnLockFailure() throws Exception {
-    Retryer<Void> retryer =
+    Retryer<RefsMetaExternalIdsUpdate> retryer =
         ExternalIdsUpdate.retryerBuilder()
             .withBlockStrategy(
                 new BlockStrategy() {
@@ -192,12 +605,15 @@
         new ExternalIdsUpdate(
             repoManager,
             allUsers,
+            metricMaker,
+            externalIds,
+            new DisabledExternalIdCache(),
             serverIdent.get(),
             serverIdent.get(),
             () -> {
               if (!doneBgUpdate.getAndSet(true)) {
                 try {
-                  extIdsUpdate.create().insert(db, ExternalId.create(barId, admin.id));
+                  extIdsUpdate.create().insert(ExternalId.create(barId, admin.id));
                 } catch (IOException | ConfigInvalidException | OrmException e) {
                   // Ignore, the successful insertion of the external ID is asserted later
                 }
@@ -205,7 +621,7 @@
             },
             retryer);
     assertThat(doneBgUpdate.get()).isFalse();
-    update.insert(db, ExternalId.create(fooId, admin.id));
+    update.insert(ExternalId.create(fooId, admin.id));
     assertThat(doneBgUpdate.get()).isTrue();
 
     assertThat(externalIds.get(fooId)).isNotNull();
@@ -224,24 +640,27 @@
         new ExternalIdsUpdate(
             repoManager,
             allUsers,
+            metricMaker,
+            externalIds,
+            new DisabledExternalIdCache(),
             serverIdent.get(),
             serverIdent.get(),
             () -> {
               try {
                 extIdsUpdate
                     .create()
-                    .insert(db, ExternalId.create(extIdsKeys[bgCounter.getAndAdd(1)], admin.id));
+                    .insert(ExternalId.create(extIdsKeys[bgCounter.getAndAdd(1)], admin.id));
               } catch (IOException | ConfigInvalidException | OrmException e) {
                 // Ignore, the successful insertion of the external ID is asserted later
               }
             },
-            RetryerBuilder.<Void>newBuilder()
+            RetryerBuilder.<RefsMetaExternalIdsUpdate>newBuilder()
                 .retryIfException(e -> e instanceof LockFailureException)
                 .withStopStrategy(StopStrategies.stopAfterAttempt(extIdsKeys.length))
                 .build());
     assertThat(bgCounter.get()).isEqualTo(0);
     try {
-      update.insert(db, ExternalId.create(ExternalId.Key.create("abc", "abc"), admin.id));
+      update.insert(ExternalId.create(ExternalId.Key.create("abc", "abc"), admin.id));
       fail("expected LockFailureException");
     } catch (LockFailureException e) {
       // Ignore, expected
@@ -251,4 +670,130 @@
       assertThat(externalIds.get(extIdKey)).isNotNull();
     }
   }
+
+  @Test
+  public void readExternalIdWithAccountIdThatCanBeExpressedInKiB() throws Exception {
+    ExternalId.Key extIdKey = ExternalId.Key.parse("foo:bar");
+    Account.Id accountId = new Account.Id(1024 * 100);
+    extIdsUpdate.create().insert(ExternalId.create(extIdKey, accountId));
+    ExternalId extId = externalIds.get(extIdKey);
+    assertThat(extId.accountId()).isEqualTo(accountId);
+  }
+
+  @Test
+  public void checkNoReloadAfterUpdate() throws Exception {
+    Set<ExternalId> expectedExtIds = new HashSet<>(externalIds.byAccount(admin.id));
+    externalIdReader.setFailOnLoad(true);
+
+    // insert external ID
+    ExternalId extId = ExternalId.create("foo", "bar", admin.id);
+    extIdsUpdate.create().insert(extId);
+    expectedExtIds.add(extId);
+    assertThat(externalIds.byAccount(admin.id)).containsExactlyElementsIn(expectedExtIds);
+
+    // update external ID
+    expectedExtIds.remove(extId);
+    extId = ExternalId.createWithEmail("foo", "bar", admin.id, "foo.bar@example.com");
+    extIdsUpdate.create().upsert(extId);
+    expectedExtIds.add(extId);
+    assertThat(externalIds.byAccount(admin.id)).containsExactlyElementsIn(expectedExtIds);
+
+    // delete external ID
+    extIdsUpdate.create().delete(extId);
+    expectedExtIds.remove(extId);
+    assertThat(externalIds.byAccount(admin.id)).containsExactlyElementsIn(expectedExtIds);
+  }
+
+  @Test
+  public void byAccountFailIfReadingExternalIdsFails() throws Exception {
+    externalIdReader.setFailOnLoad(true);
+
+    // update external ID branch so that external IDs need to be reloaded
+    insertExtIdBehindGerritsBack(ExternalId.create("foo", "bar", admin.id));
+
+    exception.expect(IOException.class);
+    externalIds.byAccount(admin.id);
+  }
+
+  @Test
+  public void byEmailFailIfReadingExternalIdsFails() throws Exception {
+    externalIdReader.setFailOnLoad(true);
+
+    // update external ID branch so that external IDs need to be reloaded
+    insertExtIdBehindGerritsBack(ExternalId.create("foo", "bar", admin.id));
+
+    exception.expect(IOException.class);
+    externalIds.byEmail(admin.email);
+  }
+
+  @Test
+  public void byAccountUpdateExternalIdsBehindGerritsBack() throws Exception {
+    Set<ExternalId> expectedExternalIds = new HashSet<>(externalIds.byAccount(admin.id));
+    ExternalId newExtId = ExternalId.create("foo", "bar", admin.id);
+    insertExtIdBehindGerritsBack(newExtId);
+    expectedExternalIds.add(newExtId);
+    assertThat(externalIds.byAccount(admin.id)).containsExactlyElementsIn(expectedExternalIds);
+  }
+
+  private void insertExtIdBehindGerritsBack(ExternalId extId) throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo);
+        ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId rev = ExternalIdReader.readRevision(repo);
+      NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+      ExternalIdsUpdate.insert(rw, ins, noteMap, extId);
+      ExternalIdsUpdate.commit(
+          repo, rw, ins, rev, noteMap, "insert new ID", serverIdent.get(), serverIdent.get());
+    }
+  }
+
+  private void addExtId(TestRepository<?> testRepo, ExternalId... extIds)
+      throws IOException, OrmDuplicateKeyException, ConfigInvalidException {
+    ObjectId rev = ExternalIdReader.readRevision(testRepo.getRepository());
+
+    try (ObjectInserter ins = testRepo.getRepository().newObjectInserter()) {
+      NoteMap noteMap = ExternalIdReader.readNoteMap(testRepo.getRevWalk(), rev);
+      for (ExternalId extId : extIds) {
+        ExternalIdsUpdate.insert(testRepo.getRevWalk(), ins, noteMap, extId);
+      }
+
+      ExternalIdsUpdate.commit(
+          testRepo.getRepository(),
+          testRepo.getRevWalk(),
+          ins,
+          rev,
+          noteMap,
+          "Add external ID",
+          admin.getIdent(),
+          admin.getIdent());
+    }
+  }
+
+  private List<AccountExternalIdInfo> toExternalIdInfos(Collection<ExternalId> extIds) {
+    return extIds.stream().map(this::toExternalIdInfo).collect(toList());
+  }
+
+  private AccountExternalIdInfo toExternalIdInfo(ExternalId extId) {
+    AccountExternalIdInfo info = new AccountExternalIdInfo();
+    info.identity = extId.key().get();
+    info.emailAddress = extId.email();
+    info.canDelete = !extId.isScheme(SCHEME_USERNAME) ? true : null;
+    info.trusted =
+        extId.isScheme(SCHEME_MAILTO)
+                || extId.isScheme(SCHEME_UUID)
+                || extId.isScheme(SCHEME_USERNAME)
+            ? true
+            : null;
+    return info;
+  }
+
+  private void allowPushOfExternalIds() throws IOException, ConfigInvalidException {
+    grant(allUsers, RefNames.REFS_EXTERNAL_IDS, Permission.READ);
+    grant(allUsers, RefNames.REFS_EXTERNAL_IDS, Permission.PUSH);
+  }
+
+  private void assertRefUpdateFailure(RemoteRefUpdate update, String msg) {
+    assertThat(update.getStatus()).isEqualTo(Status.REJECTED_OTHER_REASON);
+    assertThat(update.getMessage()).isEqualTo(msg);
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index c69391c..b221ec5 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -39,8 +39,10 @@
 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.ListChangesOption;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
@@ -62,6 +64,7 @@
 import com.google.gerrit.server.project.Util;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
+import java.util.EnumSet;
 import org.apache.http.Header;
 import org.apache.http.message.BasicHeader;
 import org.junit.After;
@@ -311,7 +314,7 @@
     in.label("Code-Review", 1);
 
     exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("on_behalf_of account " + user.id + " cannot see destination ref");
+    exception.expectMessage("on_behalf_of account " + user.id + " cannot see change");
     revision.review(in);
   }
 
@@ -375,7 +378,7 @@
     SubmitInput in = new SubmitInput();
     in.onBehalfOf = admin2.email;
     exception.expect(AuthException.class);
-    exception.expectMessage("submit on behalf of not permitted");
+    exception.expectMessage("submit as not permitted");
     gApi.changes().id(project.get() + "~master~" + r.getChangeId()).current().submit(in);
   }
 
@@ -390,7 +393,7 @@
     SubmitInput in = new SubmitInput();
     in.onBehalfOf = user.email;
     exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("on_behalf_of account " + user.id + " cannot see destination ref");
+    exception.expectMessage("on_behalf_of account " + user.id + " cannot see change");
     gApi.changes().id(changeId).current().submit(in);
   }
 
@@ -529,6 +532,28 @@
     assertThat(m.getRealAuthor()).isEqualTo(admin.id); // not user2
   }
 
+  @Test
+  public void changeMessageCreatedOnBehalfOfHasRealUser() throws Exception {
+    allowCodeReviewOnBehalfOf();
+
+    PushOneCommit.Result r = createChange();
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.message = "Message on behalf of";
+    in.label("Code-Review", 1);
+
+    setApiUser(accounts.user2());
+    gApi.changes().id(r.getChangeId()).revision(r.getPatchSetId().getId()).review(in);
+
+    ChangeInfo info =
+        gApi.changes().id(r.getChangeId()).get(EnumSet.of(ListChangesOption.MESSAGES));
+    assertThat(info.messages).hasSize(2);
+
+    ChangeMessageInfo changeMessageInfo = Iterables.getLast(info.messages);
+    assertThat(changeMessageInfo.realAuthor).isNotNull();
+    assertThat(changeMessageInfo.realAuthor._accountId).isEqualTo(accounts.user2().id.get());
+  }
+
   private void allowCodeReviewOnBehalfOf() throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
     LabelType codeReviewType = Util.codeReview();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
index 9378591..17dabde 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
@@ -18,23 +18,16 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.PutUsername;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import java.util.Collections;
 import org.junit.Test;
 
 public class PutUsernameIT extends AbstractDaemonTest {
-  @Inject private SchemaFactory<ReviewDb> reviewDbProvider;
-
   @Test
   public void set() throws Exception {
     PutUsername.Input in = new PutUsername.Input();
     in.username = "myUsername";
-    RestResponse r = adminRestSession.put("/accounts/" + createUser().get() + "/username", in);
+    RestResponse r =
+        adminRestSession.put("/accounts/" + accounts.create().id.get() + "/username", in);
     r.assertOK();
     assertThat(newGson().fromJson(r.getReader(), String.class)).isEqualTo(in.username);
   }
@@ -43,7 +36,9 @@
   public void setExisting_Conflict() throws Exception {
     PutUsername.Input in = new PutUsername.Input();
     in.username = admin.username;
-    adminRestSession.put("/accounts/" + createUser().get() + "/username", in).assertConflict();
+    adminRestSession
+        .put("/accounts/" + accounts.create().id.get() + "/username", in)
+        .assertConflict();
   }
 
   @Test
@@ -57,13 +52,4 @@
   public void delete_MethodNotAllowed() throws Exception {
     adminRestSession.put("/accounts/" + admin.username + "/username").assertMethodNotAllowed();
   }
-
-  private Account.Id createUser() throws Exception {
-    try (ReviewDb db = reviewDbProvider.open()) {
-      Account.Id id = new Account.Id(db.nextAccountId());
-      Account a = new Account(id, TimeUtil.nowTs());
-      db.accounts().insert(Collections.singleton(a));
-      return id;
-    }
-  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 94fa99b..e4b5ff5 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.Truth8.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
@@ -32,6 +33,7 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
@@ -95,6 +97,8 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.transport.RefSpec;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -112,8 +116,6 @@
 
   @Inject private IdentifiedUser.GenericFactory userFactory;
 
-  @Inject private BatchUpdate.Factory updateFactory;
-
   @Inject private DynamicSet<OnSubmitValidationListener> onSubmitValidationListeners;
   private RegistrationHandle onSubmitValidatorHandle;
 
@@ -306,7 +308,7 @@
   public void submitNoPermission() throws Exception {
     // create project where submit is blocked
     Project.NameKey p = createProject("p");
-    block(Permission.SUBMIT, REGISTERED_USERS, "refs/*", p);
+    block(p, "refs/*", Permission.SUBMIT, REGISTERED_USERS);
 
     TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), repo);
@@ -494,6 +496,20 @@
   }
 
   @Test
+  public void submitWorkInProgressChange() throws Exception {
+    PushOneCommit.Result change = createWorkInProgressChange();
+    Change.Id num = change.getChange().getId();
+    submitWithConflict(
+        change.getChangeId(),
+        "Failed to submit 1 change due to the following problems:\n"
+            + "Change "
+            + num
+            + ": Change "
+            + num
+            + " is work in progress");
+  }
+
+  @Test
   public void submitDraftPatchSet() throws Exception {
     PushOneCommit.Result change = createChange();
     PushOneCommit.Result draft = amendChangeAsDraft(change.getChangeId());
@@ -756,11 +772,16 @@
         new OnSubmitValidationListener() {
           @Override
           public void preBranchUpdate(Arguments args) throws ValidationException {
-            assertThat(args.getCommands().keySet()).contains("refs/heads/master");
-            try (RevWalk rw = args.newRevWalk()) {
-              rw.parseBody(rw.parseCommit(args.getCommands().get("refs/heads/master").getNewId()));
+            String master = "refs/heads/master";
+            assertThat(args.getCommands()).containsKey(master);
+            ReceiveCommand cmd = args.getCommands().get(master);
+            ObjectId newMasterId = cmd.getNewId();
+            try (Repository repo = repoManager.openRepository(args.getProject())) {
+              assertThat(repo.exactRef(master).getObjectId()).isEqualTo(cmd.getOldId());
+              assertThat(args.getRef(master)).hasValue(newMasterId);
+              args.getRevWalk().parseBody(args.getRevWalk().parseCommit(newMasterId));
             } catch (IOException e) {
-              assertThat(e).isNull();
+              throw new AssertionError("failed checking new ref value", e);
             }
             projectsCalled.add(args.getProject().get());
             if (projectsCalled.size() == 2) {
@@ -783,10 +804,81 @@
     }
   }
 
+  @Test
+  public void submitWithCommitAndItsMergeCommitTogether() throws Exception {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+
+    RevCommit initialHead = getRemoteHead();
+
+    // Create a stable branch and bootstrap it.
+    gApi.projects().name(project.get()).branch("stable").create(new BranchInput());
+    PushOneCommit push =
+        pushFactory.create(db, user.getIdent(), testRepo, "initial commit", "a.txt", "a");
+    PushOneCommit.Result change = push.to("refs/heads/stable");
+
+    RevCommit stable = getRemoteHead(project, "stable");
+    RevCommit master = getRemoteHead(project, "master");
+
+    assertThat(master).isEqualTo(initialHead);
+    assertThat(stable).isEqualTo(change.getCommit());
+
+    testRepo.git().fetch().call();
+    testRepo.git().branchCreate().setName("stable").setStartPoint(stable).call();
+    testRepo.git().branchCreate().setName("master").setStartPoint(master).call();
+
+    // Create a fix in stable branch.
+    testRepo.reset(stable);
+    RevCommit fix =
+        testRepo
+            .commit()
+            .parent(stable)
+            .message("small fix")
+            .add("b.txt", "b")
+            .insertChangeId()
+            .create();
+    testRepo.branch("refs/heads/stable").update(fix);
+    testRepo
+        .git()
+        .push()
+        .setRefSpecs(new RefSpec("refs/heads/stable:refs/for/stable/" + name("topic")))
+        .call();
+
+    // Merge the fix into master.
+    testRepo.reset(master);
+    RevCommit merge =
+        testRepo
+            .commit()
+            .parent(master)
+            .parent(fix)
+            .message("Merge stable into master")
+            .insertChangeId()
+            .create();
+    testRepo.branch("refs/heads/master").update(merge);
+    testRepo
+        .git()
+        .push()
+        .setRefSpecs(new RefSpec("refs/heads/master:refs/for/master/" + name("topic")))
+        .call();
+
+    // Submit together.
+    String fixId = GitUtil.getChangeId(testRepo, fix).get();
+    String mergeId = GitUtil.getChangeId(testRepo, merge).get();
+    approve(fixId);
+    approve(mergeId);
+    submit(mergeId);
+    assertMerged(fixId);
+    assertMerged(mergeId);
+    testRepo.git().fetch().call();
+    RevWalk rw = testRepo.getRevWalk();
+    master = rw.parseCommit(getRemoteHead(project, "master"));
+    assertThat(rw.isMergedInto(merge, master)).isTrue();
+    assertThat(rw.isMergedInto(fix, master)).isTrue();
+  }
+
   private void setChangeStatusToNew(PushOneCommit.Result... changes) throws Exception {
     for (PushOneCommit.Result change : changes) {
       try (BatchUpdate bu =
-          updateFactory.create(db, project, userFactory.create(admin.id), TimeUtil.nowTs())) {
+          batchUpdateFactory.create(db, project, userFactory.create(admin.id), TimeUtil.nowTs())) {
         bu.addOp(
             change.getChange().getId(),
             new BatchUpdateOp() {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
index 0250db1..fa1b95f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
@@ -18,11 +18,9 @@
 import static com.google.common.truth.TruthJUnit.assume;
 
 import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
 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.InheritableBoolean;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -34,7 +32,6 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.RefSpec;
 import org.junit.Test;
 
 public abstract class AbstractSubmitByMerge extends AbstractSubmit {
@@ -130,6 +127,9 @@
 
   @Test
   public void repairChangeStateAfterFailure() throws Exception {
+    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
+    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
+
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
@@ -178,74 +178,4 @@
     }
   }
 
-  @Test
-  public void submitWithCommitAndItsMergeCommitTogether() throws Exception {
-    assume().that(isSubmitWholeTopicEnabled()).isTrue();
-
-    RevCommit initialHead = getRemoteHead();
-
-    // Create a stable branch and bootstrap it.
-    gApi.projects().name(project.get()).branch("stable").create(new BranchInput());
-    PushOneCommit push =
-        pushFactory.create(db, user.getIdent(), testRepo, "initial commit", "a.txt", "a");
-    PushOneCommit.Result change = push.to("refs/heads/stable");
-
-    RevCommit stable = getRemoteHead(project, "stable");
-    RevCommit master = getRemoteHead(project, "master");
-
-    assertThat(master).isEqualTo(initialHead);
-    assertThat(stable).isEqualTo(change.getCommit());
-
-    testRepo.git().fetch().call();
-    testRepo.git().branchCreate().setName("stable").setStartPoint(stable).call();
-    testRepo.git().branchCreate().setName("master").setStartPoint(master).call();
-
-    // Create a fix in stable branch.
-    testRepo.reset(stable);
-    RevCommit fix =
-        testRepo
-            .commit()
-            .parent(stable)
-            .message("small fix")
-            .add("b.txt", "b")
-            .insertChangeId()
-            .create();
-    testRepo.branch("refs/heads/stable").update(fix);
-    testRepo
-        .git()
-        .push()
-        .setRefSpecs(new RefSpec("refs/heads/stable:refs/for/stable/" + name("topic")))
-        .call();
-
-    // Merge the fix into master.
-    testRepo.reset(master);
-    RevCommit merge =
-        testRepo
-            .commit()
-            .parent(master)
-            .parent(fix)
-            .message("Merge stable into master")
-            .insertChangeId()
-            .create();
-    testRepo.branch("refs/heads/master").update(merge);
-    testRepo
-        .git()
-        .push()
-        .setRefSpecs(new RefSpec("refs/heads/master:refs/for/master/" + name("topic")))
-        .call();
-
-    // Submit together.
-    String fixId = GitUtil.getChangeId(testRepo, fix).get();
-    String mergeId = GitUtil.getChangeId(testRepo, merge).get();
-    approve(fixId);
-    approve(mergeId);
-    submit(mergeId);
-    assertMerged(fixId);
-    assertMerged(mergeId);
-    testRepo.git().fetch().call();
-    RevWalk rw = testRepo.getRevWalk();
-    master = rw.parseCommit(getRemoteHead(project, "master"));
-    assertThat(rw.isMergedInto(merge, master)).isTrue();
-    assertThat(rw.isMergedInto(fix, master)).isTrue();
-  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
index d8aa35c..b94b062 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.GitUtil.getChangeId;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
@@ -244,6 +245,9 @@
 
   @Test
   public void repairChangeStateAfterFailure() throws Exception {
+    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
+    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
+
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AssigneeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
index 35ba1a2..a905d38 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
@@ -16,19 +16,26 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 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.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.testutil.FakeEmailSender.Message;
 import com.google.gerrit.testutil.TestTimeUtil;
 import java.util.Iterator;
 import java.util.List;
+import org.eclipse.jgit.transport.RefSpec;
 import org.junit.AfterClass;
 import org.junit.BeforeClass;
 import org.junit.Test;
@@ -124,6 +131,43 @@
     assertThat(deleteAssignee(r)).isNull();
   }
 
+  @Test
+  @Sandboxed
+  public void setAssigneeToInactiveUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.accounts().id(user.getId().get()).setActive(false);
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("is not active");
+    setAssignee(r, user.email);
+  }
+
+  @Test
+  public void setAssigneeForNonVisibleChange() throws Exception {
+    git().fetch().setRefSpecs(new RefSpec("refs/meta/config:refs/meta/config")).call();
+    testRepo.reset(RefNames.REFS_CONFIG);
+    PushOneCommit.Result r = createChange("refs/for/refs/meta/config");
+    exception.expect(AuthException.class);
+    exception.expectMessage("read not permitted");
+    setAssignee(r, user.email);
+  }
+
+  @Test
+  public void setAssigneeNotAllowedWithoutPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    exception.expectMessage("not permitted");
+    setAssignee(r, user.email);
+  }
+
+  @Test
+  public void setAssigneeAllowedWithPermission() throws Exception {
+    PushOneCommit.Result r = createChange();
+    grant(project, "refs/heads/master", Permission.EDIT_ASSIGNEE, false, REGISTERED_USERS);
+    setApiUser(user);
+    assertThat(setAssignee(r, user.email)._accountId).isEqualTo(user.getId().get());
+  }
+
   private AccountInfo getAssignee(PushOneCommit.Result r) throws Exception {
     return gApi.changes().id(r.getChange().getId().get()).getAssignee();
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
new file mode 100644
index 0000000..4a874a4
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
@@ -0,0 +1,350 @@
+// Copyright (C) 2017 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.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+
+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.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerResult;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.testutil.FakeEmailSender.Message;
+import java.util.EnumSet;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class ChangeReviewersByEmailIT extends AbstractDaemonTest {
+
+  @Before
+  public void setUp() throws Exception {
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(conf);
+  }
+
+  @Test
+  public void addByEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput input = new AddReviewerInput();
+      input.reviewer = toRfcAddressString(acc);
+      input.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(input);
+
+      ChangeInfo info =
+          gApi.changes().id(r.getChangeId()).get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+      assertThat(info.reviewers).isEqualTo(ImmutableMap.of(state, ImmutableList.of(acc)));
+      // All reviewers added by email should be removable
+      assertThat(info.removableReviewers).isEqualTo(ImmutableList.of(acc));
+    }
+  }
+
+  @Test
+  public void addByEmailAndById() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo byEmail = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+    AccountInfo byId = new AccountInfo(user.id.get());
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput inputByEmail = new AddReviewerInput();
+      inputByEmail.reviewer = toRfcAddressString(byEmail);
+      inputByEmail.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(inputByEmail);
+
+      AddReviewerInput inputById = new AddReviewerInput();
+      inputById.reviewer = user.email;
+      inputById.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(inputById);
+
+      ChangeInfo info =
+          gApi.changes().id(r.getChangeId()).get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+      assertThat(info.reviewers).isEqualTo(ImmutableMap.of(state, ImmutableList.of(byId, byEmail)));
+      // All reviewers (both by id and by email) should be removable
+      assertThat(info.removableReviewers).isEqualTo(ImmutableList.of(byId, byEmail));
+    }
+  }
+
+  @Test
+  public void removeByEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput addInput = new AddReviewerInput();
+      addInput.reviewer = toRfcAddressString(acc);
+      addInput.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(addInput);
+
+      gApi.changes().id(r.getChangeId()).reviewer(acc.email).remove();
+
+      ChangeInfo info =
+          gApi.changes().id(r.getChangeId()).get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+      assertThat(info.reviewers).isEmpty();
+    }
+  }
+
+  @Test
+  public void convertFromCCToReviewer() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerInput addInput = new AddReviewerInput();
+    addInput.reviewer = toRfcAddressString(acc);
+    addInput.state = ReviewerState.CC;
+    gApi.changes().id(r.getChangeId()).addReviewer(addInput);
+
+    AddReviewerInput modifyInput = new AddReviewerInput();
+    modifyInput.reviewer = addInput.reviewer;
+    modifyInput.state = ReviewerState.REVIEWER;
+    gApi.changes().id(r.getChangeId()).addReviewer(modifyInput);
+
+    ChangeInfo info =
+        gApi.changes().id(r.getChangeId()).get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+    assertThat(info.reviewers)
+        .isEqualTo(ImmutableMap.of(ReviewerState.REVIEWER, ImmutableList.of(acc)));
+  }
+
+  @Test
+  public void addedReviewersGetNotified() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput input = new AddReviewerInput();
+      input.reviewer = toRfcAddressString(acc);
+      input.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(input);
+
+      List<Message> messages = sender.getMessages();
+      assertThat(messages).hasSize(1);
+      assertThat(messages.get(0).rcpt()).containsExactly(Address.parse(input.reviewer));
+      sender.clear();
+    }
+  }
+
+  @Test
+  public void removingReviewerTriggersNotification() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput addInput = new AddReviewerInput();
+      addInput.reviewer = toRfcAddressString(acc);
+      addInput.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(addInput);
+
+      // Review change as user
+      ReviewInput reviewInput = new ReviewInput();
+      reviewInput.message = "I have a comment";
+      setApiUser(user);
+      revision(r).review(reviewInput);
+      setApiUser(admin);
+
+      sender.clear();
+
+      // Delete as admin
+      gApi.changes().id(r.getChangeId()).reviewer(addInput.reviewer).remove();
+
+      List<Message> messages = sender.getMessages();
+      assertThat(messages).hasSize(1);
+      assertThat(messages.get(0).rcpt())
+          .containsExactly(Address.parse(addInput.reviewer), user.emailAddress);
+      sender.clear();
+    }
+  }
+
+  @Test
+  public void reviewerAndCCReceiveRegularNotification() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput input = new AddReviewerInput();
+      input.reviewer = toRfcAddressString(acc);
+      input.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(input);
+      sender.clear();
+
+      gApi.changes()
+          .id(r.getChangeId())
+          .revision(r.getCommit().name())
+          .review(ReviewInput.approve());
+
+      if (state == ReviewerState.CC) {
+        assertNotifyCc(Address.parse(input.reviewer));
+      } else {
+        assertNotifyTo(Address.parse(input.reviewer));
+      }
+    }
+  }
+
+  @Test
+  public void reviewerAndCCReceiveSameEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    PushOneCommit.Result r = createChange();
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      for (int i = 0; i < 10; i++) {
+        AddReviewerInput input = new AddReviewerInput();
+        input.reviewer = String.format("%s-%s@gerritcodereview.com", state, i);
+        input.state = state;
+        gApi.changes().id(r.getChangeId()).addReviewer(input);
+      }
+    }
+
+    // Also add user as a regular reviewer
+    AddReviewerInput input = new AddReviewerInput();
+    input.reviewer = user.email;
+    input.state = ReviewerState.REVIEWER;
+    gApi.changes().id(r.getChangeId()).addReviewer(input);
+
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    // Assert that only one email was sent out to everyone
+    assertThat(sender.getMessages()).hasSize(1);
+  }
+
+  @Test
+  public void addingMultipleReviewersAndCCsAtOnceSendsOnlyOneEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    PushOneCommit.Result r = createChange();
+    ReviewInput reviewInput = new ReviewInput();
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      for (int i = 0; i < 10; i++) {
+        reviewInput.reviewer(String.format("%s-%s@gerritcodereview.com", state, i), state, true);
+      }
+    }
+    assertThat(reviewInput.reviewers).hasSize(20);
+
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(reviewInput);
+    assertThat(sender.getMessages()).hasSize(1);
+  }
+
+  @Test
+  public void rejectMissingEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer("");
+    assertThat(result.error).isEqualTo(" is not a valid user identifier");
+    assertThat(result.reviewers).isNull();
+  }
+
+  @Test
+  public void rejectMalformedEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer("Foo Bar <foo.bar@");
+    assertThat(result.error).isEqualTo("Foo Bar <foo.bar@ is not a valid user identifier");
+    assertThat(result.reviewers).isNull();
+  }
+
+  @Test
+  public void rejectOnNonPublicChange() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    PushOneCommit.Result r = createDraftChange();
+
+    AddReviewerResult result =
+        gApi.changes().id(r.getChangeId()).addReviewer("Foo Bar <foo.bar@gerritcodereview.com>");
+    assertThat(result.error)
+        .isEqualTo(
+            "Foo Bar <foo.bar@gerritcodereview.com> does not have permission to see this change");
+    assertThat(result.reviewers).isNull();
+  }
+
+  @Test
+  public void rejectWhenFeatureIsDisabled() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.FALSE;
+    gApi.projects().name(project.get()).config(conf);
+
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerResult result =
+        gApi.changes().id(r.getChangeId()).addReviewer("Foo Bar <foo.bar@gerritcodereview.com>");
+    assertThat(result.error)
+        .isEqualTo(
+            "Foo Bar <foo.bar@gerritcodereview.com> does not identify a registered user or group");
+    assertThat(result.reviewers).isNull();
+  }
+
+  @Test
+  public void reviewersByEmailAreServedFromIndex() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput input = new AddReviewerInput();
+      input.reviewer = toRfcAddressString(acc);
+      input.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(input);
+
+      notesMigration.setFailOnLoad(true);
+      try {
+        ChangeInfo info =
+            Iterables.getOnlyElement(
+                gApi.changes()
+                    .query(r.getChangeId())
+                    .withOption(ListChangesOption.DETAILED_LABELS)
+                    .get());
+        assertThat(info.reviewers).isEqualTo(ImmutableMap.of(state, ImmutableList.of(acc)));
+      } finally {
+        notesMigration.setFailOnLoad(false);
+      }
+    }
+  }
+
+  private static String toRfcAddressString(AccountInfo info) {
+    return (new Address(info.name, info.email)).toString();
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index 66966c3..0809bf2 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -16,18 +16,24 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
 import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 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.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.NotifyInfo;
+import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
 import com.google.gerrit.extensions.client.ReviewerState;
@@ -42,6 +48,7 @@
 import com.google.gson.stream.JsonReader;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
@@ -655,6 +662,64 @@
     assertThat(reviewerResult.ccs).hasSize(1);
   }
 
+  @Test
+  public void removingReviewerRemovesTheirVote() throws Exception {
+    String crLabel = "Code-Review";
+    PushOneCommit.Result r = createChange();
+    ReviewInput input = ReviewInput.approve().reviewer(admin.email);
+    ReviewResult addResult = review(r.getChangeId(), r.getCommit().name(), input);
+    assertThat(addResult.reviewers).isNotNull();
+    assertThat(addResult.reviewers).hasSize(1);
+
+    Map<String, LabelInfo> changeLabels = getChangeLabels(r.getChangeId());
+    assertThat(changeLabels.get(crLabel).all).hasSize(1);
+
+    RestResponse deleteResult = deleteReviewer(r.getChangeId(), admin);
+    deleteResult.assertNoContent();
+
+    changeLabels = getChangeLabels(r.getChangeId());
+    assertThat(changeLabels.get(crLabel).all).isNull();
+
+    // Check that the vote is gone even after the reviewer is added back
+    addReviewer(r.getChangeId(), admin.email);
+    changeLabels = getChangeLabels(r.getChangeId());
+    assertThat(changeLabels.get(crLabel).all).isNull();
+  }
+
+  @Test
+  public void notifyDetailsWorkOnPostReview() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestAccount userToNotify = createAccounts(1, "notify-details-post-review").get(0);
+
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.reviewer(user.email, ReviewerState.REVIEWER, true);
+    reviewInput.notify = NotifyHandling.NONE;
+    reviewInput.notifyDetails =
+        ImmutableMap.of(RecipientType.TO, new NotifyInfo(ImmutableList.of(userToNotify.email)));
+
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).rcpt()).containsExactly(userToNotify.emailAddress);
+  }
+
+  @Test
+  public void notifyDetailsWorkOnPostReviewers() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestAccount userToNotify = createAccounts(1, "notify-details-post-reviewers").get(0);
+
+    AddReviewerInput addReviewer = new AddReviewerInput();
+    addReviewer.reviewer = user.email;
+    addReviewer.notify = NotifyHandling.NONE;
+    addReviewer.notifyDetails =
+        ImmutableMap.of(RecipientType.TO, new NotifyInfo(ImmutableList.of(userToNotify.email)));
+
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).addReviewer(addReviewer);
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(sender.getMessages().get(0).rcpt()).containsExactly(userToNotify.emailAddress);
+  }
+
   private AddReviewerResult addReviewer(String changeId, String reviewer) throws Exception {
     return addReviewer(changeId, reviewer, SC_OK);
   }
@@ -735,4 +800,8 @@
     }
     return result;
   }
+
+  private Map<String, LabelInfo> getChangeLabels(String changeId) throws Exception {
+    return gApi.changes().id(changeId).get(EnumSet.of(DETAILED_LABELS)).labels;
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index f79b5fa..f1a598c 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -16,13 +16,17 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.common.data.Permission.READ;
 import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static org.eclipse.jgit.lib.Constants.SIGNED_OFF_BY_TAG;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.RestResponse;
@@ -30,14 +34,20 @@
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.MergeInput;
+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.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
@@ -45,10 +55,14 @@
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.FakeEmailSender.Message;
 import com.google.gerrit.testutil.TestTimeUtil;
+import java.util.Iterator;
 import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -148,6 +162,43 @@
   }
 
   @Test
+  public void createNewPrivateChange() throws Exception {
+    ChangeInput input = newChangeInput(ChangeStatus.NEW);
+    input.isPrivate = true;
+    assertCreateSucceeds(input);
+  }
+
+  @Test
+  public void createNewWorkInProgressChange() throws Exception {
+    ChangeInput input = newChangeInput(ChangeStatus.NEW);
+    input.workInProgress = true;
+    assertCreateSucceeds(input);
+  }
+
+  @Test
+  public void createChangeWithoutAccessToParentCommitFails() throws Exception {
+    Map<String, PushOneCommit.Result> results =
+        changeInTwoBranches("invisible-branch", "a.txt", "visible-branch", "b.txt");
+    block(project, "refs/heads/invisible-branch", READ, REGISTERED_USERS);
+
+    ChangeInput in = newChangeInput(ChangeStatus.NEW);
+    in.branch = "visible-branch";
+    in.baseChange = results.get("invisible-branch").getChangeId();
+    assertCreateFails(
+        in, UnprocessableEntityException.class, "Base change not found: " + in.baseChange);
+  }
+
+  @Test
+  public void createChangeOnInvisibleBranchFails() throws Exception {
+    changeInTwoBranches("invisible-branch", "a.txt", "branchB", "b.txt");
+    block(project, "refs/heads/invisible-branch", READ, REGISTERED_USERS);
+
+    ChangeInput in = newChangeInput(ChangeStatus.NEW);
+    in.branch = "invisible-branch";
+    assertCreateFails(in, AuthException.class, "cannot upload review");
+  }
+
+  @Test
   public void noteDbCommit() throws Exception {
     assume().that(notesMigration.readChanges()).isTrue();
 
@@ -278,6 +329,79 @@
     assertCreateSucceeds(in);
   }
 
+  @Test
+  public void cherryPickCommitWithoutChangeId() throws Exception {
+    // This test is a little superfluous, since the current cherry-pick code ignores
+    // the commit message of the to-be-cherry-picked change, using the one in
+    // CherryPickInput instead.
+    CherryPickInput input = new CherryPickInput();
+    input.destination = "foo";
+    input.message = "it goes to foo branch";
+    gApi.projects().name(project.get()).branch(input.destination).create(new BranchInput());
+
+    RevCommit revCommit = createNewCommitWithoutChangeId();
+    ChangeInfo changeInfo =
+        gApi.projects().name(project.get()).commit(revCommit.getName()).cherryPick(input).get();
+
+    assertThat(changeInfo.messages).hasSize(1);
+    Iterator<ChangeMessageInfo> messageIterator = changeInfo.messages.iterator();
+    String expectedMessage =
+        String.format("Patch Set 1: Cherry Picked from commit %s.", revCommit.getName());
+    assertThat(messageIterator.next().message).isEqualTo(expectedMessage);
+
+    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    assertThat(revInfo).isNotNull();
+    CommitInfo commitInfo = revInfo.commit;
+    assertThat(commitInfo.message)
+        .isEqualTo(input.message + "\n\nChange-Id: " + changeInfo.changeId + "\n");
+  }
+
+  @Test
+  public void cherryPickCommitWithChangeId() throws Exception {
+    CherryPickInput input = new CherryPickInput();
+    input.destination = "foo";
+
+    RevCommit revCommit = createChange().getCommit();
+    List<String> footers = revCommit.getFooterLines("Change-Id");
+    assertThat(footers).hasSize(1);
+    String changeId = footers.get(0);
+
+    input.message = "it goes to foo branch\n\nChange-Id: " + changeId;
+    gApi.projects().name(project.get()).branch(input.destination).create(new BranchInput());
+
+    ChangeInfo changeInfo =
+        gApi.projects().name(project.get()).commit(revCommit.getName()).cherryPick(input).get();
+
+    assertThat(changeInfo.messages).hasSize(1);
+    Iterator<ChangeMessageInfo> messageIterator = changeInfo.messages.iterator();
+    String expectedMessage =
+        String.format("Patch Set 1: Cherry Picked from commit %s.", revCommit.getName());
+    assertThat(messageIterator.next().message).isEqualTo(expectedMessage);
+
+    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    assertThat(revInfo).isNotNull();
+    assertThat(revInfo.commit.message).isEqualTo(input.message + "\n");
+  }
+
+  private RevCommit createNewCommitWithoutChangeId() throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk walk = new RevWalk(repo)) {
+      Ref ref = repo.exactRef("refs/heads/master");
+      RevCommit tip = null;
+      if (ref != null) {
+        tip = walk.parseCommit(ref.getObjectId());
+      }
+      TestRepository<?> testSrcRepo = new TestRepository<>(repo);
+      TestRepository<?>.BranchBuilder builder = testSrcRepo.branch("refs/heads/master");
+      RevCommit revCommit =
+          tip == null
+              ? builder.commit().message("commit 1").add("a.txt", "content").create()
+              : builder.commit().parent(tip).message("commit 1").add("a.txt", "content").create();
+      assertThat(GitUtil.getChangeId(testSrcRepo, revCommit).isPresent()).isFalse();
+      return revCommit;
+    }
+  }
+
   private ChangeInput newChangeInput(ChangeStatus status) {
     ChangeInput in = new ChangeInput();
     in.project = project.get();
@@ -295,6 +419,8 @@
     assertThat(out.subject).isEqualTo(in.subject);
     assertThat(out.topic).isEqualTo(in.topic);
     assertThat(out.status).isEqualTo(in.status);
+    assertThat(out.isPrivate).isEqualTo(in.isPrivate);
+    assertThat(out.workInProgress).isEqualTo(in.workInProgress);
     assertThat(out.revisions).hasSize(1);
     assertThat(out.submitted).isNull();
     Boolean draft = Iterables.getOnlyElement(out.revisions.values()).draft;
@@ -347,8 +473,18 @@
     return in;
   }
 
-  private void changeInTwoBranches(String branchA, String fileA, String branchB, String fileB)
-      throws Exception {
+  /**
+   * Create an empty commit in master, two new branches with one commit each.
+   *
+   * @param branchA name of first branch to create
+   * @param fileA name of file to commit to branchA
+   * @param branchB name of second branch to create
+   * @param fileB name of file to commit to branchB
+   * @return A {@code Map} of branchName => commit result.
+   * @throws Exception
+   */
+  private Map<String, Result> changeInTwoBranches(
+      String branchA, String fileA, String branchB, String fileB) throws Exception {
     // create a initial commit in master
     Result initialCommit =
         pushFactory
@@ -373,5 +509,7 @@
     commitB.setParent(initialCommit.getCommit());
     Result changeB = commitB.to("refs/heads/" + branchB);
     changeB.assertOkStatus();
+
+    return ImmutableMap.of("master", initialCommit, branchA, changeA, branchB, changeB);
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteDraftPatchSetIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteDraftPatchSetIT.java
index 11ff612..3eee3a4 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteDraftPatchSetIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DeleteDraftPatchSetIT.java
@@ -92,7 +92,7 @@
     din.message = "comment on a.txt";
     gApi.changes().id(changeId).current().createDraft(din);
 
-    if (notesMigration.writeChanges()) {
+    if (notesMigration.commitChangeWrites()) {
       assertThat(getDraftRef(admin, id)).isNotNull();
     }
 
@@ -110,7 +110,7 @@
     deletePatchSet(changeId, ps);
     assertThat(queryProvider.get().byKeyPrefix(changeId)).isEmpty();
 
-    if (notesMigration.writeChanges()) {
+    if (notesMigration.commitChangeWrites()) {
       assertThat(getDraftRef(admin, id)).isNull();
       assertThat(getMetaRef(id)).isNull();
     }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DraftChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DraftChangeIT.java
index da7d7b5..904e21e 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DraftChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DraftChangeIT.java
@@ -45,7 +45,6 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.testutil.ConfigSuite;
-import com.google.inject.Inject;
 import java.util.Collection;
 import java.util.EnumSet;
 import java.util.List;
@@ -58,8 +57,6 @@
     return allowDraftsDisabledConfig();
   }
 
-  @Inject private BatchUpdate.Factory updateFactory;
-
   @Test
   public void deleteDraftChange() throws Exception {
     assume().that(isAllowDrafts()).isTrue();
@@ -82,14 +79,13 @@
     PushOneCommit.Result changeResult = createDraftChange();
     changeResult.assertOkStatus();
     String changeId = changeResult.getChangeId();
-    Change.Id id = changeResult.getChange().getId();
 
     // The user needs to be able to see the draft change (which reviewers can).
     gApi.changes().id(changeId).addReviewer(user.fullName);
 
     setApiUser(user);
     exception.expect(AuthException.class);
-    exception.expectMessage(String.format("Deleting change %s is not permitted", id));
+    exception.expectMessage("delete not permitted");
     gApi.changes().id(changeId).delete();
   }
 
@@ -130,15 +126,15 @@
     String changeId = changeResult.getChangeId();
 
     // Grant those permissions to admins.
-    grant(Permission.VIEW_DRAFTS, project, "refs/*");
-    grant(Permission.DELETE_DRAFTS, project, "refs/*");
+    grant(project, "refs/*", Permission.VIEW_DRAFTS);
+    grant(project, "refs/*", Permission.DELETE_DRAFTS);
 
     try {
       setApiUser(admin);
       gApi.changes().id(changeId).delete();
     } finally {
-      removePermission(Permission.DELETE_DRAFTS, project, "refs/*");
-      removePermission(Permission.VIEW_DRAFTS, project, "refs/*");
+      removePermission(project, "refs/*", Permission.DELETE_DRAFTS);
+      removePermission(project, "refs/*", Permission.VIEW_DRAFTS);
     }
 
     setApiUser(user);
@@ -245,7 +241,7 @@
 
   private void markChangeAsDraft(Change.Id id) throws Exception {
     try (BatchUpdate batchUpdate =
-        updateFactory.create(db, project, atrScope.get().getUser(), TimeUtil.nowTs())) {
+        batchUpdateFactory.create(db, project, atrScope.get().getUser(), TimeUtil.nowTs())) {
       batchUpdate.addOp(id, new MarkChangeAsDraftUpdateOp());
       batchUpdate.execute();
     }
@@ -257,7 +253,7 @@
   private void setDraftStatusOfPatchSetsOfChange(Change.Id id, boolean draftStatus)
       throws Exception {
     try (BatchUpdate batchUpdate =
-        updateFactory.create(db, project, atrScope.get().getUser(), TimeUtil.nowTs())) {
+        batchUpdateFactory.create(db, project, atrScope.get().getUser(), TimeUtil.nowTs())) {
       batchUpdate.addOp(id, new DraftStatusOfPatchSetsUpdateOp(draftStatus));
       batchUpdate.execute();
     }
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 18925b4..b8c2cf8 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
@@ -252,14 +252,14 @@
     PushOneCommit.Result r = createChange();
     setApiUser(user);
     exception.expect(AuthException.class);
-    exception.expectMessage("Editing hashtags not permitted");
+    exception.expectMessage("edit hashtags not permitted");
     addHashtags(r, "MyHashtag");
   }
 
   @Test
   public void addHashtagWithPermissionAllowed() throws Exception {
     PushOneCommit.Result r = createChange();
-    grant(Permission.EDIT_HASHTAGS, project, "refs/heads/master", false, REGISTERED_USERS);
+    grant(project, "refs/heads/master", Permission.EDIT_HASHTAGS, false, REGISTERED_USERS);
     setApiUser(user);
     addHashtags(r, "MyHashtag");
     assertThatGet(r).containsExactly("MyHashtag");
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
index 36f8452..8388ed0 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
@@ -159,11 +159,11 @@
         new Branch.NameKey(r.getChange().change().getProject(), "blocked_branch");
     createBranch(newBranch);
     block(
+        "refs/for/" + newBranch.get(),
         Permission.PUSH,
-        systemGroupBackend.getGroup(REGISTERED_USERS).getUUID(),
-        "refs/for/" + newBranch.get());
+        systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
     exception.expect(AuthException.class);
-    exception.expectMessage("Move not permitted");
+    exception.expectMessage("move not permitted");
     move(r.getChangeId(), newBranch.get());
   }
 
@@ -174,12 +174,12 @@
     Branch.NameKey newBranch = new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
     block(
+        r.getChange().change().getDest().get(),
         Permission.ABANDON,
-        systemGroupBackend.getGroup(REGISTERED_USERS).getUUID(),
-        r.getChange().change().getDest().get());
+        systemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
     setApiUser(user);
     exception.expect(AuthException.class);
-    exception.expectMessage("Move not permitted");
+    exception.expectMessage("move not permitted");
     move(r.getChangeId(), newBranch.get());
   }
 
@@ -219,11 +219,11 @@
     Util.allow(
         cfg, Permission.forLabel(patchSetLock.getName()), 0, 1, registeredUsers, "refs/heads/*");
     saveProjectConfig(cfg);
-    grant(Permission.LABEL + "Patch-Set-Lock", project, "refs/heads/*");
+    grant(project, "refs/heads/*", Permission.LABEL + "Patch-Set-Lock");
     revision(r).review(new ReviewInput().label("Patch-Set-Lock", 1));
 
     exception.expect(AuthException.class);
-    exception.expectMessage("Move not permitted");
+    exception.expectMessage("move not permitted");
     move(r.getChangeId(), newBranch.get());
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index 26a91aa..5235b14 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -388,6 +389,9 @@
 
   @Test
   public void repairChangeStateAfterFailure() throws Exception {
+    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
+    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
+
     RevCommit initialHead = getRemoteHead();
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     submit(change.getChangeId());
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index 65ad499..a65ba82 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 
 import com.google.common.collect.Iterables;
@@ -145,6 +146,9 @@
 
   @Test
   public void repairChangeStateAfterFailure() throws Exception {
+    // In NoteDb-only mode, repo and meta updates are atomic (at least in InMemoryRepository).
+    assume().that(notesMigration.disableChangeReviewDb()).isFalse();
+
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
     Change.Id id = change.getChange().getId();
     SubmitInput failAfterRefUpdates = new TestSubmitInput(new SubmitInput(), true);
@@ -189,8 +193,8 @@
   public void submitSameCommitsAsInExperimentalBranch() throws Exception {
     RevCommit initialHead = getRemoteHead();
 
-    grant(Permission.CREATE, project, "refs/heads/*");
-    grant(Permission.PUSH, project, "refs/heads/experimental");
+    grant(project, "refs/heads/*", Permission.CREATE);
+    grant(project, "refs/heads/experimental", Permission.PUSH);
 
     RevCommit c1 = commitBuilder().add("b.txt", "1").message("commit at tip").create();
     String id1 = GitUtil.getChangeId(testRepo, c1).get();
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 b4fef08..03bc711 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
@@ -144,7 +144,7 @@
     List<SuggestedReviewerInfo> reviewers;
 
     setApiUser(user3);
-    block("read", ANONYMOUS_USERS, "refs/*");
+    block("refs/*", "read", ANONYMOUS_USERS);
     allow("read", group1.getGroupUUID(), "refs/*");
     reviewers = suggestReviewers(changeId, user2.username, 2);
     assertThat(reviewers).isEmpty();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index f51bbf5..351623a 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -168,7 +168,7 @@
     assertThat(i.change.largeChange).isEqualTo(500);
     assertThat(i.change.replyTooltip).startsWith("Reply and score");
     assertThat(i.change.replyLabel).isEqualTo("Reply\u2026");
-    assertThat(i.change.updateDelay).isEqualTo(30);
+    assertThat(i.change.updateDelay).isEqualTo(300);
 
     // download
     assertThat(i.download.archives).containsExactly("tar", "tbz2", "tgz", "txz");
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/AccessIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/AccessIT.java
index 839f166..8695498 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -218,7 +218,7 @@
 
     setApiUser(user);
     exception.expect(AuthException.class);
-    exception.expectMessage("not administrator");
+    exception.expectMessage("administrate server not permitted");
     gApi.projects().name(newProjectName).access(accessInput);
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
index 2c74949..224e41c 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -80,7 +80,7 @@
   }
 
   private void blockCreateReference() throws Exception {
-    block(Permission.CREATE, ANONYMOUS_USERS, "refs/*");
+    block("refs/*", Permission.CREATE, ANONYMOUS_USERS);
   }
 
   private void grantOwner() throws Exception {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
index bf08ac9..92cf30e 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
@@ -87,11 +87,11 @@
   }
 
   private void blockForcePush() throws Exception {
-    block(Permission.PUSH, ANONYMOUS_USERS, "refs/heads/*").setForce(true);
+    block("refs/heads/*", Permission.PUSH, ANONYMOUS_USERS).setForce(true);
   }
 
   private void grantForcePush() throws Exception {
-    grant(Permission.PUSH, project, "refs/heads/*", true, ANONYMOUS_USERS);
+    grant(project, "refs/heads/*", Permission.PUSH, true, ANONYMOUS_USERS);
   }
 
   private void grantDelete() throws Exception {
@@ -117,7 +117,7 @@
 
   private void assertDeleteForbidden() throws Exception {
     exception.expect(AuthException.class);
-    exception.expectMessage("Cannot delete branch");
+    exception.expectMessage("delete not permitted");
     branch().delete();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
index c9d7446..40fe4ae 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
@@ -83,11 +83,11 @@
   }
 
   private void blockForcePush() throws Exception {
-    block(Permission.PUSH, ANONYMOUS_USERS, "refs/tags/*").setForce(true);
+    block("refs/tags/*", Permission.PUSH, ANONYMOUS_USERS).setForce(true);
   }
 
   private void grantForcePush() throws Exception {
-    grant(Permission.PUSH, project, "refs/tags/*", true, ANONYMOUS_USERS);
+    grant(project, "refs/tags/*", Permission.PUSH, true, ANONYMOUS_USERS);
   }
 
   private void grantDelete() throws Exception {
@@ -112,7 +112,7 @@
 
   private void assertDeleteForbidden() throws Exception {
     exception.expect(AuthException.class);
-    exception.expectMessage("Cannot delete tag");
+    exception.expectMessage("delete not permitted");
     tag().delete();
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/PushTagIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/PushTagIT.java
index 7ed15f4..691ea8a 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/PushTagIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/PushTagIT.java
@@ -220,7 +220,7 @@
     }
 
     if (!newCommit) {
-      grant(Permission.SUBMIT, project, "refs/for/refs/heads/master", false, REGISTERED_USERS);
+      grant(project, "refs/for/refs/heads/master", Permission.SUBMIT, false, REGISTERED_USERS);
       pushHead(testRepo, "refs/for/master%submit");
     }
 
@@ -243,26 +243,26 @@
   }
 
   private void allowTagCreation(TagType tagType) throws Exception {
-    grant(tagType.createPermission, project, "refs/tags/*", false, REGISTERED_USERS);
+    grant(project, "refs/tags/*", tagType.createPermission, false, REGISTERED_USERS);
   }
 
   private void allowPushOnRefsTags() throws Exception {
     removePushFromRefsTags();
-    grant(Permission.PUSH, project, "refs/tags/*", false, REGISTERED_USERS);
+    grant(project, "refs/tags/*", Permission.PUSH, false, REGISTERED_USERS);
   }
 
   private void allowForcePushOnRefsTags() throws Exception {
     removePushFromRefsTags();
-    grant(Permission.PUSH, project, "refs/tags/*", true, REGISTERED_USERS);
+    grant(project, "refs/tags/*", Permission.PUSH, true, REGISTERED_USERS);
   }
 
   private void allowTagDeletion() throws Exception {
     removePushFromRefsTags();
-    grant(Permission.DELETE, project, "refs/tags/*", true, REGISTERED_USERS);
+    grant(project, "refs/tags/*", Permission.DELETE, true, REGISTERED_USERS);
   }
 
   private void removePushFromRefsTags() throws Exception {
-    removePermission(Permission.PUSH, project, "refs/tags/*");
+    removePermission(project, "refs/tags/*", Permission.PUSH);
   }
 
   private void commit(PersonIdent ident, String subject) throws Exception {
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/TagsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/TagsIT.java
index f191681..a34a745 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -239,17 +239,17 @@
 
   @Test
   public void createTagNotAllowed() throws Exception {
-    block(Permission.CREATE, REGISTERED_USERS, R_TAGS + "*");
+    block(R_TAGS + "*", Permission.CREATE, REGISTERED_USERS);
     TagInput input = new TagInput();
     input.ref = "test";
     exception.expect(AuthException.class);
-    exception.expectMessage("Cannot create tag \"" + R_TAGS + "test\"");
+    exception.expectMessage("create not permitted");
     tag(input.ref).create(input);
   }
 
   @Test
   public void createAnnotatedTagNotAllowed() throws Exception {
-    block(Permission.CREATE_TAG, REGISTERED_USERS, R_TAGS + "*");
+    block(R_TAGS + "*", Permission.CREATE_TAG, REGISTERED_USERS);
     TagInput input = new TagInput();
     input.ref = "test";
     input.message = "annotation";
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/revision/BUILD b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/revision/BUILD
new file mode 100644
index 0000000..f47ac46
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/revision/BUILD
@@ -0,0 +1,7 @@
+load("//gerrit-acceptance-tests:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "rest_revision",
+    labels = ["rest"],
+)
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/revision/RevisionIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/revision/RevisionIT.java
new file mode 100644
index 0000000..89fdeff
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/revision/RevisionIT.java
@@ -0,0 +1,78 @@
+// Copyright (C) 2017 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.revision;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.PushOneCommit.FILE_CONTENT;
+import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import org.eclipse.jgit.util.Base64;
+import org.junit.Test;
+
+public class RevisionIT extends AbstractDaemonTest {
+  @Test
+  public void contentOfParent() throws Exception {
+    String parentContent = "parent content";
+    PushOneCommit.Result parent = createChange("Parent change", FILE_NAME, parentContent);
+    parent.assertOkStatus();
+
+    gApi.changes().id(parent.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(parent.getChangeId()).current().submit();
+
+    PushOneCommit.Result child = createChange("Child change", FILE_NAME, FILE_CONTENT);
+    child.assertOkStatus();
+    assertContent(child, FILE_NAME, FILE_CONTENT);
+
+    RestResponse response =
+        adminRestSession.get(
+            "/changes/"
+                + child.getChangeId()
+                + "/revisions/current/files/"
+                + FILE_NAME
+                + "/content?parent=1");
+    response.assertOK();
+    assertThat(new String(Base64.decode(response.getEntityContent()), UTF_8))
+        .isEqualTo(parentContent);
+  }
+
+  @Test
+  public void contentOfInvalidParent() throws Exception {
+    String parentContent = "parent content";
+    PushOneCommit.Result parent = createChange("Parent change", FILE_NAME, parentContent);
+    parent.assertOkStatus();
+
+    gApi.changes().id(parent.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(parent.getChangeId()).current().submit();
+
+    PushOneCommit.Result child = createChange("Child change", FILE_NAME, FILE_CONTENT);
+    child.assertOkStatus();
+    assertContent(child, FILE_NAME, FILE_CONTENT);
+
+    RestResponse response =
+        adminRestSession.get(
+            "/changes/"
+                + child.getChangeId()
+                + "/revisions/current/files/"
+                + FILE_NAME
+                + "/content?parent=10");
+    response.assertBadRequest();
+    assertThat(response.getEntityContent()).isEqualTo("invalid parent");
+  }
+}
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 7e95da6..383858d 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
@@ -20,12 +20,14 @@
 
 import com.google.common.base.Function;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
 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;
@@ -34,27 +36,43 @@
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ChangesCollection;
 import com.google.gerrit.server.change.PostReview;
 import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.notedb.ChangeNoteUtil;
+import com.google.gerrit.server.notedb.DeleteCommentRewriter;
 import com.google.gerrit.testutil.FakeEmailSender;
 import com.google.gerrit.testutil.FakeEmailSender.Message;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Map.Entry;
 import java.util.function.Supplier;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -67,6 +85,8 @@
 
   @Inject private FakeEmailSender email;
 
+  @Inject private ChangeNoteUtil noteUtil;
+
   private final Integer[] lines = {0, 1};
 
   @Before
@@ -380,7 +400,7 @@
       ChangeResource changeRsrc =
           changes.get().parse(TopLevelResource.INSTANCE, IdString.fromDecoded(changeId));
       RevisionResource revRsrc = revisions.parse(changeRsrc, IdString.fromDecoded(revId));
-      postReview.get().apply(revRsrc, input, timestamp);
+      postReview.get().apply(batchUpdateFactory, revRsrc, input, timestamp);
       Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
       assertThat(result).isNotEmpty();
       CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
@@ -739,6 +759,235 @@
     }
   }
 
+  @Test
+  public void deleteCommentCannotBeAppliedByUser() throws Exception {
+    PushOneCommit.Result result = createChange();
+    CommentInput targetComment = addComment(result.getChangeId(), "My password: abc123");
+
+    Map<String, List<CommentInfo>> commentsMap =
+        getPublishedComments(result.getChangeId(), result.getCommit().name());
+
+    assertThat(commentsMap.size()).isEqualTo(1);
+    assertThat(commentsMap.get(FILE_NAME)).hasSize(1);
+
+    String uuid = commentsMap.get(targetComment.path).get(0).id;
+    DeleteCommentInput input = new DeleteCommentInput("contains confidential information");
+
+    setApiUser(user);
+    exception.expect(AuthException.class);
+    gApi.changes().id(result.getChangeId()).current().comment(uuid).delete(input);
+  }
+
+  @Test
+  public void deleteCommentByRewritingCommitHistory() throws Exception {
+    // Creates the following commit history on the meta branch of the test change. Then tries to
+    // delete the comments one by one, which will rewrite most of the commits on the 'meta' branch.
+    // Commits will be rewritten N times for N added comments. After each deletion, the meta branch
+    // should keep its previous state except that the target comment's message should be updated.
+
+    // 1st commit: Create PS1.
+    PushOneCommit.Result result1 = createChange(SUBJECT, "a.txt", "a");
+    Change.Id id = result1.getChange().getId();
+    String changeId = result1.getChangeId();
+    String ps1 = result1.getCommit().name();
+
+    // 2nd commit: Add (c1) to PS1.
+    CommentInput c1 = newComment("a.txt", "comment 1");
+    addComments(changeId, ps1, c1);
+
+    // 3rd commit: Add (c2, c3) to PS1.
+    CommentInput c2 = newComment("a.txt", "comment 2");
+    CommentInput c3 = newComment("a.txt", "comment 3");
+    addComments(changeId, ps1, c2, c3);
+
+    // 4th commit: Add (c4) to PS1.
+    CommentInput c4 = newComment("a.txt", "comment 4");
+    addComments(changeId, ps1, c4);
+
+    // 5th commit: Create PS2.
+    PushOneCommit.Result result2 = amendChange(changeId, "refs/for/master", "b.txt", "b");
+    String ps2 = result2.getCommit().name();
+
+    // 6th commit: Add (c5) to PS1.
+    CommentInput c5 = newComment("a.txt", "comment 5");
+    addComments(changeId, ps1, c5);
+
+    // 7th commit: Add (c6) to PS2.
+    CommentInput c6 = newComment("b.txt", "comment 6");
+    addComments(changeId, ps2, c6);
+
+    // 8th commit: Create PS3.
+    PushOneCommit.Result result3 = amendChange(changeId);
+    String ps3 = result3.getCommit().name();
+
+    // 9th commit: Create PS4.
+    PushOneCommit.Result result4 = amendChange(changeId, "refs/for/master", "c.txt", "c");
+    String ps4 = result4.getCommit().name();
+
+    // 10th commit: Add (c7, c8) to PS4.
+    CommentInput c7 = newComment("c.txt", "comment 7");
+    CommentInput c8 = newComment("b.txt", "comment 8");
+    addComments(changeId, ps4, c7, c8);
+
+    // 11th commit: Add (c9) to PS2.
+    CommentInput c9 = newComment("b.txt", "comment 9");
+    addComments(changeId, ps2, c9);
+
+    List<CommentInfo> commentsBeforeDelete = getChangeSortedComments(changeId);
+    assertThat(commentsBeforeDelete).hasSize(9);
+    // PS1 has comments [c1, c2, c3, c4, c5].
+    assertThat(getRevisionComments(changeId, ps1)).hasSize(5);
+    // PS2 has comments [c6, c9].
+    assertThat(getRevisionComments(changeId, ps2)).hasSize(2);
+    // PS3 has no comment.
+    assertThat(getRevisionComments(changeId, ps3)).hasSize(0);
+    // PS4 has comments [c7, c8].
+    assertThat(getRevisionComments(changeId, ps4)).hasSize(2);
+
+    setApiUser(admin);
+    for (int i = 0; i < commentsBeforeDelete.size(); i++) {
+      List<RevCommit> commitsBeforeDelete = new ArrayList<>();
+      if (notesMigration.commitChangeWrites()) {
+        commitsBeforeDelete = getCommits(id);
+      }
+
+      CommentInfo comment = commentsBeforeDelete.get(i);
+      String uuid = comment.id;
+      int patchSet = comment.patchSet;
+      // 'oldComment' has some fields unset compared with 'comment'.
+      CommentInfo oldComment = gApi.changes().id(changeId).revision(patchSet).comment(uuid).get();
+
+      DeleteCommentInput input = new DeleteCommentInput("delete comment " + uuid);
+      CommentInfo updatedComment =
+          gApi.changes().id(changeId).revision(patchSet).comment(uuid).delete(input);
+
+      String expectedMsg =
+          String.format("Comment removed by: %s; Reason: %s", admin.fullName, input.reason);
+      assertThat(updatedComment.message).isEqualTo(expectedMsg);
+      oldComment.message = expectedMsg;
+      assertThat(updatedComment).isEqualTo(oldComment);
+
+      // Check the NoteDb state after the deletion.
+      if (notesMigration.commitChangeWrites()) {
+        assertMetaBranchCommitsAfterRewriting(commitsBeforeDelete, id, uuid, expectedMsg);
+      }
+
+      comment.message = expectedMsg;
+      commentsBeforeDelete.set(i, comment);
+      List<CommentInfo> commentsAfterDelete = getChangeSortedComments(changeId);
+      assertThat(commentsAfterDelete).isEqualTo(commentsBeforeDelete);
+    }
+
+    // Make sure that comments can still be added correctly.
+    CommentInput c10 = newComment("a.txt", "comment 10");
+    CommentInput c11 = newComment("b.txt", "comment 11");
+    CommentInput c12 = newComment("a.txt", "comment 12");
+    CommentInput c13 = newComment("c.txt", "comment 13");
+    addComments(changeId, ps1, c10);
+    addComments(changeId, ps2, c11);
+    addComments(changeId, ps3, c12);
+    addComments(changeId, ps4, c13);
+
+    assertThat(getChangeSortedComments(changeId)).hasSize(13);
+    assertThat(getRevisionComments(changeId, ps1)).hasSize(6);
+    assertThat(getRevisionComments(changeId, ps2)).hasSize(3);
+    assertThat(getRevisionComments(changeId, ps3)).hasSize(1);
+    assertThat(getRevisionComments(changeId, ps4)).hasSize(3);
+  }
+
+  private List<CommentInfo> getChangeSortedComments(String changeId) throws Exception {
+    List<CommentInfo> comments = new ArrayList<>();
+    Map<String, List<CommentInfo>> commentsMap = getPublishedComments(changeId);
+    for (Entry<String, List<CommentInfo>> e : commentsMap.entrySet()) {
+      for (CommentInfo c : e.getValue()) {
+        c.path = e.getKey(); // Set the comment's path field.
+        comments.add(c);
+      }
+    }
+    comments.sort(Comparator.comparing(c -> c.id));
+    return comments;
+  }
+
+  private List<CommentInfo> getRevisionComments(String changeId, String revId) throws Exception {
+    return getPublishedComments(changeId, revId)
+        .values()
+        .stream()
+        .flatMap(List::stream)
+        .collect(Collectors.toList());
+  }
+
+  private CommentInput addComment(String changeId, String message) throws Exception {
+    ReviewInput input = new ReviewInput();
+    CommentInput comment = newComment(FILE_NAME, Side.REVISION, 0, message, false);
+    input.comments = ImmutableMap.of(comment.path, Lists.newArrayList(comment));
+    gApi.changes().id(changeId).current().review(input);
+    return comment;
+  }
+
+  private void addComments(String changeId, String revision, CommentInput... commentInputs)
+      throws Exception {
+    ReviewInput input = new ReviewInput();
+    input.comments = Arrays.stream(commentInputs).collect(Collectors.groupingBy(c -> c.path));
+    gApi.changes().id(changeId).revision(revision).review(input);
+  }
+
+  private List<RevCommit> getCommits(Change.Id changeId) throws IOException {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      Ref metaRef = repo.exactRef(RefNames.changeMetaRef(changeId));
+      revWalk.markStart(revWalk.parseCommit(metaRef.getObjectId()));
+      return Lists.newArrayList(revWalk);
+    }
+  }
+
+  /**
+   * All the commits, which contain the target comment before, should still contain the comment with
+   * the updated message. All the other metas of the commits should be exactly the same.
+   */
+  private void assertMetaBranchCommitsAfterRewriting(
+      List<RevCommit> beforeDelete,
+      Change.Id changeId,
+      String targetCommentUuid,
+      String expectedMessage)
+      throws Exception {
+    List<RevCommit> afterDelete = getCommits(changeId);
+    assertThat(afterDelete).hasSize(beforeDelete.size());
+
+    try (Repository repo = repoManager.openRepository(project);
+        ObjectReader reader = repo.newObjectReader()) {
+      for (int i = 0; i < beforeDelete.size(); i++) {
+        RevCommit commitBefore = beforeDelete.get(i);
+        RevCommit commitAfter = afterDelete.get(i);
+
+        Map<String, com.google.gerrit.reviewdb.client.Comment> commentMapBefore =
+            DeleteCommentRewriter.getPublishedComments(
+                noteUtil, changeId, reader, NoteMap.read(reader, commitBefore));
+        Map<String, com.google.gerrit.reviewdb.client.Comment> commentMapAfter =
+            DeleteCommentRewriter.getPublishedComments(
+                noteUtil, changeId, reader, NoteMap.read(reader, commitAfter));
+
+        if (commentMapBefore.containsKey(targetCommentUuid)) {
+          assertThat(commentMapAfter).containsKey(targetCommentUuid);
+          com.google.gerrit.reviewdb.client.Comment comment =
+              commentMapAfter.get(targetCommentUuid);
+          assertThat(comment.message).isEqualTo(expectedMessage);
+          comment.message = commentMapBefore.get(targetCommentUuid).message;
+          commentMapAfter.put(targetCommentUuid, comment);
+          assertThat(commentMapAfter).isEqualTo(commentMapBefore);
+        } else {
+          assertThat(commentMapAfter).doesNotContainKey(targetCommentUuid);
+        }
+
+        // Other metas should be exactly the same.
+        assertThat(commitAfter.getFullMessage()).isEqualTo(commitBefore.getFullMessage());
+        assertThat(commitAfter.getCommitterIdent()).isEqualTo(commitBefore.getCommitterIdent());
+        assertThat(commitAfter.getAuthorIdent()).isEqualTo(commitBefore.getAuthorIdent());
+        assertThat(commitAfter.getEncoding()).isEqualTo(commitBefore.getEncoding());
+        assertThat(commitAfter.getEncodingName()).isEqualTo(commitBefore.getEncodingName());
+      }
+    }
+  }
+
   private static String extractComments(String msg) {
     // Extract lines between start "....." and end "-- ".
     Pattern p = Pattern.compile(".*[.]{5}\n+(.*)\\n+-- \n.*", Pattern.DOTALL);
@@ -803,10 +1052,18 @@
     return gApi.changes().id(changeId).revision(revId).drafts();
   }
 
+  private Map<String, List<CommentInfo>> getPublishedComments(String changeId) throws Exception {
+    return gApi.changes().id(changeId).comments();
+  }
+
   private CommentInfo getDraftComment(String changeId, String revId, String uuid) throws Exception {
     return gApi.changes().id(changeId).revision(revId).draft(uuid).get();
   }
 
+  private static CommentInput newComment(String file, String message) {
+    return newComment(file, Side.REVISION, 0, message, false);
+  }
+
   private static CommentInput newComment(
       String path, Side side, int line, String message, Boolean unresolved) {
     CommentInput c = new CommentInput();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index e0346b3..8f256be 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -41,11 +41,11 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ConsistencyChecker;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.config.AnonymousCowardName;
-import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.project.ChangeControl;
@@ -68,7 +68,6 @@
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.transport.ReceiveCommand;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -80,8 +79,6 @@
 
   @Inject private IdentifiedUser.GenericFactory userFactory;
 
-  @Inject private BatchUpdate.Factory updateFactory;
-
   @Inject private ChangeInserter.Factory changeInserterFactory;
 
   @Inject private PatchSetInserter.Factory patchSetInserterFactory;
@@ -92,6 +89,8 @@
 
   @Inject private Sequences sequences;
 
+  @Inject private AccountsUpdate.Server accountsUpdate;
+
   private RevCommit tip;
   private Account.Id adminId;
   private ConsistencyChecker checker;
@@ -121,7 +120,7 @@
   public void missingOwner() throws Exception {
     TestAccount owner = accounts.create("missing");
     ChangeControl ctl = insertChange(owner);
-    db.accounts().deleteKeys(singleton(owner.getId()));
+    accountsUpdate.create().deleteByKey(db, owner.getId());
 
     assertProblems(ctl, null, problem("Missing change owner: " + owner.getId()));
   }
@@ -783,7 +782,7 @@
   }
 
   private BatchUpdate newUpdate(Account.Id owner) {
-    return updateFactory.create(db, project, userFactory.create(owner), TimeUtil.nowTs());
+    return batchUpdateFactory.create(db, project, userFactory.create(owner), TimeUtil.nowTs());
   }
 
   private ChangeControl insertChange() throws Exception {
@@ -802,7 +801,7 @@
       ins =
           changeInserterFactory
               .create(id, commit, dest)
-              .setValidatePolicy(CommitValidators.Policy.NONE)
+              .setValidate(false)
               .setNotify(NotifyHandling.NONE)
               .setFireRevisionCreated(false)
               .setSendMail(false);
@@ -826,7 +825,7 @@
       ins =
           patchSetInserterFactory
               .create(ctl, nextPatchSetId(ctl), commit)
-              .setValidatePolicy(CommitValidators.Policy.NONE)
+              .setValidate(false)
               .setFireRevisionCreated(false)
               .setNotify(NotifyHandling.NONE);
       bu.addOp(ctl.getId(), ins).execute();
@@ -920,7 +919,7 @@
           new BatchUpdateOp() {
             @Override
             public void updateRepo(RepoContext ctx) throws IOException {
-              ctx.addRefUpdate(new ReceiveCommand(oldId, newId, dest));
+              ctx.addRefUpdate(oldId, newId, dest);
             }
 
             @Override
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index 8d9885c..e957c88 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -64,8 +64,6 @@
     System.setProperty("user.timezone", systemTimeZone);
   }
 
-  @Inject private BatchUpdate.Factory updateFactory;
-
   @Inject private ChangesCollection changes;
 
   @Test
@@ -517,7 +515,7 @@
   }
 
   @Test
-  @GerritConfig(name = "index.testReindexAfterUpdate", value = "false")
+  @GerritConfig(name = "index.testAutoReindexIfStale", value = "false")
   public void getRelatedForStaleChange() throws Exception {
     RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
 
@@ -578,7 +576,7 @@
   }
 
   private void clearGroups(final PatchSet.Id psId) throws Exception {
-    try (BatchUpdate bu = updateFactory.create(db, project, user(user), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(db, project, user(user), TimeUtil.nowTs())) {
       bu.addOp(
           psId.getParentKey(),
           new BatchUpdateOp() {
@@ -586,7 +584,7 @@
             public boolean updateChange(ChangeContext ctx) throws OrmException {
               PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
               psUtil.setGroups(ctx.getDb(), ctx.getUpdate(psId), ps, ImmutableList.<String>of());
-              ctx.bumpLastUpdatedOn(false);
+              ctx.dontBumpLastUpdatedOn();
               return true;
             }
           });
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/EmailValidatorIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/EmailValidatorIT.java
new file mode 100644
index 0000000..cb039ad
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/mail/EmailValidatorIT.java
@@ -0,0 +1,100 @@
+// 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.server.mail;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
+import com.google.inject.Inject;
+import java.io.BufferedReader;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.lang.reflect.Field;
+import org.junit.After;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class EmailValidatorIT extends AbstractDaemonTest {
+  private static final String UNSUPPORTED_PREFIX = "#! ";
+
+  @Inject private OutgoingEmailValidator validator;
+
+  @BeforeClass
+  public static void setUpClass() throws Exception {
+    // Reset before first use, in case other tests have already run in this JVM.
+    resetDomainValidator();
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    resetDomainValidator();
+  }
+
+  private static void resetDomainValidator() throws Exception {
+    Class<?> c = Class.forName("org.apache.commons.validator.routines.DomainValidator");
+    Field f = c.getDeclaredField("inUse");
+    f.setAccessible(true);
+    f.setBoolean(c, false);
+  }
+
+  @Test
+  @GerritConfig(name = "sendemail.allowTLD", value = "example")
+  public void testCustomTopLevelDomain() throws Exception {
+    assertThat(validator.isValid("foo@bar.local")).isFalse();
+    assertThat(validator.isValid("foo@bar.example")).isTrue();
+    assertThat(validator.isValid("foo@example")).isTrue();
+  }
+
+  @Test
+  @GerritConfig(name = "sendemail.allowTLD", value = "a")
+  public void testCustomTopLevelDomainOneCharacter() throws Exception {
+    assertThat(validator.isValid("foo@bar.local")).isFalse();
+    assertThat(validator.isValid("foo@bar.a")).isTrue();
+    assertThat(validator.isValid("foo@a")).isTrue();
+  }
+
+  @Test
+  public void validateTopLevelDomains() throws Exception {
+    try (InputStream in = this.getClass().getResourceAsStream("tlds-alpha-by-domain.txt")) {
+      if (in == null) {
+        throw new Exception("TLD list not found");
+      }
+      BufferedReader r = new BufferedReader(new InputStreamReader(in));
+      String tld;
+      while ((tld = r.readLine()) != null) {
+        if (tld.startsWith("# ") || tld.startsWith("XN--")) {
+          // Ignore comments and non-latin domains
+          continue;
+        }
+        if (tld.startsWith(UNSUPPORTED_PREFIX)) {
+          String test = "test@example." + tld.toLowerCase().substring(UNSUPPORTED_PREFIX.length());
+          assert_()
+              .withFailureMessage("expected invalid TLD \"" + test + "\"")
+              .that(validator.isValid(test))
+              .isFalse();
+        } else {
+          String test = "test@example." + tld.toLowerCase();
+          assert_()
+              .withFailureMessage("failed to validate TLD \"" + test + "\"")
+              .that(validator.isValid(test))
+              .isTrue();
+        }
+      }
+    }
+  }
+}
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 9d15daf..aaf01f36 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
@@ -120,7 +120,7 @@
     // unintentional auto-rebuilding of the change in NoteDb during the read
     // path of the reindex-if-stale check. For the purposes of this test, we
     // want precise control over when auto-rebuilding happens.
-    cfg.setBoolean("index", null, "testReindexAfterUpdate", false);
+    cfg.setBoolean("index", null, "testAutoReindexIfStale", false);
 
     return cfg;
   }
@@ -139,8 +139,6 @@
 
   @Inject private TestChangeRebuilderWrapper rebuilderWrapper;
 
-  @Inject private BatchUpdate.Factory batchUpdateFactory;
-
   @Inject private Sequences seq;
 
   @Inject private ChangeBundleReader bundleReader;
@@ -754,7 +752,7 @@
     assertThat(ts).isGreaterThan(c.getCreatedOn());
     assertThat(ts).isLessThan(db.patchSets().get(psId).getCreatedOn());
     RevisionResource revRsrc = parseCurrentRevisionResource(r.getChangeId());
-    postReview.get().apply(revRsrc, rin, ts);
+    postReview.get().apply(batchUpdateFactory, revRsrc, rin, ts);
 
     checker.rebuildAndCheckChanges(id);
   }
@@ -772,7 +770,7 @@
     Timestamp ts = new Timestamp(c.getCreatedOn().getTime() - 10000);
     RevisionResource revRsrc = parseCurrentRevisionResource(r.getChangeId());
     setApiUser(user);
-    postReview.get().apply(revRsrc, rin, ts);
+    postReview.get().apply(batchUpdateFactory, revRsrc, rin, ts);
 
     checker.rebuildAndCheckChanges(id);
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
new file mode 100644
index 0000000..9859086
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
@@ -0,0 +1,347 @@
+// Copyright (C) 2017 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.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateListener;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.testutil.ConfigSuite;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+public class NoteDbOnlyIT extends AbstractDaemonTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    Config cfg = new Config();
+    // Avoid spurious timeouts during intentional retries due to overloaded test machines.
+    cfg.setString("noteDb", null, "retryTimeout", Integer.MAX_VALUE + "s");
+    return cfg;
+  }
+
+  @Inject private RetryHelper retryHelper;
+
+  @Before
+  public void setUp() throws Exception {
+    assume().that(notesMigration.disableChangeReviewDb()).isTrue();
+  }
+
+  @Test
+  public void updateChangeFailureRollsBackRefUpdate() throws Exception {
+    assume().that(notesMigration.fuseUpdates()).isTrue();
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+
+    String master = "refs/heads/master";
+    String backup = "refs/backup/master";
+    ObjectId master1 = getRef(master).get();
+    assertThat(getRef(backup)).isEmpty();
+
+    // Toy op that copies the value of refs/heads/master to refs/backup/master.
+    BatchUpdateOp backupMasterOp =
+        new BatchUpdateOp() {
+          ObjectId newId;
+
+          @Override
+          public void updateRepo(RepoContext ctx) throws IOException {
+            ObjectId oldId = ctx.getRepoView().getRef(backup).orElse(ObjectId.zeroId());
+            newId = ctx.getRepoView().getRef(master).get();
+            ctx.addRefUpdate(oldId, newId, backup);
+          }
+
+          @Override
+          public boolean updateChange(ChangeContext ctx) {
+            ctx.getUpdate(ctx.getChange().currentPatchSetId())
+                .setChangeMessage("Backed up master branch to " + newId.name());
+            return true;
+          }
+        };
+
+    try (BatchUpdate bu = newBatchUpdate(batchUpdateFactory)) {
+      bu.addOp(id, backupMasterOp);
+      bu.execute();
+    }
+
+    // Ensure backupMasterOp worked.
+    assertThat(getRef(backup)).hasValue(master1);
+    assertThat(getMessages(id)).contains("Backed up master branch to " + master1.name());
+
+    // Advance master by submitting the change.
+    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(id.get()).current().submit();
+    ObjectId master2 = getRef(master).get();
+    assertThat(master2).isNotEqualTo(master1);
+    int msgCount = getMessages(id).size();
+
+    try (BatchUpdate bu = newBatchUpdate(batchUpdateFactory)) {
+      // This time, we attempt to back up master, but we fail during updateChange.
+      bu.addOp(id, backupMasterOp);
+      String msg = "Change is bad";
+      bu.addOp(
+          id,
+          new BatchUpdateOp() {
+            @Override
+            public boolean updateChange(ChangeContext ctx) throws ResourceConflictException {
+              throw new ResourceConflictException(msg);
+            }
+          });
+      try {
+        bu.execute();
+        assert_().fail("expected ResourceConflictException");
+      } catch (ResourceConflictException e) {
+        assertThat(e).hasMessageThat().isEqualTo(msg);
+      }
+    }
+
+    // If updateChange hadn't failed, backup would have been updated to master2.
+    assertThat(getRef(backup)).hasValue(master1);
+    assertThat(getMessages(id)).hasSize(msgCount);
+  }
+
+  @Test
+  public void retryOnLockFailureWithAtomicUpdates() throws Exception {
+    assume().that(notesMigration.fuseUpdates()).isTrue();
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+    String master = "refs/heads/master";
+    ObjectId initial;
+    try (Repository repo = repoManager.openRepository(project)) {
+      ((InMemoryRepository) repo).setPerformsAtomicTransactions(true);
+      initial = repo.exactRef(master).getObjectId();
+    }
+
+    AtomicInteger updateRepoCalledCount = new AtomicInteger();
+    AtomicInteger updateChangeCalledCount = new AtomicInteger();
+    AtomicInteger afterUpdateReposCalledCount = new AtomicInteger();
+
+    String result =
+        retryHelper.execute(
+            batchUpdateFactory -> {
+              try (BatchUpdate bu = newBatchUpdate(batchUpdateFactory)) {
+                bu.addOp(
+                    id,
+                    new UpdateRefAndAddMessageOp(updateRepoCalledCount, updateChangeCalledCount));
+                bu.execute(new ConcurrentWritingListener(afterUpdateReposCalledCount));
+              }
+              return "Done";
+            });
+
+    assertThat(result).isEqualTo("Done");
+    assertThat(updateRepoCalledCount.get()).isEqualTo(2);
+    assertThat(afterUpdateReposCalledCount.get()).isEqualTo(2);
+    assertThat(updateChangeCalledCount.get()).isEqualTo(2);
+
+    List<String> messages = getMessages(id);
+    assertThat(Iterables.getLast(messages)).isEqualTo(UpdateRefAndAddMessageOp.CHANGE_MESSAGE);
+    assertThat(Collections.frequency(messages, UpdateRefAndAddMessageOp.CHANGE_MESSAGE))
+        .isEqualTo(1);
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      // Op lost the race, so the other writer's commit happened first. Then op retried and wrote
+      // its commit with the other writer's commit as parent.
+      assertThat(commitMessages(repo, initial, repo.exactRef(master).getObjectId()))
+          .containsExactly(
+              ConcurrentWritingListener.MSG_PREFIX + "1", UpdateRefAndAddMessageOp.COMMIT_MESSAGE)
+          .inOrder();
+    }
+  }
+
+  @Test
+  public void noRetryOnLockFailureWithoutAtomicUpdates() throws Exception {
+    assume().that(notesMigration.fuseUpdates()).isFalse();
+
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+    String master = "refs/heads/master";
+    ObjectId initial;
+    try (Repository repo = repoManager.openRepository(project)) {
+      initial = repo.exactRef(master).getObjectId();
+    }
+
+    AtomicInteger updateRepoCalledCount = new AtomicInteger();
+    AtomicInteger updateChangeCalledCount = new AtomicInteger();
+    AtomicInteger afterUpdateReposCalledCount = new AtomicInteger();
+
+    try {
+      retryHelper.execute(
+          batchUpdateFactory -> {
+            try (BatchUpdate bu = newBatchUpdate(batchUpdateFactory)) {
+              bu.addOp(
+                  id, new UpdateRefAndAddMessageOp(updateRepoCalledCount, updateChangeCalledCount));
+              bu.execute(new ConcurrentWritingListener(afterUpdateReposCalledCount));
+            }
+            return null;
+          });
+      assert_().fail("expected RestApiException");
+    } catch (RestApiException e) {
+      // Expected.
+    }
+
+    assertThat(updateRepoCalledCount.get()).isEqualTo(1);
+    assertThat(afterUpdateReposCalledCount.get()).isEqualTo(1);
+    assertThat(updateChangeCalledCount.get()).isEqualTo(0);
+
+    // updateChange was never called, so no message was ever added.
+    assertThat(getMessages(id)).doesNotContain(UpdateRefAndAddMessageOp.CHANGE_MESSAGE);
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      // Op lost the race, so the other writer's commit happened first. Op didn't retry, because the
+      // ref updates weren't atomic, so it didn't throw LockFailureException on failure.
+      assertThat(commitMessages(repo, initial, repo.exactRef(master).getObjectId()))
+          .containsExactly(ConcurrentWritingListener.MSG_PREFIX + "1");
+    }
+  }
+
+  private class ConcurrentWritingListener implements BatchUpdateListener {
+    static final String MSG_PREFIX = "Other writer ";
+
+    private final AtomicInteger calledCount;
+
+    private ConcurrentWritingListener(AtomicInteger calledCount) {
+      this.calledCount = calledCount;
+    }
+
+    @Override
+    public void afterUpdateRepos() throws Exception {
+      // Reopen repo and update ref, to simulate a concurrent write in another
+      // thread. Only do this the first time the listener is called.
+      if (calledCount.getAndIncrement() > 0) {
+        return;
+      }
+      try (Repository repo = repoManager.openRepository(project);
+          RevWalk rw = new RevWalk(repo);
+          ObjectInserter ins = repo.newObjectInserter()) {
+        String master = "refs/heads/master";
+        ObjectId oldId = repo.exactRef(master).getObjectId();
+        ObjectId newId = newCommit(rw, ins, oldId, MSG_PREFIX + calledCount.get());
+        ins.flush();
+        RefUpdate ru = repo.updateRef(master);
+        ru.setExpectedOldObjectId(oldId);
+        ru.setNewObjectId(newId);
+        assertThat(ru.update(rw)).isEqualTo(RefUpdate.Result.FAST_FORWARD);
+      }
+    }
+  }
+
+  private class UpdateRefAndAddMessageOp implements BatchUpdateOp {
+    static final String COMMIT_MESSAGE = "A commit";
+    static final String CHANGE_MESSAGE = "A change message";
+
+    private final AtomicInteger updateRepoCalledCount;
+    private final AtomicInteger updateChangeCalledCount;
+
+    private UpdateRefAndAddMessageOp(
+        AtomicInteger updateRepoCalledCount, AtomicInteger updateChangeCalledCount) {
+      this.updateRepoCalledCount = updateRepoCalledCount;
+      this.updateChangeCalledCount = updateChangeCalledCount;
+    }
+
+    @Override
+    public void updateRepo(RepoContext ctx) throws Exception {
+      String master = "refs/heads/master";
+      ObjectId oldId = ctx.getRepoView().getRef(master).get();
+      ObjectId newId = newCommit(ctx.getRevWalk(), ctx.getInserter(), oldId, COMMIT_MESSAGE);
+      ctx.addRefUpdate(oldId, newId, master);
+      updateRepoCalledCount.incrementAndGet();
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx) throws Exception {
+      ctx.getUpdate(ctx.getChange().currentPatchSetId()).setChangeMessage(CHANGE_MESSAGE);
+      updateChangeCalledCount.incrementAndGet();
+      return true;
+    }
+  }
+
+  private ObjectId newCommit(RevWalk rw, ObjectInserter ins, ObjectId parent, String msg)
+      throws IOException {
+    PersonIdent ident = serverIdent.get();
+    CommitBuilder cb = new CommitBuilder();
+    cb.setParentId(parent);
+    cb.setTreeId(rw.parseCommit(parent).getTree());
+    cb.setMessage(msg);
+    cb.setAuthor(ident);
+    cb.setCommitter(ident);
+    return ins.insert(Constants.OBJ_COMMIT, cb.build());
+  }
+
+  private BatchUpdate newBatchUpdate(BatchUpdate.Factory buf) {
+    return buf.create(db, project, identifiedUserFactory.create(user.getId()), TimeUtil.nowTs());
+  }
+
+  private Optional<ObjectId> getRef(String name) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      return Optional.ofNullable(repo.exactRef(name)).map(Ref::getObjectId);
+    }
+  }
+
+  private List<String> getMessages(Change.Id id) throws Exception {
+    return gApi.changes()
+        .id(id.get())
+        .get(EnumSet.of(ListChangesOption.MESSAGES))
+        .messages
+        .stream()
+        .map(m -> m.message)
+        .collect(toList());
+  }
+
+  private static List<String> commitMessages(
+      Repository repo, ObjectId fromExclusive, ObjectId toInclusive) throws Exception {
+    try (RevWalk rw = new RevWalk(repo)) {
+      rw.markStart(rw.parseCommit(toInclusive));
+      rw.markUninteresting(rw.parseCommit(fromExclusive));
+      rw.sort(RevSort.REVERSE);
+      rw.setRetainBody(true);
+      return Streams.stream(rw).map(c -> c.getShortMessage()).collect(toList());
+    }
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java
index 1d2f0e0..3ed172f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java
@@ -63,7 +63,7 @@
 import com.google.gerrit.server.notedb.PrimaryStorageMigrator;
 import com.google.gerrit.server.notedb.TestChangeRebuilderWrapper;
 import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.NoteDbMode;
 import com.google.gerrit.testutil.TestTimeUtil;
@@ -97,13 +97,13 @@
   }
 
   @Inject private AllUsersName allUsers;
-  @Inject private BatchUpdate.Factory batchUpdateFactory;
   @Inject private ChangeBundleReader bundleReader;
   @Inject private CommentsUtil commentsUtil;
   @Inject private TestChangeRebuilderWrapper rebuilderWrapper;
   @Inject private ChangeControl.GenericFactory changeControlFactory;
   @Inject private ChangeUpdate.Factory updateFactory;
   @Inject private InternalUser.Factory internalUserFactory;
+  @Inject private RetryHelper retryHelper;
 
   private PrimaryStorageMigrator migrator;
 
@@ -128,7 +128,7 @@
         queryProvider,
         updateFactory,
         internalUserFactory,
-        batchUpdateFactory);
+        retryHelper);
   }
 
   @After
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index 174fb76..0671840 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.StarsInput;
@@ -39,7 +38,6 @@
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.testutil.FakeEmailSender.Message;
 import com.google.inject.Inject;
-import java.util.Collections;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.List;
@@ -162,6 +160,127 @@
   }
 
   @Test
+  public void noNotificationForPrivateChangesForWatchersInNotifyConfig() throws Exception {
+    Address addr = new Address("Watcher", "watcher@example.com");
+    NotifyConfig nc = new NotifyConfig();
+    nc.addEmail(addr);
+    nc.setName("team");
+    nc.setHeader(NotifyConfig.Header.TO);
+    nc.setTypes(EnumSet.of(NotifyType.NEW_CHANGES, NotifyType.ALL_COMMENTS));
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.putNotifyConfig("team", nc);
+    saveProjectConfig(project, cfg);
+
+    sender.clear();
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "private change", "a", "a1")
+            .to("refs/for/master%private");
+    r.assertOkStatus();
+
+    assertThat(sender.getMessages()).isEmpty();
+
+    setApiUser(admin);
+    ReviewInput in = new ReviewInput();
+    in.message = "comment";
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void noNotificationForChangeThatIsTurnedPrivateForWatchersInNotifyConfig()
+      throws Exception {
+    Address addr = new Address("Watcher", "watcher@example.com");
+    NotifyConfig nc = new NotifyConfig();
+    nc.addEmail(addr);
+    nc.setName("team");
+    nc.setHeader(NotifyConfig.Header.TO);
+    nc.setTypes(EnumSet.of(NotifyType.NEW_PATCHSETS));
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.putNotifyConfig("team", nc);
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "subject", "a", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    sender.clear();
+
+    r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "subject", "a", "a2", r.getChangeId())
+            .to("refs/for/master%private");
+    r.assertOkStatus();
+
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void noNotificationForWipChangesForWatchersInNotifyConfig() throws Exception {
+    Address addr = new Address("Watcher", "watcher@example.com");
+    NotifyConfig nc = new NotifyConfig();
+    nc.addEmail(addr);
+    nc.setName("team");
+    nc.setHeader(NotifyConfig.Header.TO);
+    nc.setTypes(EnumSet.of(NotifyType.NEW_CHANGES, NotifyType.ALL_COMMENTS));
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.putNotifyConfig("team", nc);
+    saveProjectConfig(project, cfg);
+
+    sender.clear();
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "wip change", "a", "a1")
+            .to("refs/for/master%wip");
+    r.assertOkStatus();
+
+    assertThat(sender.getMessages()).isEmpty();
+
+    setApiUser(admin);
+    ReviewInput in = new ReviewInput();
+    in.message = "comment";
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void noNotificationForChangeThatIsTurnedWipForWatchersInNotifyConfig() throws Exception {
+    Address addr = new Address("Watcher", "watcher@example.com");
+    NotifyConfig nc = new NotifyConfig();
+    nc.addEmail(addr);
+    nc.setName("team");
+    nc.setHeader(NotifyConfig.Header.TO);
+    nc.setTypes(EnumSet.of(NotifyType.NEW_PATCHSETS));
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.putNotifyConfig("team", nc);
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "subject", "a", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    sender.clear();
+
+    r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "subject", "a", "a2", r.getChangeId())
+            .to("refs/for/master%wip");
+    r.assertOkStatus();
+
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
   public void watchProject() throws Exception {
     // watch project
     String watchedProject = createProject("watchedProject").get();
@@ -437,9 +556,9 @@
     // create group that can view all drafts
     GroupInfo groupThatCanViewDrafts = gApi.groups().create("groupThatCanViewDrafts").get();
     grant(
-        Permission.VIEW_DRAFTS,
         new Project.NameKey(watchedProject),
         "refs/*",
+        Permission.VIEW_DRAFTS,
         false,
         new AccountGroup.UUID(groupThatCanViewDrafts.id));
 
@@ -520,9 +639,7 @@
   @Test
   public void deleteAllProjectWatchesIfWatchConfigIsTheOnlyFileInUserBranch() throws Exception {
     // Create account that has no files in its refs/users/ branch.
-    Account.Id id = new Account.Id(db.nextAccountId());
-    Account a = new Account(id, TimeUtil.nowTs());
-    db.accounts().insert(Collections.singleton(a));
+    Account.Id id = accounts.create().id;
 
     // Add a project watch so that a watch.config file in the refs/users/ branch is created.
     Map<ProjectWatchKey, Set<NotifyType>> watches = new HashMap<>();
@@ -535,4 +652,69 @@
     watchConfig.deleteAllProjectWatches(id);
     assertThat(watchConfig.getProjectWatches(id)).isEmpty();
   }
+
+  @Test
+  public void watchProjectNoNotificationForPrivateChange() throws Exception {
+    // watch project
+    String watchedProject = createProject("watchedProject").get();
+    setApiUser(user);
+    watch(watchedProject, null);
+
+    // push a private change to watched project -> should not trigger email notification
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(new Project.NameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), watchedRepo, "private change", "a", "a1")
+            .to("refs/for/master%private");
+    r.assertOkStatus();
+
+    // assert email notification
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void watchProjectNotifyOnPrivateChange() throws Exception {
+    String watchedProject = createProject("watchedProject").get();
+
+    // create group that can view all private changes
+    GroupInfo groupThatCanViewPrivateChanges =
+        gApi.groups().create("groupThatCanViewPrivateChanges").get();
+    grant(
+        new Project.NameKey(watchedProject),
+        "refs/*",
+        Permission.VIEW_PRIVATE_CHANGES,
+        false,
+        new AccountGroup.UUID(groupThatCanViewPrivateChanges.id));
+
+    // watch project as user that can't view private changes
+    setApiUser(user);
+    watch(watchedProject, null);
+
+    // watch project as user that can view all private change
+    TestAccount userThatCanViewPrivateChanges =
+        accounts.create("user2", "user2@test.com", "User2", groupThatCanViewPrivateChanges.name);
+    setApiUser(userThatCanViewPrivateChanges);
+    watch(watchedProject, null);
+
+    // push a private change to watched project -> should trigger email notification for
+    // userThatCanViewPrivateChanges, but not for user
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(new Project.NameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), watchedRepo, "TRIGGER", "a", "a1")
+            .to("refs/for/master%private");
+    r.assertOkStatus();
+
+    // assert email notification
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(userThatCanViewPrivateChanges.emailAddress);
+    assertThat(m.body()).contains("Change subject: TRIGGER\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java
index b1efb4a..19fcff9 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java
@@ -83,10 +83,7 @@
     assertThat(userSshSession.hasError()).isTrue();
     String error = userSshSession.getError();
     assertThat(error).isNotNull();
-    assertError(
-        "One of the following capabilities is required to access this"
-            + " resource: [runGC, maintainServer]",
-        error);
+    assertError("maintain server not permitted", error);
   }
 
   @Test
diff --git a/gerrit-server/src/test/resources/com/google/gerrit/server/mail/send/tlds-alpha-by-domain.txt b/gerrit-acceptance-tests/src/test/resources/com/google/gerrit/acceptance/server/mail/tlds-alpha-by-domain.txt
similarity index 82%
rename from gerrit-server/src/test/resources/com/google/gerrit/server/mail/send/tlds-alpha-by-domain.txt
rename to gerrit-acceptance-tests/src/test/resources/com/google/gerrit/acceptance/server/mail/tlds-alpha-by-domain.txt
index 9edf6a4..4231f76 100644
--- a/gerrit-server/src/test/resources/com/google/gerrit/server/mail/send/tlds-alpha-by-domain.txt
+++ b/gerrit-acceptance-tests/src/test/resources/com/google/gerrit/acceptance/server/mail/tlds-alpha-by-domain.txt
@@ -1,10 +1,13 @@
-# Version 2016060601, Last Updated Tue Jun  7 07:07:01 2016 UTC
+# Version 2017032102, Last Updated Wed Mar 22 07:07:01 2017 UTC
 # From http://data.iana.org/TLD/tlds-alpha-by-domain.txt
 AAA
 AARP
+ABARTH
 ABB
 ABBOTT
 ABBVIE
+ABC
+ABLE
 ABOGADO
 ABUDHABI
 AC
@@ -22,30 +25,43 @@
 AE
 AEG
 AERO
-#! AETNA
+AETNA
 AF
+AFAMILYCOMPANY
 AFL
+#! AFRICA
 AG
 AGAKHAN
 AGENCY
 AI
 AIG
+AIGO
+AIRBUS
 AIRFORCE
 AIRTEL
 AKDN
 AL
+ALFAROMEO
 ALIBABA
 ALIPAY
 ALLFINANZ
+ALLSTATE
 ALLY
 ALSACE
+ALSTOM
 AM
+AMERICANEXPRESS
+AMERICANFAMILY
+AMEX
+AMFAM
 AMICA
 AMSTERDAM
 ANALYTICS
 ANDROID
 ANQUAN
+ANZ
 AO
+AOL
 APARTMENTS
 APP
 APPLE
@@ -56,16 +72,21 @@
 ARCHI
 ARMY
 ARPA
+ART
 ARTE
 AS
+ASDA
 ASIA
 ASSOCIATES
 AT
+ATHLETA
 ATTORNEY
 AU
 AUCTION
 AUDI
+AUDIBLE
 AUDIO
+AUSPOST
 AUTHOR
 AUTO
 AUTOS
@@ -79,6 +100,8 @@
 BA
 BABY
 BAIDU
+BANAMEX
+BANANAREPUBLIC
 BAND
 BANK
 BAR
@@ -87,20 +110,25 @@
 BARCLAYS
 BAREFOOT
 BARGAINS
+BASEBALL
+BASKETBALL
 BAUHAUS
 BAYERN
 BB
 BBC
+BBT
 BBVA
 BCG
 BCN
 BD
 BE
 BEATS
+BEAUTY
 BEER
 BENTLEY
 BERLIN
 BEST
+BESTBUY
 BET
 BF
 BG
@@ -117,7 +145,9 @@
 BJ
 BLACK
 BLACKFRIDAY
-#! BLOG
+BLANCO
+BLOCKBUSTER
+BLOG
 BLOOMBERG
 BLUE
 BM
@@ -129,15 +159,19 @@
 BO
 BOATS
 BOEHRINGER
+BOFA
 BOM
 BOND
 BOO
 BOOK
+BOOKING
 BOOTS
 BOSCH
 BOSTIK
+BOSTON
 BOT
 BOUTIQUE
+BOX
 BR
 BRADESCO
 BRIDGESTONE
@@ -164,12 +198,15 @@
 CAFE
 CAL
 CALL
+CALVINKLEIN
+CAM
 CAMERA
 CAMP
 CANCERRESEARCH
 CANON
 CAPETOWN
 CAPITAL
+CAPITALONE
 CAR
 CARAVAN
 CARDS
@@ -179,12 +216,17 @@
 CARS
 CARTIER
 CASA
+CASE
+CASEIH
 CASH
 CASINO
 CAT
 CATERING
+CATHOLIC
 CBA
 CBN
+CBRE
+CBS
 CC
 CD
 CEB
@@ -201,14 +243,18 @@
 CHASE
 CHAT
 CHEAP
+CHINTAI
 CHLOE
 CHRISTMAS
 CHROME
+CHRYSLER
 CHURCH
 CI
 CIPRIANI
 CIRCLE
 CISCO
+CITADEL
+CITI
 CITIC
 CITY
 CITYEATS
@@ -232,6 +278,7 @@
 COLLEGE
 COLOGNE
 COM
+COMCAST
 COMMBANK
 COMMUNITY
 COMPANY
@@ -244,6 +291,7 @@
 CONTACT
 CONTRACTORS
 COOKING
+COOKINGCHANNEL
 COOL
 COOP
 CORSICA
@@ -258,6 +306,7 @@
 CRICKET
 CROWN
 CRS
+CRUISE
 CRUISES
 CSC
 CU
@@ -272,13 +321,15 @@
 DABUR
 DAD
 DANCE
+DATA
 DATE
 DATING
 DATSUN
 DAY
 DCLK
-#! DDS
+DDS
 DE
+DEAL
 DEALER
 DEALS
 DEGREE
@@ -292,33 +343,44 @@
 DESI
 DESIGN
 DEV
-#! DHL
+DHL
 DIAMONDS
 DIET
 DIGITAL
 DIRECT
 DIRECTORY
 DISCOUNT
+DISCOVER
+DISH
+DIY
 DJ
 DK
 DM
 DNP
 DO
 DOCS
+DOCTOR
+DODGE
 DOG
 DOHA
 DOMAINS
-#! DOT
+DOT
 DOWNLOAD
 DRIVE
-#! DTV
+DTV
 DUBAI
+DUCK
+DUNLOP
+DUNS
+DUPONT
 DURBAN
 DVAG
+DVR
 DZ
 EARTH
 EAT
 EC
+ECO
 EDEKA
 EDU
 EDUCATION
@@ -330,13 +392,16 @@
 ENGINEER
 ENGINEERING
 ENTERPRISES
+EPOST
 EPSON
 EQUIPMENT
 ER
+ERICSSON
 ERNI
 ES
 ESQ
 ESTATE
+ESURANCE
 ET
 EU
 EUROVISION
@@ -356,15 +421,22 @@
 FAN
 FANS
 FARM
+FARMERS
 FASHION
 FAST
+FEDEX
 FEEDBACK
+FERRARI
 FERRERO
 FI
+FIAT
+FIDELITY
+FIDO
 FILM
 FINAL
 FINANCE
 FINANCIAL
+FIRE
 FIRESTONE
 FIRMDALE
 FISH
@@ -375,14 +447,15 @@
 FK
 FLICKR
 FLIGHTS
-#! FLIR
+FLIR
 FLORIST
 FLOWERS
-FLSMIDTH
 FLY
 FM
 FO
 FOO
+FOOD
+FOODNETWORK
 FOOTBALL
 FORD
 FOREX
@@ -391,11 +464,16 @@
 FOUNDATION
 FOX
 FR
+FREE
 FRESENIUS
 FRL
 FROGANS
+FRONTDOOR
 FRONTIER
 FTR
+FUJITSU
+FUJIXEROX
+FUN
 FUND
 FURNITURE
 FUTBOL
@@ -406,7 +484,8 @@
 GALLO
 GALLUP
 GAME
-#! GAMES
+GAMES
+GAP
 GARDEN
 GB
 GBIZ
@@ -416,6 +495,7 @@
 GEA
 GENT
 GENTING
+GEORGE
 GF
 GG
 GGEE
@@ -426,6 +506,7 @@
 GIVES
 GIVING
 GL
+GLADE
 GLASS
 GLE
 GLOBAL
@@ -436,10 +517,13 @@
 GMO
 GMX
 GN
+GODADDY
 GOLD
 GOLDPOINT
 GOLF
 GOO
+GOODHANDS
+GOODYEAR
 GOOG
 GOOGLE
 GOP
@@ -457,7 +541,7 @@
 GS
 GT
 GU
-#! GUARDIAN
+GUARDIAN
 GUCCI
 GUGE
 GUIDE
@@ -465,9 +549,12 @@
 GURU
 GW
 GY
+HAIR
 HAMBURG
 HANGOUT
 HAUS
+HBO
+HDFC
 HDFCBANK
 HEALTH
 HEALTHCARE
@@ -475,23 +562,29 @@
 HELSINKI
 HERE
 HERMES
+HGTV
 HIPHOP
-#! HISAMITSU
+HISAMITSU
 HITACHI
 HIV
 HK
-#! HKT
+HKT
 HM
 HN
 HOCKEY
 HOLDINGS
 HOLIDAY
 HOMEDEPOT
+HOMEGOODS
 HOMES
+HOMESENSE
 HONDA
+HONEYWELL
 HORSE
+HOSPITAL
 HOST
 HOSTING
+HOT
 HOTELES
 HOTMAIL
 HOUSE
@@ -501,6 +594,8 @@
 HT
 HTC
 HU
+HUGHES
+HYATT
 HYUNDAI
 IBM
 ICBC
@@ -508,11 +603,13 @@
 ICU
 ID
 IE
+IEEE
 IFM
-IINET
+IKANO
 IL
 IM
 IMAMAT
+IMDB
 IMMO
 IMMOBILIEN
 IN
@@ -525,7 +622,9 @@
 INSURANCE
 INSURE
 INT
+INTEL
 INTERNATIONAL
+INTUIT
 INVESTMENTS
 IO
 IPIRANGA
@@ -539,14 +638,18 @@
 ISTANBUL
 IT
 ITAU
+ITV
+IVECO
 IWC
 JAGUAR
 JAVA
 JCB
 JCP
 JE
+JEEP
 JETZT
 JEWELRY
+JIO
 JLC
 JLL
 JM
@@ -561,6 +664,7 @@
 JPMORGAN
 JPRS
 JUEGOS
+JUNIPER
 KAUFEN
 KDDI
 KE
@@ -574,12 +678,14 @@
 KIA
 KIM
 KINDER
+KINDLE
 KITCHEN
 KIWI
 KM
 KN
 KOELN
 KOMATSU
+KOSHER
 KP
 KPMG
 KPN
@@ -593,14 +699,18 @@
 KZ
 LA
 LACAIXA
+LADBROKES
 LAMBORGHINI
 LAMER
 LANCASTER
+LANCIA
+LANCOME
 LAND
 LANDROVER
 LANXESS
 LASALLE
 LAT
+LATINO
 LATROBE
 LAW
 LAWYER
@@ -609,7 +719,9 @@
 LDS
 LEASE
 LECLERC
+LEFRAK
 LEGAL
+LEGO
 LEXUS
 LGBT
 LI
@@ -620,37 +732,43 @@
 LIFESTYLE
 LIGHTING
 LIKE
+LILLY
 LIMITED
 LIMO
 LINCOLN
 LINDE
 LINK
-#! LIPSY
+LIPSY
 LIVE
 LIVING
 LIXIL
 LK
 LOAN
 LOANS
-#! LOCKER
+LOCKER
 LOCUS
+LOFT
 LOL
 LONDON
 LOTTE
 LOTTO
 LOVE
+LPL
+LPLFINANCIAL
 LR
 LS
 LT
 LTD
 LTDA
 LU
+LUNDBECK
 LUPIN
 LUXE
 LUXURY
 LV
 LY
 MA
+MACYS
 MADRID
 MAIF
 MAISON
@@ -662,9 +780,14 @@
 MARKETING
 MARKETS
 MARRIOTT
-#! MATTEL
+MARSHALLS
+MASERATI
+MATTEL
 MBA
 MC
+MCD
+MCDONALDS
+MCKINSEY
 MD
 ME
 MED
@@ -676,22 +799,26 @@
 MEN
 MENU
 MEO
-#! METLIFE
+METLIFE
 MG
 MH
 MIAMI
 MICROSOFT
 MIL
 MINI
+MINT
+MIT
+MITSUBISHI
 MK
 ML
-#! MLB
+MLB
 MLS
 MM
 MMA
 MN
 MO
 MOBI
+MOBILE
 MOBILY
 MODA
 MOE
@@ -699,10 +826,13 @@
 MOM
 MONASH
 MONEY
+MONSTER
 MONTBLANC
+MOPAR
 MORMON
 MORTGAGE
 MOSCOW
+MOTO
 MOTORCYCLES
 MOV
 MOVIE
@@ -711,6 +841,7 @@
 MQ
 MR
 MS
+MSD
 MT
 MTN
 MTPC
@@ -718,37 +849,42 @@
 MU
 MUSEUM
 MUTUAL
-MUTUELLE
 MV
 MW
 MX
 MY
 MZ
 NA
+NAB
 NADEX
 NAGOYA
 NAME
+NATIONWIDE
 NATURA
 NAVY
+NBA
 NC
 NE
 NEC
 NET
 NETBANK
-#! NETFLIX
+NETFLIX
 NETWORK
 NEUSTAR
 NEW
+NEWHOLLAND
 NEWS
-#! NEXT
-#! NEXTDIRECT
+NEXT
+NEXTDIRECT
 NEXUS
 NF
+NFL
 NG
 NGO
 NHK
 NI
 NICO
+NIKE
 NIKON
 NINJA
 NISSAN
@@ -758,8 +894,9 @@
 NOKIA
 NORTHWESTERNMUTUAL
 NORTON
+NOW
 NOWRUZ
-#! NOWTV
+NOWTV
 NP
 NR
 NRA
@@ -769,30 +906,37 @@
 NYC
 NZ
 OBI
+OBSERVER
+OFF
 OFFICE
 OKINAWA
-#! OLAYAN
-#! OLAYANGROUP
-#! OLLO
+OLAYAN
+OLAYANGROUP
+OLDNAVY
+OLLO
 OM
 OMEGA
 ONE
 ONG
 ONL
 ONLINE
+ONYOURSIDE
 OOO
+OPEN
 ORACLE
 ORANGE
 ORG
 ORGANIC
+ORIENTEXPRESS
 ORIGINS
 OSAKA
 OTSUKA
-#! OTT
+OTT
 OVH
 PA
 PAGE
 PAMPEREDCHEF
+PANASONIC
 PANERAI
 PARIS
 PARS
@@ -800,14 +944,17 @@
 PARTS
 PARTY
 PASSAGENS
-#! PCCW
+PAY
+PCCW
 PE
 PET
 PF
+PFIZER
 PG
 PH
 PHARMACY
 PHILIPS
+PHONE
 PHOTO
 PHOTOGRAPHY
 PHOTOS
@@ -820,7 +967,7 @@
 PIN
 PING
 PINK
-#! PIONEER
+PIONEER
 PIZZA
 PK
 PL
@@ -831,13 +978,17 @@
 PLUS
 PM
 PN
+PNC
 POHL
 POKER
+POLITIE
 PORN
 POST
 PR
+PRAMERICA
 PRAXI
 PRESS
+PRIME
 PRO
 PROD
 PRODUCTIONS
@@ -847,6 +998,8 @@
 PROPERTIES
 PROPERTY
 PROTECTION
+PRU
+PRUDENTIAL
 PS
 PT
 PUB
@@ -857,10 +1010,13 @@
 QPON
 QUEBEC
 QUEST
+QVC
 RACING
+RADIO
+RAID
 RE
 READ
-#! REALESTATE
+REALESTATE
 REALTOR
 REALTY
 RECIPES
@@ -871,6 +1027,7 @@
 REISE
 REISEN
 REIT
+RELIANCE
 REN
 RENT
 RENTALS
@@ -883,14 +1040,18 @@
 REVIEWS
 REXROTH
 RICH
-#! RICHARDLI
+RICHARDLI
 RICOH
+RIGHTATHOME
+RIL
 RIO
 RIP
+RMIT
 RO
 ROCHER
 ROCKS
 RODEO
+ROGERS
 ROOM
 RS
 RSVP
@@ -907,6 +1068,7 @@
 SAKURA
 SALE
 SALON
+SAMSCLUB
 SAMSUNG
 SANDVIK
 SANDVIKCOROMANT
@@ -915,6 +1077,7 @@
 SAPO
 SARL
 SAS
+SAVE
 SAXO
 SB
 SBI
@@ -929,16 +1092,19 @@
 SCHULE
 SCHWARZ
 SCIENCE
+SCJOHNSON
 SCOR
 SCOT
 SD
 SE
 SEAT
+SECURE
 SECURITY
 SEEK
 SELECT
 SENER
 SERVICES
+SES
 SEVEN
 SEW
 SEX
@@ -946,17 +1112,21 @@
 SFR
 SG
 SH
+SHANGRILA
 SHARP
 SHAW
 SHELL
 SHIA
 SHIKSHA
 SHOES
-#! SHOP
+SHOP
+SHOPPING
 SHOUJI
 SHOW
+SHOWTIME
 SHRIRAM
 SI
+SILK
 SINA
 SINGLES
 SITE
@@ -967,7 +1137,9 @@
 SKY
 SKYPE
 SL
+SLING
 SM
+SMART
 SMILE
 SN
 SNCF
@@ -988,8 +1160,10 @@
 SPREADBETTING
 SR
 SRL
+SRT
 ST
 STADA
+STAPLES
 STAR
 STARHUB
 STATEBANK
@@ -1014,6 +1188,7 @@
 SUZUKI
 SV
 SWATCH
+SWIFTCOVER
 SWISS
 SX
 SY
@@ -1025,6 +1200,7 @@
 TAIPEI
 TALK
 TAOBAO
+TARGET
 TATAMOTORS
 TATAR
 TATTOO
@@ -1033,6 +1209,7 @@
 TC
 TCI
 TD
+TDK
 TEAM
 TECH
 TECHNOLOGY
@@ -1048,6 +1225,7 @@
 THD
 THEATER
 THEATRE
+TIAA
 TICKETS
 TIENDA
 TIFFANY
@@ -1055,7 +1233,10 @@
 TIRES
 TIROL
 TJ
+TJMAXX
+TJX
 TK
+TKMAXX
 TL
 TM
 TMALL
@@ -1077,6 +1258,7 @@
 TRADING
 TRAINING
 TRAVEL
+TRAVELCHANNEL
 TRAVELERS
 TRAVELERSINSURANCE
 TRUST
@@ -1091,20 +1273,23 @@
 TW
 TZ
 UA
+UBANK
 UBS
+UCONNECT
 UG
 UK
 UNICOM
 UNIVERSITY
 UNO
 UOL
-#! UPS
+UPS
 US
 UY
 UZ
 VA
 VACATIONS
 VANA
+VANGUARD
 VC
 VE
 VEGAS
@@ -1122,14 +1307,17 @@
 VIN
 VIP
 VIRGIN
+VISA
 VISION
 VISTA
 VISTAPRINT
 VIVA
+VIVO
 VLAANDEREN
 VN
 VODKA
 VOLKSWAGEN
+VOLVO
 VOTE
 VOTING
 VOTO
@@ -1137,10 +1325,11 @@
 VU
 VUELOS
 WALES
+WALMART
 WALTER
 WANG
 WANGGOU
-#! WARMAN
+WARMAN
 WATCH
 WATCHES
 WEATHER
@@ -1160,16 +1349,20 @@
 WIN
 WINDOWS
 WINE
+WINNERS
 WME
 WOLTERSKLUWER
+WOODSIDE
 WORK
 WORKS
 WORLD
+WOW
 WS
 WTC
 WTF
 XBOX
 XEROX
+XFINITY
 XIHUAN
 XIN
 XN--11B4C3D
@@ -1179,22 +1372,27 @@
 XN--3BST00M
 XN--3DS443G
 XN--3E0B707E
+XN--3OQ18VL8PN36A
 XN--3PXU8K
 XN--42C2D9A
 XN--45BRJ9C
 XN--45Q11C
 XN--4GBRIM
+XN--54B7FTA0CC
 XN--55QW42G
 XN--55QX5D
+XN--5SU34J936BGSG
 XN--5TZM5G
 XN--6FRZ82G
 XN--6QQ986B3XL
 XN--80ADXHKS
 XN--80AO21A
+XN--80AQECDR1A
 XN--80ASEHDB
 XN--80ASWG
 XN--8Y0A063A
 XN--90A3AC
+XN--90AE
 XN--90AIS
 XN--9DBQ2A
 XN--9ET52U
@@ -1229,6 +1427,7 @@
 XN--G2XX48C
 XN--GCKR3F0F
 XN--GECRJ9C
+XN--GK3AT1E
 XN--H2BRJ9C
 XN--HXT814E
 XN--I1B6B1A6A2E
@@ -1252,12 +1451,14 @@
 XN--MGBA7C0BBN0A
 XN--MGBAAM7A8H
 XN--MGBAB2BD
+XN--MGBAI9AZGQP6J
 XN--MGBAYH7GPA
 XN--MGBB9FBPOB
 XN--MGBBH1A71E
 XN--MGBC0A9AZCG
 XN--MGBCA7DZDO
 XN--MGBERP4A5D4AR
+XN--MGBI4ECEXP
 XN--MGBPL2FH
 XN--MGBT3DHD
 XN--MGBTX2B
@@ -1287,6 +1488,7 @@
 XN--SES554G
 XN--T60B56A
 XN--TCKWE
+XN--TIQ49XQYJ
 XN--UNUP4Y
 XN--VERMGENSBERATER-CTB
 XN--VERMGENSBERATUNG-PWB
@@ -1319,10 +1521,11 @@
 YT
 YUN
 ZA
-#! ZAPPOS
+ZAPPOS
 ZARA
 ZERO
 ZIP
+ZIPPO
 ZM
 ZONE
 ZUERICH
diff --git a/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index 3b86c95..8bdc410 100644
--- a/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/gerrit-cache-h2/src/main/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -131,7 +131,22 @@
 
   @Override
   public V get(K key, Callable<? extends V> valueLoader) throws ExecutionException {
-    return mem.get(key, new LoadingCallable(key, valueLoader)).value;
+    return mem.get(
+            key,
+            () -> {
+              if (store.mightContain(key)) {
+                ValueHolder<V> h = store.getIfPresent(key);
+                if (h != null) {
+                  return h;
+                }
+              }
+
+              ValueHolder<V> h = new ValueHolder<>(valueLoader.call());
+              h.created = TimeUtil.nowMs();
+              executor.execute(() -> store.put(key, h));
+              return h;
+            })
+        .value;
   }
 
   @Override
@@ -239,31 +254,6 @@
     }
   }
 
-  private class LoadingCallable implements Callable<ValueHolder<V>> {
-    private final K key;
-    private final Callable<? extends V> loader;
-
-    LoadingCallable(K key, Callable<? extends V> loader) {
-      this.key = key;
-      this.loader = loader;
-    }
-
-    @Override
-    public ValueHolder<V> call() throws Exception {
-      if (store.mightContain(key)) {
-        ValueHolder<V> h = store.getIfPresent(key);
-        if (h != null) {
-          return h;
-        }
-      }
-
-      final ValueHolder<V> h = new ValueHolder<>(loader.call());
-      h.created = TimeUtil.nowMs();
-      executor.execute(() -> store.put(key, h));
-      return h;
-    }
-  }
-
   private static class KeyType<K> {
     String columnType() {
       return "OTHER";
diff --git a/gerrit-cache-h2/src/test/java/com/google/gerrit/server/cache/h2/H2CacheTest.java b/gerrit-cache-h2/src/test/java/com/google/gerrit/server/cache/h2/H2CacheTest.java
index 15e0de0..80bca6d 100644
--- a/gerrit-cache-h2/src/test/java/com/google/gerrit/server/cache/h2/H2CacheTest.java
+++ b/gerrit-cache-h2/src/test/java/com/google/gerrit/server/cache/h2/H2CacheTest.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.server.cache.h2.H2CacheImpl.SqlStore;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
 import com.google.inject.TypeLiteral;
-import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
 import org.junit.Before;
@@ -54,12 +53,9 @@
     assertTrue(
         impl.get(
             "foo",
-            new Callable<Boolean>() {
-              @Override
-              public Boolean call() throws Exception {
-                called.set(true);
-                return true;
-              }
+            () -> {
+              called.set(true);
+              return true;
             }));
     assertTrue("used Callable", called.get());
     assertTrue("exists in cache", impl.getIfPresent("foo"));
@@ -70,12 +66,9 @@
     assertTrue(
         impl.get(
             "foo",
-            new Callable<Boolean>() {
-              @Override
-              public Boolean call() throws Exception {
-                called.set(true);
-                return true;
-              }
+            () -> {
+              called.set(true);
+              return true;
             }));
     assertFalse("did not invoke Callable", called.get());
   }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/GlobalCapability.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/GlobalCapability.java
index 4c9b64a..6fd0e77 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/GlobalCapability.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/GlobalCapability.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.common.data;
 
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
@@ -115,6 +116,9 @@
 
   private static final List<String> NAMES_ALL;
   private static final List<String> NAMES_LC;
+  private static final String[] RANGE_NAMES = {
+    QUERY_LIMIT, BATCH_CHANGES_LIMIT,
+  };
 
   static {
     NAMES_ALL = new ArrayList<>();
@@ -158,7 +162,16 @@
 
   /** @return true if the capability should have a range attached. */
   public static boolean hasRange(String varName) {
-    return QUERY_LIMIT.equalsIgnoreCase(varName) || BATCH_CHANGES_LIMIT.equalsIgnoreCase(varName);
+    for (String n : RANGE_NAMES) {
+      if (n.equalsIgnoreCase(varName)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  public static List<String> getRangeNames() {
+    return Collections.unmodifiableList(Arrays.asList(RANGE_NAMES));
   }
 
   /** @return the valid range for the capability if it has one, otherwise null. */
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/PatchScript.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/PatchScript.java
index 172be09..2c4d0c4e 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/PatchScript.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/PatchScript.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gerrit.prettify.common.EditList;
 import com.google.gerrit.prettify.common.SparseFileContent;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Patch;
@@ -211,14 +210,6 @@
     return edits;
   }
 
-  public Iterable<EditList.Hunk> getHunks() {
-    int ctx = diffPrefs.context;
-    if (ctx == DiffPreferencesInfo.WHOLE_FILE_CONTEXT) {
-      ctx = Math.max(a.size(), b.size());
-    }
-    return new EditList(edits, ctx, a.size(), b.size()).getHunks();
-  }
-
   public boolean isBinary() {
     return binary;
   }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java
index 47c5224..6222c1b 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java
@@ -47,6 +47,7 @@
   public static final String SUBMIT = "submit";
   public static final String SUBMIT_AS = "submitAs";
   public static final String VIEW_DRAFTS = "viewDrafts";
+  public static final String VIEW_PRIVATE_CHANGES = "viewPrivateChanges";
 
   private static final List<String> NAMES_LC;
   private static final int LABEL_INDEX;
@@ -74,6 +75,7 @@
     NAMES_LC.add(SUBMIT.toLowerCase());
     NAMES_LC.add(SUBMIT_AS.toLowerCase());
     NAMES_LC.add(VIEW_DRAFTS.toLowerCase());
+    NAMES_LC.add(VIEW_PRIVATE_CHANGES.toLowerCase());
     NAMES_LC.add(EDIT_TOPIC_NAME.toLowerCase());
     NAMES_LC.add(EDIT_HASHTAGS.toLowerCase());
     NAMES_LC.add(EDIT_ASSIGNEE.toLowerCase());
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
index 2a46c40..c13d8d7 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
@@ -38,9 +38,9 @@
 import com.google.gson.JsonObject;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import io.searchbox.client.JestResult;
 import io.searchbox.core.Bulk;
 import io.searchbox.core.Bulk.Builder;
@@ -76,7 +76,7 @@
   private final AccountMapping mapping;
   private final Provider<AccountCache> accountCache;
 
-  @AssistedInject
+  @Inject
   ElasticAccountIndex(
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
index 4f90de5..b0adbaf 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.reviewdb.client.Change.Id;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
@@ -54,9 +55,9 @@
 import com.google.gson.JsonObject;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import io.searchbox.client.JestResult;
 import io.searchbox.core.Bulk;
 import io.searchbox.core.Bulk.Builder;
@@ -99,7 +100,7 @@
   private final Provider<ReviewDb> db;
   private final ChangeData.Factory changeDataFactory;
 
-  @AssistedInject
+  @Inject
   ElasticChangeIndex(
       @GerritServerConfig Config cfg,
       Provider<ReviewDb> db,
@@ -343,6 +344,16 @@
         cd.setReviewers(ReviewerSet.empty());
       }
 
+      if (source.get(ChangeField.REVIEWER_BY_EMAIL.getName()) != null) {
+        cd.setReviewersByEmail(
+            ChangeField.parseReviewerByEmailFieldValues(
+                FluentIterable.from(
+                        source.get(ChangeField.REVIEWER_BY_EMAIL.getName()).getAsJsonArray())
+                    .transform(JsonElement::getAsString)));
+      } else if (fields.contains(ChangeField.REVIEWER_BY_EMAIL.getName())) {
+        cd.setReviewersByEmail(ReviewerByEmailSet.empty());
+      }
+
       decodeSubmitRecords(
           source,
           ChangeField.STORED_SUBMIT_RECORD_STRICT.getName(),
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
index e2c34f2..018f8d8 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
@@ -35,9 +35,9 @@
 import com.google.gson.JsonObject;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import io.searchbox.client.JestResult;
 import io.searchbox.core.Bulk;
 import io.searchbox.core.Bulk.Builder;
@@ -73,7 +73,7 @@
   private final GroupMapping mapping;
   private final Provider<GroupCache> groupCache;
 
-  @AssistedInject
+  @Inject
   ElasticGroupIndex(
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
index 65065f9..2108815 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
@@ -72,6 +72,6 @@
   @Provides
   @Singleton
   IndexConfig getIndexConfig(@GerritServerConfig Config cfg) {
-    return IndexConfig.fromConfig(cfg);
+    return IndexConfig.fromConfig(cfg).separateChangeSubIndexes(true).build();
   }
 }
diff --git a/gerrit-extension-api/pom.xml b/gerrit-extension-api/pom.xml
index 61d0bec..9468b67 100644
--- a/gerrit-extension-api/pom.xml
+++ b/gerrit-extension-api/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-extension-api</artifactId>
-  <version>2.14.1-SNAPSHOT</version>
+  <version>2.15-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Exports.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Exports.java
index 05fd5b2..1295ea0 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Exports.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/annotations/Exports.java
@@ -21,5 +21,10 @@
     return new ExportImpl(name);
   }
 
+  /** Create an annotation to export based on a cannonical class name. */
+  public static Export named(Class<?> clazz) {
+    return named(clazz.getCanonicalName());
+  }
+
   private Exports() {}
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/GlobalOrPluginPermission.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/GlobalOrPluginPermission.java
new file mode 100644
index 0000000..deae084
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/GlobalOrPluginPermission.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.access;
+
+/**
+ * A {@link com.google.gerrit.server.permissions.GlobalPermission} or a {@link PluginPermission}.
+ */
+public interface GlobalOrPluginPermission {
+  /** @return name used in {@code project.config} permissions. */
+  public String permissionName();
+
+  /** @return readable identifier of this permission for exception message. */
+  public String describeForException();
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PluginPermission.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PluginPermission.java
new file mode 100644
index 0000000..33a85cd
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/access/PluginPermission.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.access;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import java.util.Objects;
+
+/** A global capability type permission used by a plugin. */
+public class PluginPermission implements GlobalOrPluginPermission {
+  private final String pluginName;
+  private final String capability;
+
+  public PluginPermission(String pluginName, String capability) {
+    this.pluginName = checkNotNull(pluginName, "pluginName");
+    this.capability = checkNotNull(capability, "capability");
+  }
+
+  public String pluginName() {
+    return pluginName;
+  }
+
+  public String capability() {
+    return capability;
+  }
+
+  @Override
+  public String permissionName() {
+    return pluginName + '-' + capability;
+  }
+
+  @Override
+  public String describeForException() {
+    return capability + " for plugin " + pluginName;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(pluginName, capability);
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (other instanceof PluginPermission) {
+      PluginPermission b = (PluginPermission) other;
+      return pluginName.equals(b.pluginName) && capability.equals(b.capability);
+    }
+    return false;
+  }
+
+  @Override
+  public String toString() {
+    return "PluginPermission[plugin=" + pluginName + ", capability=" + capability + ']';
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 27fdc18..3dabced 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -85,6 +86,34 @@
 
   void move(MoveInput in) throws RestApiException;
 
+  void setPrivate(boolean value, @Nullable String message) throws RestApiException;
+
+  void setWorkInProgress(String message) throws RestApiException;
+
+  void setReadyForReview(String message) throws RestApiException;
+
+  default void setWorkInProgress() throws RestApiException {
+    setWorkInProgress(null);
+  }
+
+  default void setReadyForReview() throws RestApiException {
+    setReadyForReview(null);
+  }
+
+  /**
+   * Ignore or un-ignore this change.
+   *
+   * @param ignore ignore the change if true
+   */
+  void ignore(boolean ignore) throws RestApiException;
+
+  /**
+   * Mute or un-mute this change.
+   *
+   * @param mute mute the change if true
+   */
+  void mute(boolean mute) throws RestApiException;
+
   /**
    * Create a new change that reverts this change.
    *
@@ -129,9 +158,9 @@
 
   IncludedInInfo includedIn() throws RestApiException;
 
-  void addReviewer(AddReviewerInput in) throws RestApiException;
+  AddReviewerResult addReviewer(AddReviewerInput in) throws RestApiException;
 
-  void addReviewer(String in) throws RestApiException;
+  AddReviewerResult addReviewer(String in) throws RestApiException;
 
   SuggestedReviewersRequest suggestReviewers() throws RestApiException;
 
@@ -307,6 +336,21 @@
     }
 
     @Override
+    public void setPrivate(boolean value, @Nullable String message) {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void setWorkInProgress(String message) {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void setReadyForReview(String message) {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public ChangeApi revert() {
       throw new NotImplementedException();
     }
@@ -352,12 +396,12 @@
     }
 
     @Override
-    public void addReviewer(AddReviewerInput in) {
+    public AddReviewerResult addReviewer(AddReviewerInput in) {
       throw new NotImplementedException();
     }
 
     @Override
-    public void addReviewer(String in) {
+    public AddReviewerResult addReviewer(String in) {
       throw new NotImplementedException();
     }
 
@@ -476,5 +520,15 @@
     public ChangeInfo createMergePatchSet(MergePatchSetInput in) {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void ignore(boolean ignore) {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void mute(boolean mute) {
+      throw new NotImplementedException();
+    }
   }
 }
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 2e1bb13..3ac3601 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
@@ -14,8 +14,13 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import java.util.Map;
+
 public class CherryPickInput {
   public String message;
   public String destination;
   public Integer parent;
+
+  public NotifyHandling notify = NotifyHandling.NONE;
+  public Map<RecipientType, NotifyInfo> notifyDetails;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CommentApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CommentApi.java
index 78f2b89..46827e5 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CommentApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CommentApi.java
@@ -22,6 +22,17 @@
   CommentInfo get() throws RestApiException;
 
   /**
+   * Deletes a published comment of a revision. For NoteDb, it deletes the comment by rewriting the
+   * commit history.
+   *
+   * <p>Note instead of deleting the whole comment, this endpoint just replaces the comment's
+   * message.
+   *
+   * @return the comment with its message updated.
+   */
+  CommentInfo delete(DeleteCommentInput input) throws RestApiException;
+
+  /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
    */
@@ -30,5 +41,10 @@
     public CommentInfo get() {
       throw new NotImplementedException();
     }
+
+    @Override
+    public CommentInfo delete(DeleteCommentInput input) {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteCommentInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteCommentInput.java
new file mode 100644
index 0000000..75fd16b
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DeleteCommentInput.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+public class DeleteCommentInput {
+  @DefaultInput public String reason;
+
+  public DeleteCommentInput() {
+    reason = "";
+  }
+
+  public DeleteCommentInput(String reason) {
+    this.reason = Strings.nullToEmpty(reason);
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java
index af61481..3a33de9 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java
@@ -25,6 +25,13 @@
    */
   @Nullable public Map<String, String> approvals;
 
+  public static ReviewerInfo byEmail(@Nullable String name, String email) {
+    ReviewerInfo info = new ReviewerInfo();
+    info.name = name;
+    info.email = email;
+    return info;
+  }
+
   public ReviewerInfo(Integer id) {
     super(id);
   }
@@ -33,4 +40,6 @@
   public String toString() {
     return username;
   }
+
+  private ReviewerInfo() {}
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index 5dd4ba4..9969995 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.common.MergeableInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
@@ -86,6 +87,17 @@
 
   List<RobotCommentInfo> robotCommentsAsList() throws RestApiException;
 
+  /**
+   * Applies the indicated fix by creating a new change edit or integrating the fix with the
+   * existing change edit. If no change edit exists before this call, the fix must refer to the
+   * current patch set. If a change edit exists, the fix must refer to the patch set on which the
+   * change edit is based.
+   *
+   * @param fixId the ID of the fix which should be applied
+   * @throws RestApiException if the fix couldn't be applied
+   */
+  EditInfo applyFix(String fixId) throws RestApiException;
+
   DraftApi createDraft(DraftInput in) throws RestApiException;
 
   DraftApi draft(String id) throws RestApiException;
@@ -255,6 +267,11 @@
     }
 
     @Override
+    public EditInfo applyFix(String fixId) {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, List<CommentInfo>> drafts() {
       throw new NotImplementedException();
     }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
new file mode 100644
index 0000000..fab2ec4
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
@@ -0,0 +1,23 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.config;
+
+public class AccessCheckInfo {
+  public String message;
+  // HTTP status code
+  public int status;
+
+  // for future extension, we may add inputs / results for bulk checks.
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/AccessCheckInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/AccessCheckInput.java
new file mode 100644
index 0000000..80a537c
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/AccessCheckInput.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.config;
+
+import com.google.gerrit.common.Nullable;
+
+public class AccessCheckInput {
+  public String account;
+  public String project;
+
+  @Nullable public String ref;
+
+  public AccessCheckInput(String account, String project, @Nullable String ref) {
+    this.account = account;
+    this.project = project;
+    this.ref = ref;
+  }
+
+  public AccessCheckInput() {}
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java
new file mode 100644
index 0000000..e3b7dd3
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.config;
+
+import java.util.List;
+import java.util.Objects;
+
+public class ConsistencyCheckInfo {
+  public CheckAccountExternalIdsResultInfo checkAccountExternalIdsResult;
+
+  public static class CheckAccountExternalIdsResultInfo {
+    public List<ConsistencyProblemInfo> problems;
+
+    public CheckAccountExternalIdsResultInfo(List<ConsistencyProblemInfo> problems) {
+      this.problems = problems;
+    }
+  }
+
+  public static class ConsistencyProblemInfo {
+    public enum Status {
+      ERROR,
+      WARNING,
+    }
+
+    public final Status status;
+    public final String message;
+
+    public ConsistencyProblemInfo(Status status, String message) {
+      this.status = status;
+      this.message = message;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof ConsistencyProblemInfo) {
+        ConsistencyProblemInfo other = ((ConsistencyProblemInfo) o);
+        return Objects.equals(status, other.status) && Objects.equals(message, other.message);
+      }
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(status, message);
+    }
+
+    @Override
+    public String toString() {
+      return status.name() + ": " + message;
+    }
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInput.java
new file mode 100644
index 0000000..170db0f
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInput.java
@@ -0,0 +1,21 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.config;
+
+public class ConsistencyCheckInput {
+  public CheckAccountExternalIdsInput checkAccountExternalIds;
+
+  public static class CheckAccountExternalIdsInput {}
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Server.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Server.java
index 97f4af0..2280396 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Server.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Server.java
@@ -34,6 +34,10 @@
 
   DiffPreferencesInfo setDefaultDiffPreferences(DiffPreferencesInfo in) throws RestApiException;
 
+  ConsistencyCheckInfo checkConsistency(ConsistencyCheckInput in) throws RestApiException;
+
+  AccessCheckInfo checkAccess(AccessCheckInput in) throws RestApiException;
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -68,5 +72,15 @@
     public DiffPreferencesInfo setDefaultDiffPreferences(DiffPreferencesInfo in) {
       throw new NotImplementedException();
     }
+
+    @Override
+    public ConsistencyCheckInfo checkConsistency(ConsistencyCheckInput in) {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public AccessCheckInfo checkAccess(AccessCheckInput in) {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/CommitApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/CommitApi.java
new file mode 100644
index 0000000..85bd952
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/CommitApi.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+public interface CommitApi {
+
+  ChangeApi cherryPick(CherryPickInput input) throws RestApiException;
+
+  /** A default implementation for source compatibility when adding new methods to the interface. */
+  class NotImplemented implements CommitApi {
+    @Override
+    public ChangeApi cherryPick(CherryPickInput input) {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
index cc91a4a..e30a730 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
@@ -31,6 +31,7 @@
   public InheritedBooleanInfo enableSignedPush;
   public InheritedBooleanInfo requireSignedPush;
   public InheritedBooleanInfo rejectImplicitMerges;
+  public InheritedBooleanInfo enableReviewerByEmail;
   public MaxObjectSizeLimitInfo maxObjectSizeLimit;
   public SubmitType submitType;
   public ProjectState state;
@@ -40,6 +41,8 @@
   public Map<String, CommentLinkInfo> commentlinks;
   public ThemeInfo theme;
 
+  public Map<String, List<String>> extensionPanelNames;
+
   public static class InheritedBooleanInfo {
     public Boolean value;
     public InheritableBoolean configuredValue;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
index ae81ea5..03b9772 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
@@ -29,6 +29,7 @@
   public InheritableBoolean enableSignedPush;
   public InheritableBoolean requireSignedPush;
   public InheritableBoolean rejectImplicitMerges;
+  public InheritableBoolean enableReviewerByEmail;
   public String maxObjectSizeLimit;
   public SubmitType submitType;
   public ProjectState state;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index dc2f899..6db13fc 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -125,6 +125,14 @@
   TagApi tag(String ref) throws RestApiException;
 
   /**
+   * Lookup a commit by its {@code ObjectId} string.
+   *
+   * @param commit the {@code ObjectId} string.
+   * @return API for accessing the commit.
+   */
+  CommitApi commit(String commit) throws RestApiException;
+
+  /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
    */
@@ -218,5 +226,10 @@
     public void deleteTags(DeleteTagsInput in) {
       throw new NotImplementedException();
     }
+
+    @Override
+    public CommitApi commit(String commit) {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java
index 2225a99..3307997 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.client;
 
 import java.sql.Timestamp;
+import java.util.Comparator;
 import java.util.Objects;
 
 public abstract class Comment {
@@ -36,7 +37,13 @@
   public String message;
   public Boolean unresolved;
 
-  public static class Range {
+  public static class Range implements Comparable<Range> {
+    private static final Comparator<Range> RANGE_COMPARATOR =
+        Comparator.<Range>comparingInt(range -> range.startLine)
+            .thenComparingInt(range -> range.startCharacter)
+            .thenComparingInt(range -> range.endLine)
+            .thenComparingInt(range -> range.endCharacter);
+
     public int startLine; // 1-based, inclusive
     public int startCharacter; // 0-based, inclusive
     public int endLine; // 1-based, exclusive
@@ -81,6 +88,11 @@
           + endCharacter
           + '}';
     }
+
+    @Override
+    public int compareTo(Range otherRange) {
+      return RANGE_COMPARATOR.compare(this, otherRange);
+    }
   }
 
   public short side() {
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 7192ff9..9dcba5e 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -157,6 +157,7 @@
   public EmailStrategy emailStrategy;
   public EmailFormat emailFormat;
   public DefaultBase defaultBaseForMerges;
+  public Boolean publishCommentsOnPush;
 
   public boolean isShowInfoInReviewCategory() {
     return getReviewCategoryStrategy() != ReviewCategoryStrategy.NONE;
@@ -225,6 +226,7 @@
     p.muteCommonPathPrefixes = true;
     p.signedOffBy = false;
     p.defaultBaseForMerges = DefaultBase.FIRST_PARENT;
+    p.publishCommentsOnPush = false;
     return p;
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountInfo.java
index 2fb32d7..f20509b 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.util.List;
+import java.util.Objects;
 
 public class AccountInfo {
   public Integer _accountId;
@@ -29,4 +30,34 @@
   public AccountInfo(Integer id) {
     this._accountId = id;
   }
+
+  /** To be used ONLY in connection with unregistered reviewers and CCs. */
+  public AccountInfo(String name, String email) {
+    this.name = name;
+    this.email = email;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof AccountInfo) {
+      AccountInfo accountInfo = (AccountInfo) o;
+      return Objects.equals(_accountId, accountInfo._accountId)
+          && Objects.equals(name, accountInfo.name)
+          && Objects.equals(email, accountInfo.email)
+          && Objects.equals(secondaryEmails, accountInfo.secondaryEmails)
+          && Objects.equals(username, accountInfo.username)
+          && Objects.equals(avatars, accountInfo.avatars)
+          && Objects.equals(_moreAccounts, accountInfo._moreAccounts)
+          && Objects.equals(status, accountInfo.status);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        _accountId, name, email, secondaryEmails, username, avatars, _moreAccounts, status);
+  }
+
+  protected AccountInfo() {}
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInfo.java
index 3803714..a3750b9 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -36,6 +36,7 @@
   public Timestamp updated;
   public Timestamp submitted;
   public Boolean starred;
+  public Boolean muted;
   public Collection<String> stars;
   public Boolean reviewed;
   public SubmitType submitType;
@@ -44,6 +45,8 @@
   public Integer insertions;
   public Integer deletions;
   public Integer unresolvedCommentCount;
+  public Boolean isPrivate;
+  public Boolean workInProgress;
 
   public int _number;
 
@@ -62,4 +65,5 @@
   public Boolean _moreChanges;
 
   public List<ProblemInfo> problems;
+  public List<PluginDefinedInfo> plugins;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInput.java
index b50bcf3..1552554 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInput.java
@@ -27,6 +27,8 @@
 
   public String topic;
   public ChangeStatus status;
+  public Boolean isPrivate;
+  public Boolean workInProgress;
   public String baseChange;
   public Boolean newBranch;
   public MergeInput merge;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
index e79918f..735b84f 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
@@ -20,6 +20,7 @@
   public String id;
   public String tag;
   public AccountInfo author;
+  public AccountInfo realAuthor;
   public Timestamp date;
   public String message;
   public Integer _revisionNumber;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginConfigInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginConfigInfo.java
index 2d1d840..13fc9ec 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginConfigInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginConfigInfo.java
@@ -19,4 +19,5 @@
 public class PluginConfigInfo {
   public Boolean hasAvatars;
   public List<String> jsResourcePaths;
+  public List<String> htmlResourcePaths;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
new file mode 100644
index 0000000..e6fef0f
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
@@ -0,0 +1,19 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+public class PluginDefinedInfo {
+  public String name;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AuthException.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AuthException.java
index 1d4cda7..0b4f459 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AuthException.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/restapi/AuthException.java
@@ -22,4 +22,12 @@
   public AuthException(String msg) {
     super(msg);
   }
+
+  /**
+   * @param msg message to return to the client.
+   * @param cause cause of this exception.
+   */
+  public AuthException(String msg, Throwable cause) {
+    super(msg, cause);
+  }
 }
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
index 8e503ee..b0429cb 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.gpg;
 
 import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.collect.ImmutableMap;
@@ -24,7 +24,7 @@
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
index 62d0df7..c3dec61 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
@@ -17,8 +17,8 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import org.eclipse.jgit.lib.Repository;
 
@@ -30,7 +30,7 @@
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
 
-  @AssistedInject
+  @Inject
   GerritPushCertificateChecker(
       GerritPublicKeyChecker.Factory keyCheckerFactory,
       GitRepositoryManager repoManager,
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
index 9aa18fe..14a4c6d 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
@@ -21,8 +21,8 @@
 import com.google.gerrit.gpg.server.GpgKey;
 import com.google.gerrit.gpg.server.GpgKeys;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import org.bouncycastle.openpgp.PGPException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -36,7 +36,7 @@
   private final DeleteGpgKey delete;
   private final GpgKey rsrc;
 
-  @AssistedInject
+  @Inject
   GpgKeyApiImpl(GpgKeys.Get get, DeleteGpgKey delete, @Assisted GpgKey rsrc) {
     this.get = get;
     this.delete = delete;
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
index 50bf57b..4e6ea66 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.gpg.server;
 
 import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -23,11 +23,10 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.gpg.PublicKeyStore;
 import com.google.gerrit.gpg.server.DeleteGpgKey.Input;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIdsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -43,7 +42,6 @@
   public static class Input {}
 
   private final Provider<PersonIdent> serverIdent;
-  private final Provider<ReviewDb> db;
   private final Provider<PublicKeyStore> storeProvider;
   private final AccountCache accountCache;
   private final ExternalIdsUpdate.User externalIdsUpdateFactory;
@@ -51,12 +49,10 @@
   @Inject
   DeleteGpgKey(
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
-      Provider<ReviewDb> db,
       Provider<PublicKeyStore> storeProvider,
       AccountCache accountCache,
       ExternalIdsUpdate.User externalIdsUpdateFactory) {
     this.serverIdent = serverIdent;
-    this.db = db;
     this.storeProvider = storeProvider;
     this.accountCache = accountCache;
     this.externalIdsUpdateFactory = externalIdsUpdateFactory;
@@ -70,7 +66,6 @@
     externalIdsUpdateFactory
         .create()
         .delete(
-            db.get(),
             rsrc.getUser().getAccountId(),
             ExternalId.Key.create(
                 SCHEME_GPGKEY, BaseEncoding.base16().encode(key.getFingerprint())));
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java
index 819ad96..678247e 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java
@@ -14,12 +14,10 @@
 
 package com.google.gerrit.gpg.server;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.CharMatcher;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
@@ -36,11 +34,10 @@
 import com.google.gerrit.gpg.GerritPublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyStore;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResource;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -66,23 +63,23 @@
   public static final String MIME_TYPE = "application/pgp-keys";
 
   private final DynamicMap<RestView<GpgKey>> views;
-  private final Provider<ReviewDb> db;
   private final Provider<CurrentUser> self;
   private final Provider<PublicKeyStore> storeProvider;
   private final GerritPublicKeyChecker.Factory checkerFactory;
+  private final ExternalIds externalIds;
 
   @Inject
   GpgKeys(
       DynamicMap<RestView<GpgKey>> views,
-      Provider<ReviewDb> db,
       Provider<CurrentUser> self,
       Provider<PublicKeyStore> storeProvider,
-      GerritPublicKeyChecker.Factory checkerFactory) {
+      GerritPublicKeyChecker.Factory checkerFactory,
+      ExternalIds externalIds) {
     this.views = views;
-    this.db = db;
     this.self = self;
     this.storeProvider = storeProvider;
     this.checkerFactory = checkerFactory;
+    this.externalIds = externalIds;
   }
 
   @Override
@@ -198,16 +195,8 @@
     }
   }
 
-  @VisibleForTesting
-  public static FluentIterable<ExternalId> getGpgExtIds(ReviewDb db, Account.Id accountId)
-      throws OrmException {
-    return FluentIterable.from(
-            ExternalId.from(db.accountExternalIds().byAccount(accountId).toList()))
-        .filter(in -> in.isScheme(SCHEME_GPGKEY));
-  }
-
-  private Iterable<ExternalId> getGpgExtIds(AccountResource rsrc) throws OrmException {
-    return getGpgExtIds(db.get(), rsrc.getUser().getAccountId());
+  private Iterable<ExternalId> getGpgExtIds(AccountResource rsrc) throws IOException {
+    return externalIds.byAccount(rsrc.getUser().getAccountId(), SCHEME_GPGKEY);
   }
 
   private static long keyId(byte[] fp) {
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
index 7b825b1..af4d6bb 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -16,7 +16,7 @@
 
 import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
 import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toList;
 
@@ -40,15 +40,15 @@
 import com.google.gerrit.gpg.PublicKeyStore;
 import com.google.gerrit.gpg.server.PostGpgKeys.Input;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIdsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.mail.send.AddKeySender;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtorm.server.OrmException;
@@ -84,34 +84,34 @@
 
   private final Logger log = LoggerFactory.getLogger(getClass());
   private final Provider<PersonIdent> serverIdent;
-  private final Provider<ReviewDb> db;
   private final Provider<CurrentUser> self;
   private final Provider<PublicKeyStore> storeProvider;
   private final GerritPublicKeyChecker.Factory checkerFactory;
   private final AddKeySender.Factory addKeyFactory;
   private final AccountCache accountCache;
   private final Provider<InternalAccountQuery> accountQueryProvider;
+  private final ExternalIds externalIds;
   private final ExternalIdsUpdate.User externalIdsUpdateFactory;
 
   @Inject
   PostGpgKeys(
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
-      Provider<ReviewDb> db,
       Provider<CurrentUser> self,
       Provider<PublicKeyStore> storeProvider,
       GerritPublicKeyChecker.Factory checkerFactory,
       AddKeySender.Factory addKeyFactory,
       AccountCache accountCache,
       Provider<InternalAccountQuery> accountQueryProvider,
+      ExternalIds externalIds,
       ExternalIdsUpdate.User externalIdsUpdateFactory) {
     this.serverIdent = serverIdent;
-    this.db = db;
     this.self = self;
     this.storeProvider = storeProvider;
     this.checkerFactory = checkerFactory;
     this.addKeyFactory = addKeyFactory;
     this.accountCache = accountCache;
     this.accountQueryProvider = accountQueryProvider;
+    this.externalIds = externalIds;
     this.externalIdsUpdateFactory = externalIdsUpdateFactory;
   }
 
@@ -122,7 +122,7 @@
     GpgKeys.checkVisible(self, rsrc);
 
     Collection<ExternalId> existingExtIds =
-        GpgKeys.getGpgExtIds(db.get(), rsrc.getUser().getAccountId()).toList();
+        externalIds.byAccount(rsrc.getUser().getAccountId(), SCHEME_GPGKEY);
     try (PublicKeyStore store = storeProvider.get()) {
       Set<Fingerprint> toRemove = readKeysToRemove(input, existingExtIds);
       List<PGPPublicKeyRing> newKeys = readKeysToAdd(input, toRemove);
@@ -147,7 +147,7 @@
           toRemove.stream().map(fp -> toExtIdKey(fp.get())).collect(toList());
       externalIdsUpdateFactory
           .create()
-          .replace(db.get(), rsrc.getUser().getAccountId(), extIdKeysToRemove, newExtIds);
+          .replace(rsrc.getUser().getAccountId(), extIdKeysToRemove, newExtIds);
       accountCache.evict(rsrc.getUser().getAccountId());
       return toJson(newKeys, toRemove, store, rsrc.getUser());
     }
diff --git a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
index 862930f..deb0dc4 100644
--- a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
+++ b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
@@ -39,8 +39,8 @@
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIdsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
@@ -223,7 +223,7 @@
   @Test
   public void noExternalIds() throws Exception {
     ExternalIdsUpdate externalIdsUpdate = externalIdsUpdateFactory.create();
-    externalIdsUpdate.deleteAll(db, user.getAccountId());
+    externalIdsUpdate.deleteAll(user.getAccountId());
     reloadUser();
 
     TestKey key = validKeyWithSecondUserId();
@@ -237,7 +237,7 @@
     assertProblems(
         checker.check(key.getPublicKey()), Status.BAD, "Key is not associated with any users");
     externalIdsUpdate.insert(
-        db, ExternalId.create(toExtIdKey(key.getPublicKey()), user.getAccountId()));
+        ExternalId.create(toExtIdKey(key.getPublicKey()), user.getAccountId()));
     reloadUser();
     assertProblems(checker.check(key.getPublicKey()), Status.BAD, "No identities found for user");
   }
@@ -406,7 +406,7 @@
     cb.setCommitter(ident);
     assertThat(store.save(cb)).isAnyOf(NEW, FAST_FORWARD, FORCED);
 
-    externalIdsUpdateFactory.create().insert(db, newExtIds);
+    externalIdsUpdateFactory.create().insert(newExtIds);
     accountCache.evict(user.getAccountId());
   }
 
@@ -432,7 +432,7 @@
   private void addExternalId(String scheme, String id, String email) throws Exception {
     externalIdsUpdateFactory
         .create()
-        .insert(db, ExternalId.createWithEmail(scheme, id, user.getAccountId(), email));
+        .insert(ExternalId.createWithEmail(scheme, id, user.getAccountId(), email));
     reloadUser();
   }
 }
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
index 0de8b68..9149230 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
@@ -136,8 +136,15 @@
 
   public final native boolean starred() /*-{ return this.starred ? true : false; }-*/;
 
+  public final native boolean muted() /*-{ return this.muted ? true : false; }-*/;
+
   public final native boolean reviewed() /*-{ return this.reviewed ? true : false; }-*/;
 
+  public final native boolean isPrivate() /*-{ return this.is_private ? true : false; }-*/;
+
+  public final native boolean
+      isWorkInProgress() /*-{ return this.work_in_progress ? true : false; }-*/;
+
   public final native NativeMap<LabelInfo> allLabels() /*-{ return this.labels; }-*/;
 
   public final native LabelInfo label(String n) /*-{ return this.labels[n]; }-*/;
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GeneralPreferences.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GeneralPreferences.java
index 23e1a93..1dcb284 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GeneralPreferences.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/GeneralPreferences.java
@@ -148,6 +148,9 @@
 
   private native String defaultBaseForMergesRaw() /*-{ return this.default_base_for_merges }-*/;
 
+  public final native boolean
+      publishCommentsOnPush() /*-{ return this.publish_comments_on_push || false }-*/;
+
   public final native JsArray<TopMenuItem> my() /*-{ return this.my; }-*/;
 
   public final native void changesPerPage(int n) /*-{ this.changes_per_page = n }-*/;
@@ -224,6 +227,9 @@
 
   private native void defaultBaseForMergesRaw(String b) /*-{ this.default_base_for_merges = b }-*/;
 
+  public final native void publishCommentsOnPush(
+      boolean p) /*-{ this.publish_comments_on_push = p }-*/;
+
   public final void setMyMenus(List<TopMenuItem> myMenus) {
     initMy();
     for (TopMenuItem n : myMenus) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java
index 9f87672..cb947fe 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java
@@ -131,10 +131,13 @@
     suggestions.add("is:open");
     suggestions.add("is:pending");
     suggestions.add("is:draft");
+    suggestions.add("is:private");
     suggestions.add("is:closed");
     suggestions.add("is:merged");
     suggestions.add("is:abandoned");
     suggestions.add("is:mergeable");
+    suggestions.add("is:ignored");
+    suggestions.add("is:wip");
 
     suggestions.add("status:");
     suggestions.add("status:open");
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/UserPopupPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/UserPopupPanel.java
index 40116af..cb529f4 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/UserPopupPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/UserPopupPanel.java
@@ -52,8 +52,9 @@
       userEmail.setText(account.email());
     }
     if (showSettingsLink) {
-      if (Gerrit.info().auth().switchAccountUrl() != null) {
-        switchAccount.setHref(Gerrit.info().auth().switchAccountUrl());
+      String switchAccountUrl = Gerrit.info().auth().switchAccountUrl();
+      if (switchAccountUrl != null) {
+        switchAccount.setHref(switchAccountUrl.replace("${path}", "/"));
       } else if (Gerrit.info().auth().isDev() || Gerrit.info().auth().isOpenId()) {
         switchAccount.setHref(Gerrit.selfRedirect("/login"));
       } else {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java
index 4a3a5f8..314871e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java
@@ -67,6 +67,8 @@
 
   String signedOffBy();
 
+  String publishCommentsOnPush();
+
   String myMenu();
 
   String myMenuInfo();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties
index 9d87365..d31abdb 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties
@@ -38,6 +38,7 @@
 showLegacycidInChangeTable = Show Change Number In Changes Table
 muteCommonPathPrefixes = Mute Common Path Prefixes In File List
 signedOffBy = Insert Signed-off-by Footer For Inline Edit Changes
+publishCommentsOnPush = Publish Draft Comments When a Change Is Updated by Push
 myMenu = My Menu
 myMenuInfo = \
   Menu items for the 'My' top level menu. \
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPreferencesScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPreferencesScreen.java
index 2edc137..9be15ff 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPreferencesScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPreferencesScreen.java
@@ -55,6 +55,7 @@
   private CheckBox legacycidInChangeTable;
   private CheckBox muteCommonPathPrefixes;
   private CheckBox signedOffBy;
+  private CheckBox publishCommentsOnPush;
   private ListBox maximumPageSize;
   private ListBox dateFormat;
   private ListBox timeFormat;
@@ -161,9 +162,10 @@
     legacycidInChangeTable = new CheckBox(Util.C.showLegacycidInChangeTable());
     muteCommonPathPrefixes = new CheckBox(Util.C.muteCommonPathPrefixes());
     signedOffBy = new CheckBox(Util.C.signedOffBy());
+    publishCommentsOnPush = new CheckBox(Util.C.publishCommentsOnPush());
 
     boolean flashClippy = !UserAgent.hasJavaScriptClipboard() && UserAgent.Flash.isInstalled();
-    final Grid formGrid = new Grid(14 + (flashClippy ? 1 : 0), 2);
+    final Grid formGrid = new Grid(15 + (flashClippy ? 1 : 0), 2);
 
     int row = 0;
 
@@ -223,6 +225,10 @@
     formGrid.setWidget(row, fieldIdx, signedOffBy);
     row++;
 
+    formGrid.setText(row, labelIdx, "");
+    formGrid.setWidget(row, fieldIdx, publishCommentsOnPush);
+    row++;
+
     if (flashClippy) {
       formGrid.setText(row, labelIdx, "");
       formGrid.setWidget(row, fieldIdx, useFlashClipboard);
@@ -257,6 +263,7 @@
     e.listenTo(legacycidInChangeTable);
     e.listenTo(muteCommonPathPrefixes);
     e.listenTo(signedOffBy);
+    e.listenTo(publishCommentsOnPush);
     e.listenTo(diffView);
     e.listenTo(reviewCategoryStrategy);
     e.listenTo(emailStrategy);
@@ -295,6 +302,7 @@
     legacycidInChangeTable.setEnabled(on);
     muteCommonPathPrefixes.setEnabled(on);
     signedOffBy.setEnabled(on);
+    publishCommentsOnPush.setEnabled(on);
     reviewCategoryStrategy.setEnabled(on);
     diffView.setEnabled(on);
     emailStrategy.setEnabled(on);
@@ -320,6 +328,7 @@
     legacycidInChangeTable.setValue(p.legacycidInChangeTable());
     muteCommonPathPrefixes.setValue(p.muteCommonPathPrefixes());
     signedOffBy.setValue(p.signedOffBy());
+    publishCommentsOnPush.setValue(p.publishCommentsOnPush());
     setListBox(
         reviewCategoryStrategy,
         GeneralPreferencesInfo.ReviewCategoryStrategy.NONE,
@@ -412,6 +421,7 @@
     p.legacycidInChangeTable(legacycidInChangeTable.getValue());
     p.muteCommonPathPrefixes(muteCommonPathPrefixes.getValue());
     p.signedOffBy(signedOffBy.getValue());
+    p.publishCommentsOnPush(publishCommentsOnPush.getValue());
     p.reviewCategoryStrategy(
         getListBox(
             reviewCategoryStrategy, ReviewCategoryStrategy.NONE, ReviewCategoryStrategy.values()));
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java
index d7fb072..1aecd08 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java
@@ -75,6 +75,8 @@
 
   String rejectImplicitMerges();
 
+  String enableReviewerByEmail();
+
   String headingMaxObjectSizeLimit();
 
   String headingGroupOptions();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
index 465bcfc..4c7153e 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
@@ -37,6 +37,7 @@
 headingParentProjectName = Rights Inherit From
 parentSuggestions = Parent Suggestion
 columnProjectName = Project Name
+enableReviewerByEmail = Enable adding unregistered users as reviewers and CCs on changes
 
 headingGroupUUID = Group UUID
 headingOwner = Owners
@@ -148,7 +149,8 @@
 	removeReviewer, \
 	submit, \
 	submitAs, \
-	viewDrafts
+	viewDrafts, \
+	viewPrivateChanges
 
 abandon = Abandon
 addPatchSet = Add Patch Set
@@ -174,6 +176,7 @@
 submit = Submit
 submitAs = Submit (On Behalf Of)
 viewDrafts = View Drafts
+viewPrivateChanges = View Private Changes
 
 refErrorEmpty = Reference must be supplied
 refErrorBeginSlash = Reference must not start with '/'
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateProjectScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateProjectScreen.java
index dd46c5c..e6dadaa 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateProjectScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/CreateProjectScreen.java
@@ -111,6 +111,14 @@
     projectsPopup.initPopup(AdminConstants.I.projects(), PageLinks.ADMIN_PROJECTS);
   }
 
+  @Override
+  public void onShowView() {
+    super.onShowView();
+    if (project != null) {
+      project.setFocus(true);
+    }
+  }
+
   private void addCreateProjectPanel() {
     final VerticalPanel fp = new VerticalPanel();
     fp.setStyleName(Gerrit.RESOURCES.css().createProjectPanel());
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
index 3645fb9..2f5caf8 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
@@ -86,6 +86,7 @@
   private ListBox enableSignedPush;
   private ListBox requireSignedPush;
   private ListBox rejectImplicitMerges;
+  private ListBox enableReviewerByEmail;
   private NpTextBox maxObjectSizeLimit;
   private Label effectiveMaxObjectSizeLimit;
   private Map<String, Map<String, HasEnabled>> pluginConfigWidgets;
@@ -191,6 +192,7 @@
     requireChangeID.setEnabled(isOwner);
     rejectImplicitMerges.setEnabled(isOwner);
     maxObjectSizeLimit.setEnabled(isOwner);
+    enableReviewerByEmail.setEnabled(isOwner);
 
     if (pluginConfigWidgets != null) {
       for (Map<String, HasEnabled> widgetMap : pluginConfigWidgets.values()) {
@@ -264,6 +266,10 @@
     saveEnabler.listenTo(rejectImplicitMerges);
     grid.addHtml(AdminConstants.I.rejectImplicitMerges(), rejectImplicitMerges);
 
+    enableReviewerByEmail = newInheritedBooleanBox();
+    saveEnabler.listenTo(enableReviewerByEmail);
+    grid.addHtml(AdminConstants.I.enableReviewerByEmail(), enableReviewerByEmail);
+
     maxObjectSizeLimit = new NpTextBox();
     saveEnabler.listenTo(maxObjectSizeLimit);
     effectiveMaxObjectSizeLimit = new Label();
@@ -395,6 +401,7 @@
       setBool(requireSignedPush, result.requireSignedPush());
     }
     setBool(rejectImplicitMerges, result.rejectImplicitMerges());
+    setBool(enableReviewerByEmail, result.enableReviewerByEmail());
     setSubmitType(result.submitType());
     setState(result.state());
     maxObjectSizeLimit.setText(result.maxObjectSizeLimit().configuredValue());
@@ -665,6 +672,7 @@
         esp,
         rsp,
         getBool(rejectImplicitMerges),
+        getBool(enableReviewerByEmail),
         maxObjectSizeLimit.getText().trim(),
         SubmitType.valueOf(submitType.getValue(submitType.getSelectedIndex())),
         ProjectState.valueOf(state.getValue(state.getSelectedIndex())),
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ApiGlue.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ApiGlue.java
index 1555f56..294fa9b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ApiGlue.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ApiGlue.java
@@ -100,9 +100,9 @@
         var s = new SettingsScreenDefinition(p,m,c);
         (this.settingsScreens[n] || (this.settingsScreens[n]=[])).push(s);
       },
-      panel: function(i,c){this._panel(this.getPluginName(),i,c)},
-      _panel: function(n,i,c){
-        var p = new PanelDefinition(n,c);
+      panel: function(i,c,n){this._panel(this.getPluginName(),i,c,n)},
+      _panel: function(n,i,c,x){
+        var p = new PanelDefinition(n,c,x);
         (this.panels[i] || (this.panels[i]=[])).push(p);
       },
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/DefaultActions.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/DefaultActions.java
index 74668c1..234df60 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/DefaultActions.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/DefaultActions.java
@@ -41,6 +41,11 @@
       @Override
       public void onSuccess(JavaScriptObject in) {
         UiResult result = asUiResult(in);
+        if (result == null) {
+          Gerrit.display(target);
+          return;
+        }
+
         if (result.alert() != null) {
           Window.alert(result.alert());
         }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionPanel.java
index 0873363..6d3dd60 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/ExtensionPanel.java
@@ -22,7 +22,10 @@
 import com.google.gwt.user.client.ui.FlowPanel;
 import com.google.gwt.user.client.ui.SimplePanel;
 import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
@@ -32,13 +35,17 @@
   private final List<Context> contexts;
 
   public ExtensionPanel(GerritUiExtensionPoint extensionPoint) {
-    this.extensionPoint = extensionPoint;
-    this.contexts = create();
+    this(extensionPoint, new ArrayList<String>());
   }
 
-  private List<Context> create() {
+  public ExtensionPanel(GerritUiExtensionPoint extensionPoint, List<String> panelNames) {
+    this.extensionPoint = extensionPoint;
+    this.contexts = create(panelNames);
+  }
+
+  private List<Context> create(List<String> panelNames) {
     List<Context> contexts = new ArrayList<>();
-    for (Definition def : Natives.asList(Definition.get(extensionPoint.name()))) {
+    for (Definition def : getOrderedDefs(panelNames)) {
       SimplePanel p = new SimplePanel();
       add(p);
       contexts.add(Context.create(def, p));
@@ -46,6 +53,42 @@
     return contexts;
   }
 
+  private List<Definition> getOrderedDefs(List<String> panelNames) {
+    if (panelNames == null) {
+      panelNames = Collections.emptyList();
+    }
+    Map<String, List<Definition>> defsOrderedByName = new LinkedHashMap<>();
+    for (String name : panelNames) {
+      defsOrderedByName.put(name, new ArrayList<Definition>());
+    }
+    for (Definition def : Natives.asList(Definition.get(extensionPoint.name()))) {
+      addDef(def, defsOrderedByName);
+    }
+    List<Definition> orderedDefs = new ArrayList<>();
+    for (List<Definition> defList : defsOrderedByName.values()) {
+      orderedDefs.addAll(defList);
+    }
+    return orderedDefs;
+  }
+
+  private static void addDef(Definition def, Map<String, List<Definition>> defsOrderedByName) {
+    String panelName = def.getPanelName();
+    if (panelName.equals(def.getPluginName() + ".undefined")) {
+      /* Handle a partially undefined panel name from the
+      javascript layer by generating a random panel name.
+      This maintains support for panels that do not provide a name. */
+      panelName =
+          def.getPluginName() + "." + Long.toHexString(Double.doubleToLongBits(Math.random()));
+    }
+    if (defsOrderedByName.containsKey(panelName)) {
+      defsOrderedByName.get(panelName).add(def);
+    } else if (defsOrderedByName.containsKey(def.getPluginName())) {
+      defsOrderedByName.get(def.getPluginName()).add(def);
+    } else {
+      defsOrderedByName.put(panelName, Collections.singletonList(def));
+    }
+  }
+
   public void put(GerritUiExtensionPoint.Key key, String value) {
     for (Context ctx : contexts) {
       ctx.put(key.name(), value);
@@ -103,9 +146,10 @@
     static final JavaScriptObject TYPE = init();
 
     private static native JavaScriptObject init() /*-{
-      function PanelDefinition(n, c) {
+      function PanelDefinition(n, c, x) {
         this.pluginName = n;
         this.onLoad = c;
+        this.name = x;
       };
       return PanelDefinition;
     }-*/;
@@ -113,6 +157,10 @@
     static native JsArray<Definition> get(String i) /*-{ return $wnd.Gerrit.panels[i] || [] }-*/;
 
     protected Definition() {}
+
+    public final native String getPanelName() /*-{ return this.pluginName + "." + this.name; }-*/;
+
+    public final native String getPluginName() /*-{ return this.pluginName; }-*/;
   }
 
   static class Context extends JavaScriptObject {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/Plugin.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/Plugin.java
index 29787b8..48a812c1 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/Plugin.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/Plugin.java
@@ -68,7 +68,7 @@
       onAction: function(t,n,c){G._onAction(this.name,t,n,c)},
       screen: function(p,c){G._screen(this.name,p,c)},
       settingsScreen: function(p,m,c){G._settingsScreen(this.name,p,m,c)},
-      panel: function(i,c){G._panel(this.name,i,c)},
+      panel: function(i,c,n){G._panel(this.name,i,c,n)},
 
       url: function (u){return G.url(this._url(u))},
       get: function(u,b){@com.google.gerrit.client.api.ActionContext::get(
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginLoader.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginLoader.java
index 1c59dac..8a479dd 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginLoader.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/api/PluginLoader.java
@@ -28,6 +28,7 @@
 import com.google.gwt.user.client.ui.DialogBox;
 import com.google.gwtexpui.progress.client.ProgressBar;
 import java.util.List;
+import java.util.stream.Collectors;
 
 /** Loads JavaScript plugins with a progress meter visible. */
 public class PluginLoader extends DialogBox {
@@ -38,10 +39,15 @@
     if (plugins == null || plugins.isEmpty()) {
       callback.onSuccess(VoidResult.create());
     } else {
-      self = new PluginLoader(loadTimeout, callback);
-      self.load(plugins);
-      self.startTimers();
-      self.center();
+      plugins = plugins.stream().filter(p -> p.endsWith(".js")).collect(Collectors.toList());
+      if (plugins.isEmpty()) {
+        callback.onSuccess(VoidResult.create());
+      } else {
+        self = new PluginLoader(loadTimeout, callback);
+        self.load(plugins);
+        self.startTimers();
+        self.center();
+      }
     }
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java
index ada28af..b22b79f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java
@@ -48,6 +48,7 @@
     "revert",
     "submit",
     "topic",
+    "private",
     "/",
   };
 
@@ -65,6 +66,9 @@
 
   @UiField Button deleteChange;
 
+  @UiField Button markPrivate;
+  @UiField Button unmarkPrivate;
+
   @UiField Button restore;
   private RestoreAction restoreAction;
 
@@ -122,6 +126,11 @@
       a2b(actions, "restore", restore);
       a2b(actions, "revert", revert);
       a2b(actions, "followup", followUp);
+      if (info.isPrivate()) {
+        a2b(actions, "private", unmarkPrivate);
+      } else {
+        a2b(actions, "private", markPrivate);
+      }
       for (String id : filterNonCore(actions)) {
         add(new ActionButton(info, actions.get(id)));
       }
@@ -192,6 +201,16 @@
     }
   }
 
+  @UiHandler("markPrivate")
+  void onMarkPrivate(@SuppressWarnings("unused") ClickEvent e) {
+    ChangeActions.markPrivate(changeId, markPrivate);
+  }
+
+  @UiHandler("unmarkPrivate")
+  void onUnmarkPrivate(@SuppressWarnings("unused") ClickEvent e) {
+    ChangeActions.unmarkPrivate(changeId, unmarkPrivate);
+  }
+
   @UiHandler("restore")
   void onRestore(@SuppressWarnings("unused") ClickEvent e) {
     if (restoreAction == null) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.ui.xml
index d0e5c3e..60efc8c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.ui.xml
@@ -81,6 +81,12 @@
     <g:Button ui:field='followUp' styleName='' visible='false'>
       <div><ui:msg>Follow-Up</ui:msg></div>
     </g:Button>
+    <g:Button ui:field='markPrivate' styleName='' visible='false'>
+      <div><ui:msg>Mark Private</ui:msg></div>
+    </g:Button>
+    <g:Button ui:field='unmarkPrivate' styleName='' visible='false'>
+      <div><ui:msg>Unmark Private</ui:msg></div>
+    </g:Button>
 
     <g:Button ui:field='submit' styleName='{style.submit}' visible='false'/>
   </g:FlowPanel>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeActions.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeActions.java
index 1be60cc..b8fcab7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeActions.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeActions.java
@@ -37,6 +37,14 @@
     ChangeApi.deleteChange(id.get(), mine(draftButtons));
   }
 
+  static void markPrivate(Change.Id id, Button... draftButtons) {
+    ChangeApi.markPrivate(id.get(), cs(id, draftButtons));
+  }
+
+  static void unmarkPrivate(Change.Id id, Button... draftButtons) {
+    ChangeApi.unmarkPrivate(id.get(), cs(id, draftButtons));
+  }
+
   public static GerritCallback<JavaScriptObject> cs(
       final Change.Id id, final Button... draftButtons) {
     setEnabled(false, draftButtons);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
index 8b699da..6e088c4 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
@@ -198,6 +198,8 @@
   @UiField InlineLabel uploaderName;
 
   @UiField Element statusText;
+  @UiField Element privateText;
+  @UiField Element wipText;
   @UiField Image projectSettings;
   @UiField AnchorElement projectSettingsLink;
   @UiField InlineHyperlink projectDashboard;
@@ -308,8 +310,7 @@
               @Override
               public void onSuccess(final ChangeInfo info) {
                 info.init();
-                addExtensionPoints(info, initCurrentRevision(info));
-
+                initCurrentRevision(info);
                 final RevisionInfo rev = info.revision(revision);
                 CallbackGroup group = new CallbackGroup();
                 loadCommit(rev, group);
@@ -378,7 +379,7 @@
     return resolveRevisionToDisplay(info);
   }
 
-  private void addExtensionPoints(ChangeInfo change, RevisionInfo rev) {
+  private void addExtensionPoints(ChangeInfo change, RevisionInfo rev, Entry result) {
     addExtensionPoint(GerritUiExtensionPoint.CHANGE_SCREEN_HEADER, headerExtension, change, rev);
     addExtensionPoint(
         GerritUiExtensionPoint.CHANGE_SCREEN_HEADER_RIGHT_OF_BUTTONS,
@@ -391,7 +392,12 @@
         change,
         rev);
     addExtensionPoint(
-        GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK, changeExtension, change, rev);
+        GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK,
+        changeExtension,
+        change,
+        rev,
+        result.getExtensionPanelNames(
+            GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK.toString()));
     addExtensionPoint(
         GerritUiExtensionPoint.CHANGE_SCREEN_BELOW_RELATED_INFO_BLOCK,
         relatedExtension,
@@ -407,13 +413,22 @@
   }
 
   private void addExtensionPoint(
-      GerritUiExtensionPoint extensionPoint, Panel p, ChangeInfo change, RevisionInfo rev) {
-    ExtensionPanel extensionPanel = new ExtensionPanel(extensionPoint);
+      GerritUiExtensionPoint extensionPoint,
+      Panel p,
+      ChangeInfo change,
+      RevisionInfo rev,
+      List<String> panelNames) {
+    ExtensionPanel extensionPanel = new ExtensionPanel(extensionPoint, panelNames);
     extensionPanel.putObject(GerritUiExtensionPoint.Key.CHANGE_INFO, change);
     extensionPanel.putObject(GerritUiExtensionPoint.Key.REVISION_INFO, rev);
     p.add(extensionPanel);
   }
 
+  private void addExtensionPoint(
+      GerritUiExtensionPoint extensionPoint, Panel p, ChangeInfo change, RevisionInfo rev) {
+    addExtensionPoint(extensionPoint, p, change, rev, Collections.emptyList());
+  }
+
   private boolean enableSignedPush() {
     return Gerrit.info().receive().enableSignedPush();
   }
@@ -1030,6 +1045,14 @@
             loadRevisionInfo();
           }
         });
+    ConfigInfoCache.get(
+        info.projectNameKey(),
+        new GerritCallback<Entry>() {
+          @Override
+          public void onSuccess(Entry entry) {
+            addExtensionPoints(info, rev, entry);
+          }
+        });
   }
 
   private void updateToken(ChangeInfo info, DiffObject base, RevisionInfo rev) {
@@ -1366,6 +1389,14 @@
       statusText.setInnerText(Util.toLongString(s));
     }
 
+    if (info.isPrivate()) {
+      privateText.setInnerText(Util.C.isPrivate());
+    }
+
+    if (info.isWorkInProgress()) {
+      wipText.setInnerText(Util.C.isWorkInProgress());
+    }
+
     if (Gerrit.isSignedIn()) {
       replyAction =
           new ReplyAction(
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml
index 152b157..09bdc24 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml
@@ -99,6 +99,13 @@
     .statusText {
       font-weight: bold;
     }
+    .privateText {
+      font-weight: bold;
+    }
+
+    .wipText {
+      font-weight: bold;
+    }
 
     div.popdown {
       display: inline-block;
@@ -376,7 +383,9 @@
           <span class='{style.changeId}'>
             <ui:msg>Change <g:Anchor ui:field='permalink' title='Reload the change (Shortcut: R)'>
               <ui:attribute name='title'/>
-            </g:Anchor> - <span ui:field='statusText' class='{style.statusText}'/></ui:msg>
+            </g:Anchor> - <span ui:field='statusText' class='{style.statusText}'/>
+              <span ui:field='privateText' class='{style.privateText}'/>
+              <span ui:field='wipText' class='{style.wipText}'/></ui:msg>
           </span>
           <g:SimplePanel ui:field='headerExtension' styleName='{style.headerExtension}'/>
         </div>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/AccountDashboardScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/AccountDashboardScreen.java
index f37cbc2..f8bda64 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/AccountDashboardScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/AccountDashboardScreen.java
@@ -101,7 +101,7 @@
         + who
         + " -owner:"
         + who
-        + " -star:ignore) OR assignee:"
+        + " -is:ignored) OR assignee:"
         + who
         + ")";
   }
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 f8a9ba1..915867d 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
@@ -121,6 +121,14 @@
     change(id).view("assignee").put(input, cb);
   }
 
+  public static void markPrivate(int id, AsyncCallback<JavaScriptObject> cb) {
+    change(id).view("private").post(PrivateInput.create(), cb);
+  }
+
+  public static void unmarkPrivate(int id, AsyncCallback<JavaScriptObject> cb) {
+    change(id).view("private.delete").post(PrivateInput.create(), cb);
+  }
+
   public static RestApi comments(int id) {
     return call(id, "comments");
   }
@@ -319,6 +327,16 @@
     protected CherryPickInput() {}
   }
 
+  private static class PrivateInput extends JavaScriptObject {
+    static PrivateInput create() {
+      return (PrivateInput) createObject();
+    }
+
+    final native void setMessage(String m) /*-{ this.message = m; }-*/;
+
+    protected PrivateInput() {}
+  }
+
   private static class RebaseInput extends JavaScriptObject {
     final native void setBase(String b) /*-{ this.base = b; }-*/;
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
index ae64ac0..80049df 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
@@ -33,6 +33,10 @@
 
   String notCurrent();
 
+  String isPrivate();
+
+  String isWorkInProgress();
+
   String changeEdit();
 
   String myDashboardTitle();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
index 01921de..8a9f323 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
@@ -7,6 +7,8 @@
 mergeConflict = Merge Conflict
 notCurrent = Not Current
 changeEdit = Change Edit
+isPrivate = (Private)
+isWorkInProgress = (WorkInProgress)
 
 myDashboardTitle = My Reviews
 unknownDashboardTitle = Code Review Dashboard
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java
index adf7cff..055044c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java
@@ -237,9 +237,17 @@
 
     Change.Status status = c.status();
     if (status != Change.Status.NEW) {
-      table.setText(row, C_STATUS, Util.toLongString(status));
+      table.setText(
+          row,
+          C_STATUS,
+          Util.toLongString(status) + (c.isPrivate() ? (" " + Util.C.isPrivate()) : ""));
     } else if (!c.mergeable()) {
-      table.setText(row, C_STATUS, Util.C.changeTableNotMergeable());
+      table.setText(
+          row,
+          C_STATUS,
+          Util.C.changeTableNotMergeable() + (c.isPrivate() ? (" " + Util.C.isPrivate()) : ""));
+    } else if (c.isPrivate()) {
+      table.setText(row, C_STATUS, Util.C.isPrivate());
     }
 
     if (c.owner() != null) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ChunkManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ChunkManager.java
index 953bc87..0091f53 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ChunkManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ChunkManager.java
@@ -68,23 +68,15 @@
     colorLines(cm, LineClassWhere.WRAP, color, line, line + cnt);
   }
 
-  void colorLines(
-      final CodeMirror cm,
-      final LineClassWhere where,
-      final String className,
-      final int start,
-      final int end) {
+  void colorLines(CodeMirror cm, LineClassWhere where, String className, int start, int end) {
     if (start < end) {
       for (int line = start; line < end; line++) {
         cm.addLineClass(line, where, className);
       }
       undo.add(
-          new Runnable() {
-            @Override
-            public void run() {
-              for (int line = start; line < end; line++) {
-                cm.removeLineClass(line, where, className);
-              }
+          () -> {
+            for (int line = start; line < end; line++) {
+              cm.removeLineClass(line, where, className);
             }
           });
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java
index 587dacc..95b88ac 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java
@@ -203,32 +203,26 @@
 
   abstract String getTokenSuffixForActiveLine(CodeMirror cm);
 
-  Runnable signInCallback(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        String token = host.getToken();
-        if (cm.extras().hasActiveLine()) {
-          token += "@" + getTokenSuffixForActiveLine(cm);
-        }
-        Gerrit.doSignIn(token);
+  Runnable signInCallback(CodeMirror cm) {
+    return () -> {
+      String token = host.getToken();
+      if (cm.extras().hasActiveLine()) {
+        token += "@" + getTokenSuffixForActiveLine(cm);
       }
+      Gerrit.doSignIn(token);
     };
   }
 
   abstract void newDraft(CodeMirror cm);
 
-  Runnable newDraftCallback(final CodeMirror cm) {
+  Runnable newDraftCallback(CodeMirror cm) {
     if (!Gerrit.isSignedIn()) {
       return signInCallback(cm);
     }
 
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (cm.extras().hasActiveLine()) {
-          newDraft(cm);
-        }
+    return () -> {
+      if (cm.extras().hasActiveLine()) {
+        newDraft(cm);
       }
     };
   }
@@ -267,52 +261,49 @@
 
   abstract SortedMap<Integer, CommentGroup> getMapForNav(DisplaySide side);
 
-  Runnable commentNav(final CodeMirror src, final Direction dir) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        // Every comment appears in both side maps as a linked pair.
-        // It is only necessary to search one side to find a comment
-        // on either side of the editor pair.
-        SortedMap<Integer, CommentGroup> map = getMapForNav(src.side());
-        int line =
-            src.extras().hasActiveLine() ? src.getLineNumber(src.extras().activeLine()) + 1 : 0;
+  Runnable commentNav(CodeMirror src, Direction dir) {
+    return () -> {
+      // Every comment appears in both side maps as a linked pair.
+      // It is only necessary to search one side to find a comment
+      // on either side of the editor pair.
+      SortedMap<Integer, CommentGroup> map = getMapForNav(src.side());
+      int line =
+          src.extras().hasActiveLine() ? src.getLineNumber(src.extras().activeLine()) + 1 : 0;
 
-        CommentGroup g;
-        if (dir == Direction.NEXT) {
-          map = map.tailMap(line + 1);
+      CommentGroup g;
+      if (dir == Direction.NEXT) {
+        map = map.tailMap(line + 1);
+        if (map.isEmpty()) {
+          return;
+        }
+        g = map.get(map.firstKey());
+        while (g.getBoxCount() == 0) {
+          map = map.tailMap(map.firstKey() + 1);
           if (map.isEmpty()) {
             return;
           }
           g = map.get(map.firstKey());
-          while (g.getBoxCount() == 0) {
-            map = map.tailMap(map.firstKey() + 1);
-            if (map.isEmpty()) {
-              return;
-            }
-            g = map.get(map.firstKey());
-          }
-        } else {
-          map = map.headMap(line);
+        }
+      } else {
+        map = map.headMap(line);
+        if (map.isEmpty()) {
+          return;
+        }
+        g = map.get(map.lastKey());
+        while (g.getBoxCount() == 0) {
+          map = map.headMap(map.lastKey());
           if (map.isEmpty()) {
             return;
           }
           g = map.get(map.lastKey());
-          while (g.getBoxCount() == 0) {
-            map = map.headMap(map.lastKey());
-            if (map.isEmpty()) {
-              return;
-            }
-            g = map.get(map.lastKey());
-          }
         }
-
-        CodeMirror cm = g.getCm();
-        double y = cm.heightAtLine(g.getLine() - 1, "local");
-        cm.setCursor(Pos.create(g.getLine() - 1));
-        cm.scrollToY(y - 0.5 * cm.scrollbarV().getClientHeight());
-        cm.focus();
       }
+
+      CodeMirror cm = g.getCm();
+      double y = cm.heightAtLine(g.getLine() - 1, "local");
+      cm.setCursor(Pos.create(g.getLine() - 1));
+      cm.scrollToY(y - 0.5 * cm.scrollbarV().getClientHeight());
+      cm.focus();
     };
   }
 
@@ -425,26 +416,20 @@
 
   abstract CommentGroup getCommentGroupOnActiveLine(CodeMirror cm);
 
-  Runnable toggleOpenBox(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        CommentGroup group = getCommentGroupOnActiveLine(cm);
-        if (group != null) {
-          group.openCloseLast();
-        }
+  Runnable toggleOpenBox(CodeMirror cm) {
+    return () -> {
+      CommentGroup group = getCommentGroupOnActiveLine(cm);
+      if (group != null) {
+        group.openCloseLast();
       }
     };
   }
 
-  Runnable openCloseAll(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        CommentGroup group = getCommentGroupOnActiveLine(cm);
-        if (group != null) {
-          group.openCloseAll();
-        }
+  Runnable openCloseAll(CodeMirror cm) {
+    return () -> {
+      CommentGroup group = getCommentGroupOnActiveLine(cm);
+      if (group != null) {
+        group.openCloseAll();
       }
     };
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java
index 60a75eb..702383a 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java
@@ -336,7 +336,7 @@
     handlers.clear();
   }
 
-  void registerCmEvents(final CodeMirror cm) {
+  void registerCmEvents(CodeMirror cm) {
     cm.on("cursorActivity", updateActiveLine(cm));
     cm.on("focus", updateActiveLine(cm));
     KeyMap keyMap =
@@ -356,170 +356,45 @@
             .on("Shift-O", getCommentManager().openCloseAll(cm))
             .on(
                 "I",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    switch (getIntraLineStatus()) {
-                      case OFF:
-                      case OK:
-                        toggleShowIntraline();
-                        break;
-                      case FAILURE:
-                      case TIMEOUT:
-                      default:
-                        break;
-                    }
+                () -> {
+                  switch (getIntraLineStatus()) {
+                    case OFF:
+                    case OK:
+                      toggleShowIntraline();
+                      break;
+                    case FAILURE:
+                    case TIMEOUT:
+                    default:
+                      break;
                   }
                 })
-            .on(
-                "','",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    prefsAction.show();
-                  }
-                })
-            .on(
-                "Shift-/",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    new ShowHelpCommand().onKeyPress(null);
-                  }
-                })
-            .on(
-                "Space",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    cm.vim().handleKey("<C-d>");
-                  }
-                })
-            .on(
-                "Shift-Space",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    cm.vim().handleKey("<C-u>");
-                  }
-                })
-            .on(
-                "Ctrl-F",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    cm.execCommand("find");
-                  }
-                })
-            .on(
-                "Ctrl-G",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    cm.execCommand("findNext");
-                  }
-                })
+            .on("','", prefsAction::show)
+            .on("Shift-/", () -> new ShowHelpCommand().onKeyPress(null))
+            .on("Space", () -> cm.vim().handleKey("<C-d>"))
+            .on("Shift-Space", () -> cm.vim().handleKey("<C-u>"))
+            .on("Ctrl-F", () -> cm.execCommand("find"))
+            .on("Ctrl-G", () -> cm.execCommand("findNext"))
             .on("Enter", maybeNextCmSearch(cm))
-            .on(
-                "Shift-Ctrl-G",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    cm.execCommand("findPrev");
-                  }
-                })
-            .on(
-                "Shift-Enter",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    cm.execCommand("findPrev");
-                  }
-                })
+            .on("Shift-Ctrl-G", () -> cm.execCommand("findPrev"))
+            .on("Shift-Enter", () -> cm.execCommand("findPrev"))
             .on(
                 "Esc",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    cm.setCursor(cm.getCursor());
-                    cm.execCommand("clearSearch");
-                    cm.vim().handleEx("nohlsearch");
-                  }
+                () -> {
+                  cm.setCursor(cm.getCursor());
+                  cm.execCommand("clearSearch");
+                  cm.vim().handleEx("nohlsearch");
                 })
-            .on(
-                "Ctrl-A",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    cm.execCommand("selectAll");
-                  }
-                })
-            .on(
-                "G O",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    Gerrit.display(PageLinks.toChangeQuery("status:open"));
-                  }
-                })
-            .on(
-                "G M",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    Gerrit.display(PageLinks.toChangeQuery("status:merged"));
-                  }
-                })
-            .on(
-                "G A",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    Gerrit.display(PageLinks.toChangeQuery("status:abandoned"));
-                  }
-                });
+            .on("Ctrl-A", () -> cm.execCommand("selectAll"))
+            .on("G O", () -> Gerrit.display(PageLinks.toChangeQuery("status:open")))
+            .on("G M", () -> Gerrit.display(PageLinks.toChangeQuery("status:merged")))
+            .on("G A", () -> Gerrit.display(PageLinks.toChangeQuery("status:abandoned")));
     if (Gerrit.isSignedIn()) {
       keyMap
-          .on(
-              "G I",
-              new Runnable() {
-                @Override
-                public void run() {
-                  Gerrit.display(PageLinks.MINE);
-                }
-              })
-          .on(
-              "G D",
-              new Runnable() {
-                @Override
-                public void run() {
-                  Gerrit.display(PageLinks.toChangeQuery("owner:self is:draft"));
-                }
-              })
-          .on(
-              "G C",
-              new Runnable() {
-                @Override
-                public void run() {
-                  Gerrit.display(PageLinks.toChangeQuery("has:draft"));
-                }
-              })
-          .on(
-              "G W",
-              new Runnable() {
-                @Override
-                public void run() {
-                  Gerrit.display(PageLinks.toChangeQuery("is:watched status:open"));
-                }
-              })
-          .on(
-              "G S",
-              new Runnable() {
-                @Override
-                public void run() {
-                  Gerrit.display(PageLinks.toChangeQuery("is:starred"));
-                }
-              });
+          .on("G I", () -> Gerrit.display(PageLinks.MINE))
+          .on("G D", () -> Gerrit.display(PageLinks.toChangeQuery("owner:self is:draft")))
+          .on("G C", () -> Gerrit.display(PageLinks.toChangeQuery("has:draft")))
+          .on("G W", () -> Gerrit.display(PageLinks.toChangeQuery("is:watched status:open")))
+          .on("G S", () -> Gerrit.display(PageLinks.toChangeQuery("is:starred")));
     }
 
     if (revision.get() != 0) {
@@ -698,15 +573,12 @@
 
   abstract void setSyntaxHighlighting(boolean b);
 
-  void setContext(final int context) {
+  void setContext(int context) {
     operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            skipManager.removeAll();
-            skipManager.render(context, diff);
-            updateRenderEntireFile();
-          }
+        () -> {
+          skipManager.removeAll();
+          skipManager.render(context, diff);
+          updateRenderEntireFile();
         });
   }
 
@@ -753,21 +625,18 @@
     return line - offset;
   }
 
-  private Runnable openEditScreen(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        LineHandle handle = cm.extras().activeLine();
-        int line = cm.getLineNumber(handle) + 1;
-        if (Patch.COMMIT_MSG.equals(path)) {
-          line = adjustCommitMessageLine(line);
-        }
-        String token = Dispatcher.toEditScreen(revision, path, line);
-        if (!Gerrit.isSignedIn()) {
-          Gerrit.doSignIn(token);
-        } else {
-          Gerrit.display(token);
-        }
+  private Runnable openEditScreen(CodeMirror cm) {
+    return () -> {
+      LineHandle handle = cm.extras().activeLine();
+      int line = cm.getLineNumber(handle) + 1;
+      if (Patch.COMMIT_MSG.equals(path)) {
+        line = adjustCommitMessageLine(line);
+      }
+      String token = Dispatcher.toEditScreen(revision, path, line);
+      if (!Gerrit.isSignedIn()) {
+        Gerrit.doSignIn(token);
+      } else {
+        Gerrit.display(token);
       }
     };
   }
@@ -832,63 +701,51 @@
 
   abstract void operation(Runnable apply);
 
-  private Runnable upToChange(final boolean openReplyBox) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        CallbackGroup group = new CallbackGroup();
-        getCommentManager().saveAllDrafts(group);
-        group.done();
-        group.addListener(
-            new GerritCallback<Void>() {
-              @Override
-              public void onSuccess(Void result) {
-                String rev = String.valueOf(revision.get());
-                Gerrit.display(
-                    PageLinks.toChange(changeId, base.asString(), rev),
-                    new ChangeScreen(changeId, base, rev, openReplyBox, FileTable.Mode.REVIEW));
-              }
-            });
+  private Runnable upToChange(boolean openReplyBox) {
+    return () -> {
+      CallbackGroup group = new CallbackGroup();
+      getCommentManager().saveAllDrafts(group);
+      group.done();
+      group.addListener(
+          new GerritCallback<Void>() {
+            @Override
+            public void onSuccess(Void result) {
+              String rev = String.valueOf(revision.get());
+              Gerrit.display(
+                  PageLinks.toChange(changeId, base.asString(), rev),
+                  new ChangeScreen(changeId, base, rev, openReplyBox, FileTable.Mode.REVIEW));
+            }
+          });
+    };
+  }
+
+  private Runnable maybePrevVimSearch(CodeMirror cm) {
+    return () -> {
+      if (cm.vim().hasSearchHighlight()) {
+        cm.vim().handleKey("N");
+      } else {
+        getCommentManager().commentNav(cm, Direction.NEXT).run();
       }
     };
   }
 
-  private Runnable maybePrevVimSearch(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (cm.vim().hasSearchHighlight()) {
-          cm.vim().handleKey("N");
-        } else {
-          getCommentManager().commentNav(cm, Direction.NEXT).run();
-        }
+  private Runnable maybeNextVimSearch(CodeMirror cm) {
+    return () -> {
+      if (cm.vim().hasSearchHighlight()) {
+        cm.vim().handleKey("n");
+      } else {
+        getChunkManager().diffChunkNav(cm, Direction.NEXT).run();
       }
     };
   }
 
-  private Runnable maybeNextVimSearch(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (cm.vim().hasSearchHighlight()) {
-          cm.vim().handleKey("n");
-        } else {
-          getChunkManager().diffChunkNav(cm, Direction.NEXT).run();
-        }
-      }
-    };
-  }
-
-  Runnable maybeNextCmSearch(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (cm.hasSearchHighlight()) {
-          cm.execCommand("findNext");
-        } else {
-          cm.execCommand("clearSearch");
-          getCommentManager().toggleOpenBox(cm).run();
-        }
+  Runnable maybeNextCmSearch(CodeMirror cm) {
+    return () -> {
+      if (cm.hasSearchHighlight()) {
+        cm.execCommand("findNext");
+      } else {
+        cm.execCommand("clearSearch");
+        getCommentManager().toggleOpenBox(cm).run();
       }
     };
   }
@@ -973,7 +830,7 @@
   }
 
   void reloadDiffInfo() {
-    final int id = ++reloadVersionId;
+    int id = ++reloadVersionId;
     DiffApi.diff(revision, path)
         .base(base.asPatchSetId())
         .wholeFile()
@@ -986,16 +843,13 @@
                 if (id == reloadVersionId && isAttached()) {
                   diff = diffInfo;
                   operation(
-                      new Runnable() {
-                        @Override
-                        public void run() {
-                          skipManager.removeAll();
-                          getChunkManager().reset();
-                          getDiffTable().scrollbar.removeDiffAnnotations();
-                          setShowIntraline(prefs.intralineDifference());
-                          render(diff);
-                          skipManager.render(prefs.context(), diff);
-                        }
+                      () -> {
+                        skipManager.removeAll();
+                        getChunkManager().reset();
+                        getDiffTable().scrollbar.removeDiffAnnotations();
+                        setShowIntraline(prefs.intralineDifference());
+                        render(diff);
+                        skipManager.render(prefs.context(), diff);
                       });
                 }
               }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.java
index a2ffb03f..bf9f9e3 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.java
@@ -318,47 +318,26 @@
   }
 
   Runnable toggleReviewed() {
-    return new Runnable() {
-      @Override
-      public void run() {
-        reviewed.setValue(!reviewed.getValue(), true);
-      }
-    };
+    return () -> reviewed.setValue(!reviewed.getValue(), true);
   }
 
   Runnable navigate(Direction dir) {
     switch (dir) {
       case PREV:
-        return new Runnable() {
-          @Override
-          public void run() {
-            (hasPrev ? prev : up).go();
-          }
-        };
+        return () -> (hasPrev ? prev : up).go();
       case NEXT:
-        return new Runnable() {
-          @Override
-          public void run() {
-            (hasNext ? next : up).go();
-          }
-        };
+        return () -> (hasNext ? next : up).go();
       default:
-        return new Runnable() {
-          @Override
-          public void run() {}
-        };
+        return () -> {};
     }
   }
 
   Runnable reviewedAndNext() {
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (Gerrit.isSignedIn()) {
-          reviewed.setValue(true, true);
-        }
-        navigate(Direction.NEXT).run();
+    return () -> {
+      if (Gerrit.isSignedIn()) {
+        reviewed.setValue(true, true);
       }
+      navigate(Direction.NEXT).run();
     };
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java
index 4d781ea..ed4ac25 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java
@@ -322,13 +322,10 @@
       prefs.tabSize(Math.max(1, Integer.parseInt(v)));
       if (view != null) {
         view.operation(
-            new Runnable() {
-              @Override
-              public void run() {
-                int v = prefs.tabSize();
-                for (CodeMirror cm : view.getCms()) {
-                  cm.setOption("tabSize", v);
-                }
+            () -> {
+              int size = prefs.tabSize();
+              for (CodeMirror cm : view.getCms()) {
+                cm.setOption("tabSize", size);
               }
             });
       }
@@ -341,13 +338,7 @@
     if (v != null && v.length() > 0) {
       prefs.lineLength(Math.max(1, Integer.parseInt(v)));
       if (view != null) {
-        view.operation(
-            new Runnable() {
-              @Override
-              public void run() {
-                view.setLineLength(prefs.lineLength());
-              }
-            });
+        view.operation(() -> view.setLineLength(prefs.lineLength()));
       }
     }
   }
@@ -448,7 +439,7 @@
 
   @UiHandler("mode")
   void onMode(@SuppressWarnings("unused") ChangeEvent e) {
-    final String mode = getSelectedMode();
+    String mode = getSelectedMode();
     prefs.syntaxHighlighting(true);
     syntaxHighlighting.setValue(true, false);
     new ModeInjector()
@@ -461,12 +452,9 @@
                     && Objects.equals(mode, getSelectedMode())
                     && view.isAttached()) {
                   view.operation(
-                      new Runnable() {
-                        @Override
-                        public void run() {
-                          view.getCmFromSide(DisplaySide.A).setOption("mode", mode);
-                          view.getCmFromSide(DisplaySide.B).setOption("mode", mode);
-                        }
+                      () -> {
+                        view.getCmFromSide(DisplaySide.A).setOption("mode", mode);
+                        view.getCmFromSide(DisplaySide.B).setOption("mode", mode);
                       });
                 }
               }
@@ -483,13 +471,10 @@
     prefs.showWhitespaceErrors(e.getValue());
     if (view != null) {
       view.operation(
-          new Runnable() {
-            @Override
-            public void run() {
-              boolean s = prefs.showWhitespaceErrors();
-              for (CodeMirror cm : view.getCms()) {
-                cm.setOption("showTrailingSpace", s);
-              }
+          () -> {
+            boolean s = prefs.showWhitespaceErrors();
+            for (CodeMirror cm : view.getCms()) {
+              cm.setOption("showTrailingSpace", s);
             }
           });
     }
@@ -537,7 +522,7 @@
 
   @UiHandler("theme")
   void onTheme(@SuppressWarnings("unused") ChangeEvent e) {
-    final Theme newTheme = getSelectedTheme();
+    Theme newTheme = getSelectedTheme();
     prefs.theme(newTheme);
     if (view != null) {
       ThemeLoader.loadTheme(
@@ -546,15 +531,12 @@
             @Override
             public void onSuccess(Void result) {
               view.operation(
-                  new Runnable() {
-                    @Override
-                    public void run() {
-                      if (getSelectedTheme() == newTheme && isAttached()) {
-                        String t = newTheme.name().toLowerCase();
-                        view.getCmFromSide(DisplaySide.A).setOption("theme", t);
-                        view.getCmFromSide(DisplaySide.B).setOption("theme", t);
-                        view.setThemeStyles(newTheme.isDark());
-                      }
+                  () -> {
+                    if (getSelectedTheme() == newTheme && isAttached()) {
+                      String t = newTheme.name().toLowerCase();
+                      view.getCmFromSide(DisplaySide.A).setOption("theme", t);
+                      view.getCmFromSide(DisplaySide.B).setOption("theme", t);
+                      view.setThemeStyles(newTheme.isDark());
                     }
                   });
             }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollbarAnnotation.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollbarAnnotation.java
index 6cb9b6a..ecdac46 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollbarAnnotation.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollbarAnnotation.java
@@ -64,12 +64,9 @@
     refresh =
         cmB.on(
             "refresh",
-            new Runnable() {
-              @Override
-              public void run() {
-                if (updateScale()) {
-                  updatePosition();
-                }
+            () -> {
+              if (updateScale()) {
+                updatePosition();
               }
             });
     updateScale();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.java
index 1560597..f2b5fa6 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.java
@@ -102,14 +102,11 @@
     super.onShowView();
 
     operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            resizeCodeMirror();
-            chunkManager.adjustPadding();
-            cmA.refresh();
-            cmB.refresh();
-          }
+        () -> {
+          resizeCodeMirror();
+          chunkManager.adjustPadding();
+          cmA.refresh();
+          cmB.refresh();
         });
     setLineLength(Patch.COMMIT_MSG.equals(path) ? 72 : prefs.lineLength());
     diffTable.refresh();
@@ -183,8 +180,8 @@
     };
   }
 
-  private void display(final CommentsCollections comments) {
-    final DiffInfo diff = getDiff();
+  private void display(CommentsCollections comments) {
+    DiffInfo diff = getDiff();
     setThemeStyles(prefs.theme().isDark());
     setShowIntraline(prefs.intralineDifference());
     if (prefs.showLineNumbers()) {
@@ -209,18 +206,15 @@
     chunkManager = new SideBySideChunkManager(this, cmA, cmB, diffTable.scrollbar);
 
     operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            // Estimate initial CodeMirror height, fixed up in onShowView.
-            int height = Window.getClientHeight() - (Gerrit.getHeaderFooterHeight() + 18);
-            cmA.setHeight(height);
-            cmB.setHeight(height);
+        () -> {
+          // Estimate initial CodeMirror height, fixed up in onShowView.
+          int height = Window.getClientHeight() - (Gerrit.getHeaderFooterHeight() + 18);
+          cmA.setHeight(height);
+          cmB.setHeight(height);
 
-            render(diff);
-            commentManager.render(comments, prefs.expandAllComments());
-            skipManager.render(prefs.context(), diff);
-          }
+          render(diff);
+          commentManager.render(comments, prefs.expandAllComments());
+          skipManager.render(prefs.context(), diff);
         });
 
     registerCmEvents(cmA);
@@ -319,66 +313,52 @@
   }
 
   @Override
-  Runnable updateActiveLine(final CodeMirror cm) {
-    final CodeMirror other = otherCm(cm);
-    return new Runnable() {
-      @Override
-      public void run() {
-        // The rendering of active lines has to be deferred. Reflow
-        // caused by adding and removing styles chokes Firefox when arrow
-        // key (or j/k) is held down. Performance on Chrome is fine
-        // without the deferral.
-        //
-        Scheduler.get()
-            .scheduleDeferred(
-                new ScheduledCommand() {
-                  @Override
-                  public void execute() {
-                    operation(
-                        new Runnable() {
-                          @Override
-                          public void run() {
-                            LineHandle handle =
-                                cm.getLineHandleVisualStart(cm.getCursor("end").line());
-                            if (!cm.extras().activeLine(handle)) {
-                              return;
-                            }
+  Runnable updateActiveLine(CodeMirror cm) {
+    CodeMirror other = otherCm(cm);
+    return () -> {
+      // The rendering of active lines has to be deferred. Reflow
+      // caused by adding and removing styles chokes Firefox when arrow
+      // key (or j/k) is held down. Performance on Chrome is fine
+      // without the deferral.
+      //
+      Scheduler.get()
+          .scheduleDeferred(
+              new ScheduledCommand() {
+                @Override
+                public void execute() {
+                  operation(
+                      () -> {
+                        LineHandle handle = cm.getLineHandleVisualStart(cm.getCursor("end").line());
+                        if (!cm.extras().activeLine(handle)) {
+                          return;
+                        }
 
-                            LineOnOtherInfo info = lineOnOther(cm.side(), cm.getLineNumber(handle));
-                            if (info.isAligned()) {
-                              other.extras().activeLine(other.getLineHandle(info.getLine()));
-                            } else {
-                              other.extras().clearActiveLine();
-                            }
-                          }
-                        });
-                  }
-                });
-      }
+                        LineOnOtherInfo info = lineOnOther(cm.side(), cm.getLineNumber(handle));
+                        if (info.isAligned()) {
+                          other.extras().activeLine(other.getLineHandle(info.getLine()));
+                        } else {
+                          other.extras().clearActiveLine();
+                        }
+                      });
+                }
+              });
     };
   }
 
-  private Runnable moveCursorToSide(final CodeMirror cmSrc, DisplaySide sideDst) {
-    final CodeMirror cmDst = getCmFromSide(sideDst);
+  private Runnable moveCursorToSide(CodeMirror cmSrc, DisplaySide sideDst) {
+    CodeMirror cmDst = getCmFromSide(sideDst);
     if (cmDst == cmSrc) {
-      return new Runnable() {
-        @Override
-        public void run() {}
-      };
+      return () -> {};
     }
 
-    final DisplaySide sideSrc = cmSrc.side();
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (cmSrc.extras().hasActiveLine()) {
-          cmDst.setCursor(
-              Pos.create(
-                  lineOnOther(sideSrc, cmSrc.getLineNumber(cmSrc.extras().activeLine()))
-                      .getLine()));
-        }
-        cmDst.focus();
+    DisplaySide sideSrc = cmSrc.side();
+    return () -> {
+      if (cmSrc.extras().hasActiveLine()) {
+        cmDst.setCursor(
+            Pos.create(
+                lineOnOther(sideSrc, cmSrc.getLineNumber(cmSrc.extras().activeLine())).getLine()));
       }
+      cmDst.focus();
     };
   }
 
@@ -389,20 +369,8 @@
   }
 
   @Override
-  void operation(final Runnable apply) {
-    cmA.operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            cmB.operation(
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    apply.run();
-                  }
-                });
-          }
-        });
+  void operation(Runnable apply) {
+    cmA.operation(() -> cmB.operation(apply::run));
   }
 
   @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideChunkManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideChunkManager.java
index a78e59e..cfd4226 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideChunkManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideChunkManager.java
@@ -245,16 +245,13 @@
   }
 
   @Override
-  Runnable diffChunkNav(final CodeMirror cm, final Direction dir) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        int line = cm.extras().hasActiveLine() ? cm.getLineNumber(cm.extras().activeLine()) : 0;
-        int res =
-            Collections.binarySearch(
-                chunks, new DiffChunkInfo(cm.side(), line, 0, false), getDiffChunkComparator());
-        diffChunkNavHelper(chunks, host, res, dir);
-      }
+  Runnable diffChunkNav(CodeMirror cm, Direction dir) {
+    return () -> {
+      int line = cm.extras().hasActiveLine() ? cm.getLineNumber(cm.extras().activeLine()) : 0;
+      int res =
+          Collections.binarySearch(
+              chunks, new DiffChunkInfo(cm.side(), line, 0, false), getDiffChunkComparator());
+      diffChunkNavHelper(chunks, host, res, dir);
     };
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentGroup.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentGroup.java
index 6fcd6c8..c728f6f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentGroup.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentGroup.java
@@ -88,29 +88,26 @@
   void handleRedraw() {
     getLineWidget()
         .onRedraw(
-            new Runnable() {
-              @Override
-              public void run() {
-                if (canComputeHeight() && peers.peek().canComputeHeight()) {
-                  if (getResizeTimer() != null) {
-                    getResizeTimer().cancel();
-                    setResizeTimer(null);
-                  }
-                  adjustPadding(SideBySideCommentGroup.this, peers.peek());
-                } else if (getResizeTimer() == null) {
-                  setResizeTimer(
-                      new Timer() {
-                        @Override
-                        public void run() {
-                          if (canComputeHeight() && peers.peek().canComputeHeight()) {
-                            cancel();
-                            setResizeTimer(null);
-                            adjustPadding(SideBySideCommentGroup.this, peers.peek());
-                          }
-                        }
-                      });
-                  getResizeTimer().scheduleRepeating(5);
+            () -> {
+              if (canComputeHeight() && peers.peek().canComputeHeight()) {
+                if (getResizeTimer() != null) {
+                  getResizeTimer().cancel();
+                  setResizeTimer(null);
                 }
+                adjustPadding(SideBySideCommentGroup.this, peers.peek());
+              } else if (getResizeTimer() == null) {
+                setResizeTimer(
+                    new Timer() {
+                      @Override
+                      public void run() {
+                        if (canComputeHeight() && peers.peek().canComputeHeight()) {
+                          cancel();
+                          setResizeTimer(null);
+                          adjustPadding(SideBySideCommentGroup.this, peers.peek());
+                        }
+                      }
+                    });
+                getResizeTimer().scheduleRepeating(5);
               }
             });
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.java
index 7465c81..c65dcf0 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.java
@@ -75,12 +75,7 @@
   }
 
   Runnable toggleA() {
-    return new Runnable() {
-      @Override
-      public void run() {
-        setVisibleA(!isVisibleA());
-      }
-    };
+    return () -> setVisibleA(!isVisibleA());
   }
 
   void setVisibleB(boolean show) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipBar.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipBar.java
index 03cfd60..eafb10f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipBar.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipBar.java
@@ -91,12 +91,9 @@
       }
       if (isNew) {
         lineWidget.onFirstRedraw(
-            new Runnable() {
-              @Override
-              public void run() {
-                int w = cm.getGutterElement().getOffsetWidth();
-                getElement().getStyle().setPaddingLeft(w, Unit.PX);
-              }
+            () -> {
+              int w = cm.getGutterElement().getOffsetWidth();
+              getElement().getStyle().setPaddingLeft(w, Unit.PX);
             });
       }
     }
@@ -110,14 +107,7 @@
                 .set("inclusiveLeft", true)
                 .set("inclusiveRight", true));
 
-    textMarker.on(
-        "beforeCursorEnter",
-        new Runnable() {
-          @Override
-          public void run() {
-            expandAll();
-          }
-        });
+    textMarker.on("beforeCursorEnter", this::expandAll);
 
     int skipped = end - start + 1;
     if (skipped <= UP_DOWN_THRESHOLD) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.java
index 0f0ba41..8647d68 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.java
@@ -30,7 +30,6 @@
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArrayString;
 import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.core.client.Scheduler.ScheduledCommand;
 import com.google.gwt.dom.client.Element;
 import com.google.gwt.event.dom.client.FocusEvent;
 import com.google.gwt.event.dom.client.FocusHandler;
@@ -102,12 +101,9 @@
     super.onShowView();
 
     operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            resizeCodeMirror();
-            cm.refresh();
-          }
+        () -> {
+          resizeCodeMirror();
+          cm.refresh();
         });
     setLineLength(Patch.COMMIT_MSG.equals(path) ? 72 : prefs.lineLength());
     diffTable.refresh();
@@ -137,18 +133,15 @@
   }
 
   @Override
-  void registerCmEvents(final CodeMirror cm) {
+  void registerCmEvents(CodeMirror cm) {
     super.registerCmEvents(cm);
 
     cm.on(
         "scroll",
-        new Runnable() {
-          @Override
-          public void run() {
-            ScrollInfo si = cm.getScrollInfo();
-            if (autoHideDiffTableHeader) {
-              updateDiffTableHeader(si);
-            }
+        () -> {
+          ScrollInfo si = cm.getScrollInfo();
+          if (autoHideDiffTableHeader) {
+            updateDiffTableHeader(si);
           }
         });
     maybeRegisterRenderEntireFileKeyMap(cm);
@@ -171,8 +164,8 @@
     };
   }
 
-  private void display(final CommentsCollections comments) {
-    final DiffInfo diff = getDiff();
+  private void display(CommentsCollections comments) {
+    DiffInfo diff = getDiff();
     setThemeStyles(prefs.theme().isDark());
     setShowIntraline(prefs.intralineDifference());
     if (prefs.showLineNumbers()) {
@@ -186,17 +179,14 @@
     chunkManager = new UnifiedChunkManager(this, cm, diffTable.scrollbar);
 
     operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            // Estimate initial CodeMirror height, fixed up in onShowView.
-            int height = Window.getClientHeight() - (Gerrit.getHeaderFooterHeight() + 18);
-            cm.setHeight(height);
+        () -> {
+          // Estimate initial CodeMirror height, fixed up in onShowView.
+          int height = Window.getClientHeight() - (Gerrit.getHeaderFooterHeight() + 18);
+          cm.setHeight(height);
 
-            render(diff);
-            commentManager.render(comments, prefs.expandAllComments());
-            skipManager.render(prefs.context(), diff);
-          }
+          render(diff);
+          commentManager.render(comments, prefs.expandAllComments());
+          skipManager.render(prefs.context(), diff);
         });
 
     registerCmEvents(cm);
@@ -317,25 +307,19 @@
   }
 
   @Override
-  Runnable updateActiveLine(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        // The rendering of active lines has to be deferred. Reflow
-        // caused by adding and removing styles chokes Firefox when arrow
-        // key (or j/k) is held down. Performance on Chrome is fine
-        // without the deferral.
-        //
-        Scheduler.get()
-            .scheduleDeferred(
-                new ScheduledCommand() {
-                  @Override
-                  public void execute() {
-                    LineHandle handle = cm.getLineHandleVisualStart(cm.getCursor("end").line());
-                    cm.extras().activeLine(handle);
-                  }
-                });
-      }
+  Runnable updateActiveLine(CodeMirror cm) {
+    return () -> {
+      // The rendering of active lines has to be deferred. Reflow
+      // caused by adding and removing styles chokes Firefox when arrow
+      // key (or j/k) is held down. Performance on Chrome is fine
+      // without the deferral.
+      //
+      Scheduler.get()
+          .scheduleDeferred(
+              () -> {
+                LineHandle handle = cm.getLineHandleVisualStart(cm.getCursor("end").line());
+                cm.extras().activeLine(handle);
+              });
     };
   }
 
@@ -354,14 +338,8 @@
   }
 
   @Override
-  void operation(final Runnable apply) {
-    cm.operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            apply.run();
-          }
-        });
+  void operation(Runnable apply) {
+    cm.operation(apply::run);
   }
 
   @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java
index 3939f99..1a662e2 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java
@@ -213,18 +213,15 @@
   }
 
   @Override
-  Runnable diffChunkNav(final CodeMirror cm, final Direction dir) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        int line = cm.extras().hasActiveLine() ? cm.getLineNumber(cm.extras().activeLine()) : 0;
-        int res =
-            Collections.binarySearch(
-                chunks,
-                new UnifiedDiffChunkInfo(cm.side(), 0, 0, line, false),
-                getDiffChunkComparatorCmLine());
-        diffChunkNavHelper(chunks, host, res, dir);
-      }
+  Runnable diffChunkNav(CodeMirror cm, Direction dir) {
+    return () -> {
+      int line = cm.extras().hasActiveLine() ? cm.getLineNumber(cm.extras().activeLine()) : 0;
+      int res =
+          Collections.binarySearch(
+              chunks,
+              new UnifiedDiffChunkInfo(cm.side(), 0, 0, line, false),
+              getDiffChunkComparatorCmLine());
+      diffChunkNavHelper(chunks, host, res, dir);
     };
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentGroup.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentGroup.java
index a6912df..6d5fba3 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentGroup.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentGroup.java
@@ -50,29 +50,26 @@
   void handleRedraw() {
     getLineWidget()
         .onRedraw(
-            new Runnable() {
-              @Override
-              public void run() {
-                if (canComputeHeight()) {
-                  if (getResizeTimer() != null) {
-                    getResizeTimer().cancel();
-                    setResizeTimer(null);
-                  }
-                  reportHeightChange();
-                } else if (getResizeTimer() == null) {
-                  setResizeTimer(
-                      new Timer() {
-                        @Override
-                        public void run() {
-                          if (canComputeHeight()) {
-                            cancel();
-                            setResizeTimer(null);
-                            reportHeightChange();
-                          }
-                        }
-                      });
-                  getResizeTimer().scheduleRepeating(5);
+            () -> {
+              if (canComputeHeight()) {
+                if (getResizeTimer() != null) {
+                  getResizeTimer().cancel();
+                  setResizeTimer(null);
                 }
+                reportHeightChange();
+              } else if (getResizeTimer() == null) {
+                setResizeTimer(
+                    new Timer() {
+                      @Override
+                      public void run() {
+                        if (canComputeHeight()) {
+                          cancel();
+                          setResizeTimer(null);
+                          reportHeightChange();
+                        }
+                      }
+                    });
+                getResizeTimer().scheduleRepeating(5);
               }
             });
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.java
index 511944b..3cf00c9 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.java
@@ -49,7 +49,6 @@
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.core.client.Scheduler.ScheduledCommand;
 import com.google.gwt.dom.client.Element;
 import com.google.gwt.dom.client.Style.Unit;
 import com.google.gwt.event.dom.client.ClickEvent;
@@ -318,12 +317,7 @@
   }
 
   private Runnable gotoLine() {
-    return new Runnable() {
-      @Override
-      public void run() {
-        cmEdit.execCommand("jumpToLine");
-      }
-    };
+    return () -> cmEdit.execCommand("jumpToLine");
   }
 
   @Override
@@ -472,21 +466,9 @@
     cmEdit.setOption(option, value);
   }
 
-  void setTheme(final Theme newTheme) {
-    cmBase.operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            cmBase.setOption("theme", newTheme.name().toLowerCase());
-          }
-        });
-    cmEdit.operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            cmEdit.setOption("theme", newTheme.name().toLowerCase());
-          }
-        });
+  void setTheme(Theme newTheme) {
+    cmBase.operation(() -> cmBase.setOption("theme", newTheme.name().toLowerCase()));
+    cmEdit.operation(() -> cmEdit.setOption("theme", newTheme.name().toLowerCase()));
   }
 
   void setLineLength(int length) {
@@ -504,21 +486,9 @@
     cmEdit.setOption("lineNumbers", show);
   }
 
-  void setShowWhitespaceErrors(final boolean show) {
-    cmBase.operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            cmBase.setOption("showTrailingSpace", show);
-          }
-        });
-    cmEdit.operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            cmEdit.setOption("showTrailingSpace", show);
-          }
-        });
+  void setShowWhitespaceErrors(boolean show) {
+    cmBase.operation(() -> cmBase.setOption("showTrailingSpace", show));
+    cmEdit.operation(() -> cmEdit.setOption("showTrailingSpace", show));
   }
 
   void setShowTabs(boolean show) {
@@ -643,29 +613,13 @@
   }
 
   private Runnable updateCursorPosition() {
-    return new Runnable() {
-      @Override
-      public void run() {
-        // The rendering of active lines has to be deferred. Reflow
-        // caused by adding and removing styles chokes Firefox when arrow
-        // key (or j/k) is held down. Performance on Chrome is fine
-        // without the deferral.
-        //
-        Scheduler.get()
-            .scheduleDeferred(
-                new ScheduledCommand() {
-                  @Override
-                  public void execute() {
-                    cmEdit.operation(
-                        new Runnable() {
-                          @Override
-                          public void run() {
-                            updateActiveLine();
-                          }
-                        });
-                  }
-                });
-      }
+    return () -> {
+      // The rendering of active lines has to be deferred. Reflow
+      // caused by adding and removing styles chokes Firefox when arrow
+      // key (or j/k) is held down. Performance on Chrome is fine
+      // without the deferral.
+      //
+      Scheduler.get().scheduleDeferred(() -> cmEdit.operation(this::updateActiveLine));
     };
   }
 
@@ -683,37 +637,34 @@
   }
 
   private Runnable save() {
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (!cmEdit.isClean(generation)) {
-          close.setEnabled(false);
-          String text = cmEdit.getValue();
-          if (Patch.COMMIT_MSG.equals(path)) {
-            String trimmed = text.trim() + "\r";
-            if (!trimmed.equals(text)) {
-              text = trimmed;
-              cmEdit.setValue(text);
-            }
+    return () -> {
+      if (!cmEdit.isClean(generation)) {
+        close.setEnabled(false);
+        String text = cmEdit.getValue();
+        if (Patch.COMMIT_MSG.equals(path)) {
+          String trimmed = text.trim() + "\r";
+          if (!trimmed.equals(text)) {
+            text = trimmed;
+            cmEdit.setValue(text);
           }
-          final int g = cmEdit.changeGeneration(false);
-          ChangeEditApi.put(
-              revision.getParentKey().get(),
-              path,
-              text,
-              new GerritCallback<VoidResult>() {
-                @Override
-                public void onSuccess(VoidResult result) {
-                  generation = g;
-                  setClean(cmEdit.isClean(g));
-                }
-
-                @Override
-                public void onFailure(final Throwable caught) {
-                  close.setEnabled(true);
-                }
-              });
         }
+        final int g = cmEdit.changeGeneration(false);
+        ChangeEditApi.put(
+            revision.getParentKey().get(),
+            path,
+            text,
+            new GerritCallback<VoidResult>() {
+              @Override
+              public void onSuccess(VoidResult result) {
+                generation = g;
+                setClean(cmEdit.isClean(g));
+              }
+
+              @Override
+              public void onFailure(final Throwable caught) {
+                close.setEnabled(true);
+              }
+            });
       }
     };
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java
index 738319d..b889ff7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java
@@ -57,6 +57,9 @@
   public final native InheritedBooleanInfo rejectImplicitMerges()
       /*-{ return this.reject_implicit_merges; }-*/ ;
 
+  public final native InheritedBooleanInfo enableReviewerByEmail()
+      /*-{ return this.enable_reviewer_by_email; }-*/ ;
+
   public final SubmitType submitType() {
     return SubmitType.valueOf(submitTypeRaw());
   }
@@ -113,6 +116,9 @@
 
   final native ThemeInfo theme() /*-{ return this.theme; }-*/;
 
+  final native NativeMap<JsArrayString>
+      extensionPanelNames() /*-{ return this.extension_panel_names; }-*/;
+
   protected ConfigInfo() {}
 
   static class CommentLinkInfo extends JavaScriptObject {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfoCache.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfoCache.java
index e41cf120..7182b78 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfoCache.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfoCache.java
@@ -16,12 +16,14 @@
 
 import com.google.gerrit.client.changes.ChangeApi;
 import com.google.gerrit.client.info.ChangeInfo;
+import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.CommentLinkProcessor;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.user.client.rpc.AsyncCallback;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 
 /** Cache of {@link ConfigInfo} objects by project name. */
@@ -48,6 +50,10 @@
     public ThemeInfo getTheme() {
       return info.theme();
     }
+
+    public List<String> getExtensionPanelNames(String extensionPoint) {
+      return Natives.asList(info.extensionPanelNames().get(extensionPoint));
+    }
   }
 
   public static void get(Project.NameKey name, AsyncCallback<Entry> cb) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java
index 4be877e..71fa007 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java
@@ -147,6 +147,7 @@
       InheritableBoolean enableSignedPush,
       InheritableBoolean requireSignedPush,
       InheritableBoolean rejectImplicitMerges,
+      InheritableBoolean enableReviewerByEmail,
       String maxObjectSizeLimit,
       SubmitType submitType,
       ProjectState state,
@@ -170,6 +171,7 @@
     in.setSubmitType(submitType);
     in.setState(state);
     in.setPluginConfigValues(pluginConfigValues);
+    in.setEnableReviewerByEmail(enableReviewerByEmail);
 
     project(name).view("config").put(in, cb);
   }
@@ -294,6 +296,13 @@
       setRequireSignedPushRaw(v.name());
     }
 
+    final void setEnableReviewerByEmail(InheritableBoolean v) {
+      setEnableReviewerByEmailRaw(v.name());
+    }
+
+    private native void setEnableReviewerByEmailRaw(String v)
+        /*-{ if(v)this.enable_reviewer_by_email=v; }-*/ ;
+
     private native void setRequireSignedPushRaw(String v)
         /*-{ if(v)this.require_signed_push=v; }-*/ ;
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectMap.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectMap.java
index 4327c07..5ff300d 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectMap.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectMap.java
@@ -44,9 +44,9 @@
         .get(NativeMap.copyKeysIntoChildren(callback));
   }
 
-  public static void suggest(String prefix, int limit, AsyncCallback<ProjectMap> cb) {
+  public static void suggest(String match, int limit, AsyncCallback<ProjectMap> cb) {
     new RestApi("/projects/")
-        .addParameter("p", prefix)
+        .addParameter("m", match)
         .addParameter("n", limit)
         .addParameterRaw("type", "ALL")
         .addParameterTrue("d") // description
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index 9676cd3..f7309ec 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.Provider;
 import com.google.inject.servlet.RequestScoped;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpServlet.java
index 7a5956e..a77f660 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpServlet.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AccessPath;
@@ -34,6 +35,9 @@
 import com.google.gerrit.server.git.VisibleRefFilter;
 import com.google.gerrit.server.git.validators.UploadValidators;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.inject.AbstractModule;
@@ -67,6 +71,7 @@
 import org.eclipse.jgit.transport.PreUploadHook;
 import org.eclipse.jgit.transport.PreUploadHookChain;
 import org.eclipse.jgit.transport.ReceivePack;
+import org.eclipse.jgit.transport.ServiceMayNotContinueException;
 import org.eclipse.jgit.transport.UploadPack;
 import org.eclipse.jgit.transport.resolver.ReceivePackFactory;
 import org.eclipse.jgit.transport.resolver.RepositoryResolver;
@@ -142,18 +147,26 @@
 
   static class Resolver implements RepositoryResolver<HttpServletRequest> {
     private final GitRepositoryManager manager;
-    private final ProjectControl.Factory projectControlFactory;
+    private final PermissionBackend permissionBackend;
+    private final Provider<CurrentUser> userProvider;
+    private final ProjectControl.GenericFactory projectControlFactory;
 
     @Inject
-    Resolver(GitRepositoryManager manager, ProjectControl.Factory projectControlFactory) {
+    Resolver(
+        GitRepositoryManager manager,
+        PermissionBackend permissionBackend,
+        Provider<CurrentUser> userProvider,
+        ProjectControl.GenericFactory projectControlFactory) {
       this.manager = manager;
+      this.permissionBackend = permissionBackend;
+      this.userProvider = userProvider;
       this.projectControlFactory = projectControlFactory;
     }
 
     @Override
     public Repository open(HttpServletRequest req, String projectName)
         throws RepositoryNotFoundException, ServiceNotAuthorizedException,
-            ServiceNotEnabledException {
+            ServiceNotEnabledException, ServiceMayNotContinueException {
       while (projectName.endsWith("/")) {
         projectName = projectName.substring(0, projectName.length() - 1);
       }
@@ -168,28 +181,31 @@
         }
       }
 
-      final ProjectControl pc;
-      try {
-        pc = projectControlFactory.controlFor(new Project.NameKey(projectName));
-      } catch (NoSuchProjectException err) {
-        throw new RepositoryNotFoundException(projectName);
-      }
-
-      CurrentUser user = pc.getUser();
+      CurrentUser user = userProvider.get();
       user.setAccessPath(AccessPath.GIT);
 
-      if (!pc.isVisible()) {
-        if (user instanceof AnonymousUser) {
-          throw new ServiceNotAuthorizedException();
-        }
-        throw new ServiceNotEnabledException();
-      }
-      req.setAttribute(ATT_CONTROL, pc);
-
       try {
-        return manager.openRepository(pc.getProject().getNameKey());
-      } catch (IOException e) {
-        throw new RepositoryNotFoundException(pc.getProject().getNameKey().get(), e);
+        Project.NameKey nameKey = new Project.NameKey(projectName);
+        ProjectControl pc;
+        try {
+          pc = projectControlFactory.controlFor(nameKey, user);
+        } catch (NoSuchProjectException err) {
+          throw new RepositoryNotFoundException(projectName);
+        }
+        req.setAttribute(ATT_CONTROL, pc);
+
+        try {
+          permissionBackend.user(user).project(nameKey).check(ProjectPermission.ACCESS);
+        } catch (AuthException e) {
+          if (user instanceof AnonymousUser) {
+            throw new ServiceNotAuthorizedException();
+          }
+          throw new ServiceNotEnabledException(e.getMessage());
+        }
+
+        return manager.openRepository(nameKey);
+      } catch (IOException | PermissionBackendException err) {
+        throw new ServiceMayNotContinueException(projectName + " unavailable", err);
       }
     }
   }
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 4862a70..47850c4 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
@@ -19,11 +19,15 @@
 import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
 
 import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -57,6 +61,7 @@
   private final Provider<ReviewDb> db;
   private final boolean enabled;
   private final DynamicItem<WebSession> session;
+  private final PermissionBackend permissionBackend;
   private final AccountResolver accountResolver;
 
   @Inject
@@ -64,10 +69,12 @@
       Provider<ReviewDb> db,
       AuthConfig config,
       DynamicItem<WebSession> session,
+      PermissionBackend permissionBackend,
       AccountResolver accountResolver) {
     this.db = db;
     this.enabled = config.isRunAsEnabled();
     this.session = session;
+    this.permissionBackend = permissionBackend;
     this.accountResolver = accountResolver;
   }
 
@@ -85,12 +92,20 @@
       }
 
       CurrentUser self = session.get().getUser();
-      if (!self.getCapabilities().canRunAs()
+      try {
+        if (!self.isIdentifiedUser()) {
           // Always disallow for anonymous users, even if permitted by the ACL,
           // because that would be crazy.
-          || !self.isIdentifiedUser()) {
+          throw new AuthException("denied");
+        }
+        permissionBackend.user(self).check(GlobalPermission.RUN_AS);
+      } catch (AuthException e) {
         replyError(req, res, SC_FORBIDDEN, "not permitted to use " + RUN_AS, null);
         return;
+      } catch (PermissionBackendException e) {
+        log.warn("cannot check runAs", e);
+        replyError(req, res, SC_INTERNAL_SERVER_ERROR, RUN_AS + " unavailable", null);
+        return;
       }
 
       Account target;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java
index f1600bc..e476f15 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 
 public interface WebSession {
   boolean isSignedIn();
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManager.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManager.java
index 59591cc..7884089 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManager.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManager.java
@@ -30,7 +30,7 @@
 
 import com.google.common.cache.Cache;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
index b7c6be3..7f6255a 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.httpd.auth.become;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_UUID;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_UUID;
 
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -30,7 +30,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.gwtorm.server.OrmException;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
index 5a0ed71..3a575a1 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
@@ -17,7 +17,7 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Strings.emptyToNull;
 import static com.google.common.net.HttpHeaders.AUTHORIZATION;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
@@ -26,7 +26,7 @@
 import com.google.gerrit.httpd.RemoteUserUtil;
 import com.google.gerrit.httpd.WebSession;
 import com.google.gerrit.httpd.raw.HostPageServlet;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.gwtjsonrpc.server.RPCServletUtils;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
index 40b543b..3696c21 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.httpd.auth.container;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_EXTERNAL;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_EXTERNAL;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.common.PageLinks;
@@ -27,7 +27,7 @@
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.gwtorm.server.OrmException;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
index f3abf2d..abc7fda 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
@@ -33,6 +33,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.AnonymousUser;
@@ -44,8 +45,10 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.LocalDiskRepositoryManager;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.ssh.SshInfo;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.inject.Inject;
@@ -92,7 +95,8 @@
   private final Path gitwebCgi;
   private final URI gitwebUrl;
   private final LocalDiskRepositoryManager repoManager;
-  private final ProjectControl.Factory projectControl;
+  private final ProjectCache projectCache;
+  private final PermissionBackend permissionBackend;
   private final Provider<AnonymousUser> anonymousUserProvider;
   private final Provider<CurrentUser> userProvider;
   private final EnvList _env;
@@ -100,7 +104,8 @@
   @Inject
   GitwebServlet(
       GitRepositoryManager repoManager,
-      ProjectControl.Factory projectControl,
+      ProjectCache projectCache,
+      PermissionBackend permissionBackend,
       Provider<AnonymousUser> anonymousUserProvider,
       Provider<CurrentUser> userProvider,
       SitePaths site,
@@ -113,7 +118,8 @@
       throw new ProvisionException("Gitweb can only be used with LocalDiskRepositoryManager");
     }
     this.repoManager = (LocalDiskRepositoryManager) repoManager;
-    this.projectControl = projectControl;
+    this.projectCache = projectCache;
+    this.permissionBackend = permissionBackend;
     this.anonymousUserProvider = anonymousUserProvider;
     this.userProvider = userProvider;
     this.gitwebCgi = gitwebCgiConfig.getGitwebCgi();
@@ -402,35 +408,39 @@
       name = name.substring(0, name.length() - 4);
     }
 
-    final Project.NameKey nameKey = new Project.NameKey(name);
-    final ProjectControl project;
+    Project.NameKey nameKey = new Project.NameKey(name);
     try {
-      project = projectControl.validateFor(nameKey);
-      if (!project.allRefsAreVisible() && !project.isOwner()) {
-        // Pretend the project doesn't exist
-        throw new NoSuchProjectException(nameKey);
+      if (projectCache.checkedGet(nameKey) == null) {
+        notFound(req, rsp);
+        return;
       }
-    } catch (NoSuchProjectException e) {
-      if (userProvider.get().isIdentifiedUser()) {
-        rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
-      } else {
-        // Allow anonymous users a chance to login.
-        // Avoid leaking information by not distinguishing between
-        // project not existing and no access rights.
-        rsp.sendRedirect(getLoginRedirectUrl(req));
-      }
+      permissionBackend.user(userProvider).project(nameKey).check(ProjectPermission.READ);
+    } catch (AuthException e) {
+      notFound(req, rsp);
+      return;
+    } catch (IOException | PermissionBackendException err) {
+      log.error("cannot load " + name, err);
+      rsp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
       return;
     }
 
     try (Repository repo = repoManager.openRepository(nameKey)) {
       CacheHeaders.setNotCacheable(rsp);
-      exec(req, rsp, project);
+      exec(req, rsp, nameKey);
     } catch (RepositoryNotFoundException e) {
       getServletContext().log("Cannot open repository", e);
       rsp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
     }
   }
 
+  private void notFound(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
+    if (userProvider.get().isIdentifiedUser()) {
+      rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
+    } else {
+      rsp.sendRedirect(getLoginRedirectUrl(req));
+    }
+  }
+
   private static String getLoginRedirectUrl(HttpServletRequest req) {
     String contextPath = req.getContextPath();
     String loginUrl = contextPath + "/login/";
@@ -462,8 +472,7 @@
     return params;
   }
 
-  private void exec(
-      final HttpServletRequest req, final HttpServletResponse rsp, final ProjectControl project)
+  private void exec(HttpServletRequest req, HttpServletResponse rsp, Project.NameKey project)
       throws IOException {
     final Process proc =
         Runtime.getRuntime()
@@ -512,7 +521,7 @@
     }
   }
 
-  private String[] makeEnv(final HttpServletRequest req, final ProjectControl project) {
+  private String[] makeEnv(HttpServletRequest req, Project.NameKey nameKey) {
     final EnvList env = new EnvList(_env);
     final int contentLength = Math.max(0, req.getContentLength());
 
@@ -551,20 +560,21 @@
     }
 
     env.set("GERRIT_CONTEXT_PATH", req.getContextPath() + "/");
-    env.set("GERRIT_PROJECT_NAME", project.getProject().getName());
+    env.set("GERRIT_PROJECT_NAME", nameKey.get());
 
-    env.set(
-        "GITWEB_PROJECTROOT",
-        repoManager.getBasePath(project.getProject().getNameKey()).toAbsolutePath().toString());
+    env.set("GITWEB_PROJECTROOT", repoManager.getBasePath(nameKey).toAbsolutePath().toString());
 
-    if (project.forUser(anonymousUserProvider.get()).isVisible()) {
+    if (permissionBackend
+        .user(anonymousUserProvider)
+        .project(nameKey)
+        .testOrFalse(ProjectPermission.READ)) {
       env.set("GERRIT_ANONYMOUS_READ", "1");
     }
 
     String remoteUser = null;
-    if (project.getUser().isIdentifiedUser()) {
-      final IdentifiedUser u = project.getUser().asIdentifiedUser();
-      final String user = u.getUserName();
+    if (userProvider.get().isIdentifiedUser()) {
+      IdentifiedUser u = userProvider.get().asIdentifiedUser();
+      String user = u.getUserName();
       env.set("GERRIT_USER_NAME", user);
       if (user != null && !user.isEmpty()) {
         remoteUser = user;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java
index b48caf5..1491345 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.httpd.plugins;
 
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.httpd.resources.Resource;
 import com.google.gerrit.httpd.resources.ResourceKey;
 import com.google.gerrit.httpd.resources.ResourceWeigher;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.plugins.ModuleGenerator;
 import com.google.gerrit.server.plugins.ReloadPluginListener;
@@ -62,5 +64,7 @@
                 .weigher(ResourceWeigher.class);
           }
         });
+
+    DynamicMap.mapOf(binder(), DynamicOptions.DynamicBean.class);
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
index 9730032..039fcb3 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
@@ -64,6 +64,7 @@
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 import java.util.concurrent.ConcurrentMap;
@@ -637,7 +638,11 @@
     Path path = plugin.getSrcFile();
     if (req.getRequestURI().endsWith(getJsPluginPath(plugin)) && Files.exists(path)) {
       res.setHeader("Content-Length", Long.toString(Files.size(path)));
-      res.setContentType("application/javascript");
+      if (path.toString().toLowerCase(Locale.US).endsWith(".html")) {
+        res.setContentType("text/html");
+      } else {
+        res.setContentType("application/javascript");
+      }
       writeToResponse(res, Files.newInputStream(path));
     } else {
       resourceCache.put(key, Resource.NOT_FOUND);
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/CatServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/CatServlet.java
index 7e298aa..298301d 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/CatServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/CatServlet.java
@@ -35,6 +35,7 @@
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.lib.ObjectId;
 
 /**
  * Exports a single version of a patch as a normal file download.
@@ -126,7 +127,7 @@
         try {
           Optional<ChangeEdit> edit = changeEditUtil.byChange(control.getChange());
           if (edit.isPresent()) {
-            revision = edit.get().getRevision().get();
+            revision = ObjectId.toString(edit.get().getEditCommit());
           } else {
             rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
             return;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/HostPageServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/HostPageServlet.java
index 84348d0..93673bc 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/HostPageServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/HostPageServlet.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.gwtjsonrpc.server.JsonServlet;
 import com.google.gwtjsonrpc.server.RPCServletUtils;
@@ -220,7 +221,7 @@
   private DiffPreferencesInfo getDiffPreferences(IdentifiedUser user) {
     try {
       return getDiff.apply(new AccountResource(user));
-    } catch (AuthException | ConfigInvalidException | IOException e) {
+    } catch (AuthException | ConfigInvalidException | IOException | PermissionBackendException e) {
       log.warn("Cannot query account diff preferences", e);
     }
     return DiffPreferencesInfo.defaults();
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ParameterParser.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ParameterParser.java
index ced3121..6960fae 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ParameterParser.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ParameterParser.java
@@ -24,9 +24,11 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.gson.JsonArray;
 import com.google.gson.JsonElement;
@@ -34,6 +36,7 @@
 import com.google.gson.JsonPrimitive;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.inject.Inject;
+import com.google.inject.Injector;
 import java.io.IOException;
 import java.io.StringWriter;
 import java.util.HashSet;
@@ -49,16 +52,27 @@
       ImmutableSet.of("pp", "prettyPrint", "strict", "callback", "alt", "fields");
 
   private final CmdLineParser.Factory parserFactory;
+  private final Injector injector;
+  private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
   @Inject
-  ParameterParser(CmdLineParser.Factory pf) {
+  ParameterParser(
+      CmdLineParser.Factory pf,
+      Injector injector,
+      DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
     this.parserFactory = pf;
+    this.injector = injector;
+    this.dynamicBeans = dynamicBeans;
   }
 
   <T> boolean parse(
       T param, ListMultimap<String, String> in, HttpServletRequest req, HttpServletResponse res)
       throws IOException {
     CmdLineParser clp = parserFactory.create(param);
+    DynamicOptions pluginOptions = new DynamicOptions(param, injector, dynamicBeans);
+    pluginOptions.parseDynamicBeans(clp);
+    pluginOptions.setDynamicBeans();
+    pluginOptions.onBeanParseStart();
     try {
       clp.parseOptionMap(in);
     } catch (CmdLineException | NumberFormatException e) {
@@ -79,6 +93,7 @@
       replyBinaryResult(req, res, BinaryResult.create(msg.toString()).setContentType("text/plain"));
       return false;
     }
+    pluginOptions.onBeanParseEnd();
 
     return true;
   }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index d9dd5d4..05e1698 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.httpd.restapi;
 
 import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS;
 import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS;
 import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS;
@@ -95,8 +96,10 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.OptionUtil;
 import com.google.gerrit.server.OutputFormat;
-import com.google.gerrit.server.account.CapabilityUtils;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.util.http.RequestUtil;
 import com.google.gson.ExclusionStrategy;
 import com.google.gson.FieldAttributes;
@@ -113,12 +116,14 @@
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.TypeLiteral;
 import com.google.inject.util.Providers;
 import java.io.BufferedReader;
 import java.io.BufferedWriter;
 import java.io.EOFException;
 import java.io.FilterOutputStream;
 import java.io.IOException;
+import java.io.InputStream;
 import java.io.OutputStream;
 import java.io.OutputStreamWriter;
 import java.io.Writer;
@@ -144,6 +149,7 @@
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.http.server.ServletUtils;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.util.TemporaryBuffer;
 import org.eclipse.jgit.util.TemporaryBuffer.Heap;
@@ -186,6 +192,7 @@
     final Provider<CurrentUser> currentUser;
     final DynamicItem<WebSession> webSession;
     final Provider<ParameterParser> paramParser;
+    final PermissionBackend permissionBackend;
     final AuditService auditService;
     final RestApiMetrics metrics;
     final Pattern allowOrigin;
@@ -195,12 +202,14 @@
         Provider<CurrentUser> currentUser,
         DynamicItem<WebSession> webSession,
         Provider<ParameterParser> paramParser,
+        PermissionBackend permissionBackend,
         AuditService auditService,
         RestApiMetrics metrics,
         @GerritServerConfig Config cfg) {
       this.currentUser = currentUser;
       this.webSession = webSession;
       this.paramParser = paramParser;
+      this.permissionBackend = permissionBackend;
       this.auditService = auditService;
       this.metrics = metrics;
       allowOrigin = makeAllowOrigin(cfg);
@@ -261,7 +270,10 @@
 
       List<IdString> path = splitPath(req);
       RestCollection<RestResource, RestResource> rc = members.get();
-      CapabilityUtils.checkRequiresCapability(globals.currentUser, null, rc.getClass());
+      globals
+          .permissionBackend
+          .user(globals.currentUser)
+          .checkAny(GlobalPermission.fromAnnotation(rc.getClass()));
 
       viewData = new ViewData(null, null);
 
@@ -371,8 +383,10 @@
         RestModifyView<RestResource, Object> m =
             (RestModifyView<RestResource, Object>) viewData.view;
 
-        inputRequestBody = parseRequest(req, inputType(m));
+        Type type = inputType(m);
+        inputRequestBody = parseRequest(req, type);
         result = m.apply(rsrc, inputRequestBody);
+        consumeRawInputRequestBody(req, type);
       } else {
         throw new ResourceNotFoundException();
       }
@@ -626,64 +640,51 @@
   }
 
   private static Type inputType(RestModifyView<RestResource, Object> m) {
-    Type inputType = extractInputType(m.getClass());
-    if (inputType == null) {
-      throw new IllegalStateException(
-          String.format(
-              "View %s does not correctly implement %s",
-              m.getClass(), RestModifyView.class.getSimpleName()));
-    }
-    return inputType;
-  }
+    // MyModifyView implements RestModifyView<SomeResource, MyInput>
+    TypeLiteral<?> typeLiteral = TypeLiteral.get(m.getClass());
 
-  @SuppressWarnings("rawtypes")
-  private static Type extractInputType(Class clazz) {
-    for (Type t : clazz.getGenericInterfaces()) {
-      if (t instanceof ParameterizedType
-          && ((ParameterizedType) t).getRawType() == RestModifyView.class) {
-        return ((ParameterizedType) t).getActualTypeArguments()[1];
-      }
-    }
+    // RestModifyView<SomeResource, MyInput>
+    // This is smart enough to resolve even when there are intervening subclasses, even if they have
+    // reordered type arguments.
+    TypeLiteral<?> supertypeLiteral = typeLiteral.getSupertype(RestModifyView.class);
 
-    if (clazz.getSuperclass() != null) {
-      Type i = extractInputType(clazz.getSuperclass());
-      if (i != null) {
-        return i;
-      }
-    }
-
-    for (Class t : clazz.getInterfaces()) {
-      Type i = extractInputType(t);
-      if (i != null) {
-        return i;
-      }
-    }
-
-    return null;
+    Type supertype = supertypeLiteral.getType();
+    checkState(
+        supertype instanceof ParameterizedType,
+        "supertype of %s is not parameterized: %s",
+        typeLiteral,
+        supertypeLiteral);
+    return ((ParameterizedType) supertype).getActualTypeArguments()[1];
   }
 
   private Object parseRequest(HttpServletRequest req, Type type)
       throws IOException, BadRequestException, SecurityException, IllegalArgumentException,
           NoSuchMethodException, IllegalAccessException, InstantiationException,
           InvocationTargetException, MethodNotAllowedException {
+    // HTTP/1.1 requires consuming the request body before writing non-error response (less than
+    // 400). Consume the request body for all but raw input request types here.
     if (isType(JSON_TYPE, req.getContentType())) {
       try (BufferedReader br = req.getReader();
           JsonReader json = new JsonReader(br)) {
-        json.setLenient(true);
-
-        JsonToken first;
         try {
-          first = json.peek();
-        } catch (EOFException e) {
-          throw new BadRequestException("Expected JSON object");
+          json.setLenient(true);
+
+          JsonToken first;
+          try {
+            first = json.peek();
+          } catch (EOFException e) {
+            throw new BadRequestException("Expected JSON object");
+          }
+          if (first == JsonToken.STRING) {
+            return parseString(json.nextString(), type);
+          }
+          return OutputFormat.JSON.newGson().fromJson(json, type);
+        } finally {
+          // Reader.close won't consume the rest of the input. Explicitly consume the request body.
+          br.skip(Long.MAX_VALUE);
         }
-        if (first == JsonToken.STRING) {
-          return parseString(json.nextString(), type);
-        }
-        return OutputFormat.JSON.newGson().fromJson(json, type);
       }
-    } else if (("PUT".equals(req.getMethod()) || "POST".equals(req.getMethod()))
-        && acceptsRawInput(type)) {
+    } else if (rawInputRequest(req, type)) {
       return parseRawInput(req, type);
     } else if ("DELETE".equals(req.getMethod()) && hasNoBody(req)) {
       return null;
@@ -706,6 +707,19 @@
     }
   }
 
+  private void consumeRawInputRequestBody(HttpServletRequest req, Type type) throws IOException {
+    if (rawInputRequest(req, type)) {
+      try (InputStream is = req.getInputStream()) {
+        ServletUtils.consumeRequestBody(is);
+      }
+    }
+  }
+
+  private static boolean rawInputRequest(HttpServletRequest req, Type type) {
+    String method = req.getMethod();
+    return ("PUT".equals(method) || "POST".equals(method)) && acceptsRawInput(type);
+  }
+
   private static boolean hasNoBody(HttpServletRequest req) {
     int len = req.getContentLength();
     String type = req.getContentType();
@@ -1083,9 +1097,12 @@
     return "GET".equals(req.getMethod()) || "HEAD".equals(req.getMethod());
   }
 
-  private void checkRequiresCapability(ViewData viewData) throws AuthException {
-    CapabilityUtils.checkRequiresCapability(
-        globals.currentUser, viewData.pluginName, viewData.view.getClass());
+  private void checkRequiresCapability(ViewData d)
+      throws AuthException, PermissionBackendException {
+    globals
+        .permissionBackend
+        .user(globals.currentUser)
+        .checkAny(GlobalPermission.fromAnnotation(d.pluginName, d.view.getClass()));
   }
 
   private static long handleException(
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
index 75026d3..0e6ae84 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ChangeProjectAccess.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectControl;
@@ -89,7 +90,8 @@
       ProjectConfig config,
       MetaDataUpdate md,
       boolean parentProjectUpdate)
-      throws IOException, NoSuchProjectException, ConfigInvalidException {
+      throws IOException, NoSuchProjectException, ConfigInvalidException,
+          PermissionBackendException {
     RevCommit commit = config.commit(md);
 
     gitRefUpdated.fire(
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
index ca23ec2..3b620f1 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessFactory.java
@@ -24,21 +24,27 @@
 import com.google.gerrit.common.data.RefConfigSection;
 import com.google.gerrit.common.data.WebLinkInfoCommon;
 import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.httpd.rpc.Handler;
 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.WebLinks;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.RefControl;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -56,27 +62,32 @@
 
   private final GroupBackend groupBackend;
   private final ProjectCache projectCache;
-  private final ProjectControl.Factory projectControlFactory;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+  private final ProjectControl.GenericFactory projectControlFactory;
   private final GroupControl.Factory groupControlFactory;
   private final MetaDataUpdate.Server metaDataUpdateFactory;
   private final AllProjectsName allProjectsName;
 
   private final Project.NameKey projectName;
-  private ProjectControl pc;
   private WebLinks webLinks;
 
   @Inject
   ProjectAccessFactory(
-      final GroupBackend groupBackend,
-      final ProjectCache projectCache,
-      final ProjectControl.Factory projectControlFactory,
-      final GroupControl.Factory groupControlFactory,
-      final MetaDataUpdate.Server metaDataUpdateFactory,
-      final AllProjectsName allProjectsName,
-      final WebLinks webLinks,
+      GroupBackend groupBackend,
+      ProjectCache projectCache,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      ProjectControl.GenericFactory projectControlFactory,
+      GroupControl.Factory groupControlFactory,
+      MetaDataUpdate.Server metaDataUpdateFactory,
+      AllProjectsName allProjectsName,
+      WebLinks webLinks,
       @Assisted final Project.NameKey name) {
     this.groupBackend = groupBackend;
     this.projectCache = projectCache;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
     this.projectControlFactory = projectControlFactory;
     this.groupControlFactory = groupControlFactory;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
@@ -87,8 +98,10 @@
   }
 
   @Override
-  public ProjectAccess call() throws NoSuchProjectException, IOException, ConfigInvalidException {
-    pc = open();
+  public ProjectAccess call()
+      throws NoSuchProjectException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    ProjectControl pc = checkProjectControl();
 
     // Load the current configuration from the repository, ensuring its the most
     // recent version available. If it differs from what was in the project
@@ -97,16 +110,15 @@
     ProjectConfig config;
     try (MetaDataUpdate md = metaDataUpdateFactory.create(projectName)) {
       config = ProjectConfig.read(md);
-
       if (config.updateGroupNames(groupBackend)) {
         md.setMessage("Update group names\n");
         config.commit(md);
         projectCache.evict(config.getProject());
-        pc = open();
+        pc = checkProjectControl();
       } else if (config.getRevision() != null
           && !config.getRevision().equals(pc.getProjectState().getConfig().getRevision())) {
         projectCache.evict(config.getProject());
-        pc = open();
+        pc = checkProjectControl();
       }
     }
 
@@ -235,9 +247,14 @@
     return Maps.filterEntries(infos, in -> in.getValue() != null);
   }
 
-  private ProjectControl open() throws NoSuchProjectException {
-    return projectControlFactory.validateFor( //
-        projectName, //
-        ProjectControl.OWNER | ProjectControl.VISIBLE);
+  private ProjectControl checkProjectControl()
+      throws NoSuchProjectException, IOException, PermissionBackendException {
+    ProjectControl pc = projectControlFactory.controlFor(projectName, user.get());
+    try {
+      permissionBackend.user(user).project(projectName).check(ProjectPermission.ACCESS);
+    } catch (AuthException e) {
+      throw new NoSuchProjectException(projectName);
+    }
+    return pc;
   }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
index 0d90190..252d023 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.RefPattern;
@@ -95,7 +96,7 @@
   public final T call()
       throws NoSuchProjectException, IOException, ConfigInvalidException, InvalidNameException,
           NoSuchGroupException, OrmException, UpdateParentFailedException,
-          PermissionDeniedException {
+          PermissionDeniedException, PermissionBackendException {
     final ProjectControl projectControl = projectControlFactory.controlFor(projectName);
 
     Capable r = projectControl.canPushToAtLeastOneRef();
@@ -182,7 +183,7 @@
       MetaDataUpdate md,
       boolean parentProjectUpdate)
       throws IOException, NoSuchProjectException, ConfigInvalidException, OrmException,
-          PermissionDeniedException;
+          PermissionDeniedException, PermissionBackendException;
 
   private void replace(ProjectConfig config, Set<String> toDelete, AccessSection section)
       throws NoSuchGroupException {
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
index 9ad1250..1a79d57 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ReviewProjectAccess.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.httpd.rpc.project;
 
+import com.google.common.base.Throwables;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.AccessSection;
@@ -37,7 +38,6 @@
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectControl;
@@ -53,6 +53,7 @@
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
@@ -114,6 +115,9 @@
     this.updateFactory = updateFactory;
   }
 
+  // TODO(dborowitz): Hack MetaDataUpdate so it can be created within a BatchUpdate and we can avoid
+  // calling setUpdateRef(false).
+  @SuppressWarnings("deprecation")
   @Override
   protected Change.Id updateProjectConfig(
       ProjectControl projectControl,
@@ -138,8 +142,9 @@
       return null;
     }
 
-    try (RevWalk rw = new RevWalk(md.getRepository());
-        ObjectInserter objInserter = md.getRepository().newObjectInserter();
+    try (ObjectInserter objInserter = md.getRepository().newObjectInserter();
+        ObjectReader objReader = objInserter.newReader();
+        RevWalk rw = new RevWalk(objReader);
         BatchUpdate bu =
             updateFactory.create(
                 db, config.getProject().getNameKey(), projectControl.getUser(), TimeUtil.nowTs())) {
@@ -147,7 +152,7 @@
       bu.insertChange(
           changeInserterFactory
               .create(changeId, commit, RefNames.REFS_CONFIG)
-              .setValidatePolicy(CommitValidators.Policy.NONE)
+              .setValidate(false)
               .setUpdateRef(false)); // Created by commitToNewRef.
       bu.execute();
     } catch (UpdateException | RestApiException e) {
@@ -173,9 +178,10 @@
       AddReviewerInput input = new AddReviewerInput();
       input.reviewer = projectOwners;
       reviewersProvider.get().apply(rsrc, input);
-    } catch (IOException | OrmException | RestApiException | UpdateException e) {
+    } catch (Exception e) {
       // one of the owner groups is not visible to the user and this it why it
       // can't be added as reviewer
+      Throwables.throwIfUnchecked(e);
     }
   }
 
@@ -192,8 +198,9 @@
         AddReviewerInput input = new AddReviewerInput();
         input.reviewer = r.getGroup().getUUID().get();
         reviewersProvider.get().apply(rsrc, input);
-      } catch (IOException | OrmException | RestApiException | UpdateException e) {
+      } catch (Exception e) {
         // ignore
+        Throwables.throwIfUnchecked(e);
       }
     }
   }
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index 5c3183a..9e375e7 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -20,7 +20,6 @@
 import com.google.common.base.Joiner;
 import com.google.common.collect.Sets;
 import com.google.common.util.concurrent.AbstractFuture;
-import com.google.common.util.concurrent.AsyncFunction;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
@@ -98,7 +97,7 @@
     this.sitePaths = sitePaths;
     this.dir = dir;
     this.name = name;
-    final String index = Joiner.on('_').skipNulls().join(name, subIndex);
+    String index = Joiner.on('_').skipNulls().join(name, subIndex);
     IndexWriter delegateWriter;
     long commitPeriod = writerConfig.getCommitWithinMs();
 
@@ -121,28 +120,25 @@
       @SuppressWarnings("unused") // Error handling within Runnable.
       Future<?> possiblyIgnoredError =
           autoCommitExecutor.scheduleAtFixedRate(
-              new Runnable() {
-                @Override
-                public void run() {
+              () -> {
+                try {
+                  if (autoCommitWriter.hasUncommittedChanges()) {
+                    autoCommitWriter.manualFlush();
+                    autoCommitWriter.commit();
+                  }
+                } catch (IOException e) {
+                  log.error("Error committing " + index + " Lucene index", e);
+                } catch (OutOfMemoryError e) {
+                  log.error("Error committing " + index + " Lucene index", e);
                   try {
-                    if (autoCommitWriter.hasUncommittedChanges()) {
-                      autoCommitWriter.manualFlush();
-                      autoCommitWriter.commit();
-                    }
-                  } catch (IOException e) {
-                    log.error("Error committing " + index + " Lucene index", e);
-                  } catch (OutOfMemoryError e) {
-                    log.error("Error committing " + index + " Lucene index", e);
-                    try {
-                      autoCommitWriter.close();
-                    } catch (IOException e2) {
-                      log.error(
-                          "SEVERE: Error closing "
-                              + index
-                              + " Lucene index after OOM;"
-                              + " index may be corrupted.",
-                          e);
-                    }
+                    autoCommitWriter.close();
+                  } catch (IOException e2) {
+                    log.error(
+                        "SEVERE: Error closing "
+                            + index
+                            + " Lucene index after OOM;"
+                            + " index may be corrupted.",
+                        e);
                   }
                 }
               },
@@ -247,48 +243,27 @@
     }
   }
 
-  ListenableFuture<?> insert(final Document doc) {
-    return submit(
-        new Callable<Long>() {
-          @Override
-          public Long call() throws IOException, InterruptedException {
-            return writer.addDocument(doc);
-          }
-        });
+  ListenableFuture<?> insert(Document doc) {
+    return submit(() -> writer.addDocument(doc));
   }
 
-  ListenableFuture<?> replace(final Term term, final Document doc) {
-    return submit(
-        new Callable<Long>() {
-          @Override
-          public Long call() throws IOException, InterruptedException {
-            return writer.updateDocument(term, doc);
-          }
-        });
+  ListenableFuture<?> replace(Term term, Document doc) {
+    return submit(() -> writer.updateDocument(term, doc));
   }
 
-  ListenableFuture<?> delete(final Term term) {
-    return submit(
-        new Callable<Long>() {
-          @Override
-          public Long call() throws IOException, InterruptedException {
-            return writer.deleteDocuments(term);
-          }
-        });
+  ListenableFuture<?> delete(Term term) {
+    return submit(() -> writer.deleteDocuments(term));
   }
 
   private ListenableFuture<?> submit(Callable<Long> task) {
     ListenableFuture<Long> future = Futures.nonCancellationPropagating(writerThread.submit(task));
     return Futures.transformAsync(
         future,
-        new AsyncFunction<Long, Void>() {
-          @Override
-          public ListenableFuture<Void> apply(Long gen) throws InterruptedException {
-            // Tell the reopen thread a future is waiting on this
-            // generation so it uses the min stale time when refreshing.
-            reopenThread.waitForGeneration(gen, 0);
-            return new NrtFuture(gen);
-          }
+        gen -> {
+          // Tell the reopen thread a future is waiting on this
+          // generation so it uses the min stale time when refreshing.
+          reopenThread.waitForGeneration(gen, 0);
+          return new NrtFuture(gen);
         },
         directExecutor());
   }
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneAccountIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneAccountIndex.java
index fbde7be..be4f917 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneAccountIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneAccountIndex.java
@@ -81,7 +81,7 @@
     if (LuceneIndexModule.isInMemoryTest(cfg)) {
       return new RAMDirectory();
     }
-    Path indexDir = LuceneVersionManager.getDir(sitePaths, ACCOUNTS + "_", schema);
+    Path indexDir = LuceneVersionManager.getDir(sitePaths, ACCOUNTS, schema);
     return FSDirectory.open(indexDir);
   }
 
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 96986a9..dc9f6c1 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -61,9 +61,9 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.OrmRuntimeException;
 import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.nio.file.Path;
 import java.util.ArrayList;
@@ -108,7 +108,7 @@
   static final String UPDATED_SORT_FIELD = sortFieldName(ChangeField.UPDATED);
   static final String ID_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID);
 
-  private static final String CHANGES_PREFIX = "changes_";
+  private static final String CHANGES = "changes";
   private static final String CHANGES_OPEN = "open";
   private static final String CHANGES_CLOSED = "closed";
   private static final String ADDED_FIELD = ChangeField.ADDED.getName();
@@ -121,6 +121,7 @@
   private static final String REF_STATE_PATTERN_FIELD = ChangeField.REF_STATE_PATTERN.getName();
   private static final String REVIEWEDBY_FIELD = ChangeField.REVIEWEDBY.getName();
   private static final String REVIEWER_FIELD = ChangeField.REVIEWER.getName();
+  private static final String REVIEWER_BY_EMAIL_FIELD = ChangeField.REVIEWER_BY_EMAIL.getName();
   private static final String HASHTAG_FIELD = ChangeField.HASHTAG_CASE_AWARE.getName();
   private static final String STAR_FIELD = ChangeField.STAR.getName();
   private static final String SUBMIT_RECORD_LENIENT_FIELD =
@@ -147,7 +148,7 @@
   private final ChangeSubIndex openIndex;
   private final ChangeSubIndex closedIndex;
 
-  @AssistedInject
+  @Inject
   LuceneChangeIndex(
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
@@ -177,7 +178,7 @@
           new ChangeSubIndex(
               schema, sitePaths, new RAMDirectory(), "ramClosed", closedConfig, searcherFactory);
     } else {
-      Path dir = LuceneVersionManager.getDir(sitePaths, CHANGES_PREFIX, schema);
+      Path dir = LuceneVersionManager.getDir(sitePaths, CHANGES, schema);
       openIndex =
           new ChangeSubIndex(
               schema, sitePaths, dir.resolve(CHANGES_OPEN), openConfig, searcherFactory);
@@ -459,6 +460,9 @@
     if (fields.contains(REVIEWER_FIELD)) {
       decodeReviewers(doc, cd);
     }
+    if (fields.contains(REVIEWER_BY_EMAIL_FIELD)) {
+      decodeReviewersByEmail(doc, cd);
+    }
     decodeSubmitRecords(
         doc, SUBMIT_RECORD_STRICT_FIELD, ChangeField.SUBMIT_RULE_OPTIONS_STRICT, cd);
     decodeSubmitRecords(
@@ -555,6 +559,13 @@
             FluentIterable.from(doc.get(REVIEWER_FIELD)).transform(IndexableField::stringValue)));
   }
 
+  private void decodeReviewersByEmail(ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    cd.setReviewersByEmail(
+        ChangeField.parseReviewerByEmailFieldValues(
+            FluentIterable.from(doc.get(REVIEWER_BY_EMAIL_FIELD))
+                .transform(IndexableField::stringValue)));
+  }
+
   private void decodeSubmitRecords(
       ListMultimap<String, IndexableField> doc,
       String field,
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneGroupIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneGroupIndex.java
index c4f10ff..daece8c 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneGroupIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneGroupIndex.java
@@ -80,7 +80,7 @@
     if (LuceneIndexModule.isInMemoryTest(cfg)) {
       return new RAMDirectory();
     }
-    Path indexDir = LuceneVersionManager.getDir(sitePaths, GROUPS + "_", schema);
+    Path indexDir = LuceneVersionManager.getDir(sitePaths, GROUPS, schema);
     return FSDirectory.open(indexDir);
   }
 
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
index 699fd51..89fd819 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
@@ -84,7 +84,7 @@
   IndexConfig getIndexConfig(@GerritServerConfig Config cfg) {
     BooleanQuery.setMaxClauseCount(
         cfg.getInt("index", "maxTerms", BooleanQuery.getMaxClauseCount()));
-    return IndexConfig.fromConfig(cfg);
+    return IndexConfig.fromConfig(cfg).separateChangeSubIndexes(true).build();
   }
 
   private static class MultiVersionModule extends LifecycleModule {
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java
index cda562b..ad13066 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java
@@ -48,8 +48,8 @@
     }
   }
 
-  static Path getDir(SitePaths sitePaths, String prefix, Schema<?> schema) {
-    return sitePaths.index_dir.resolve(String.format("%s%04d", prefix, schema.getVersion()));
+  static Path getDir(SitePaths sitePaths, String name, Schema<?> schema) {
+    return sitePaths.index_dir.resolve(String.format("%s_%04d", name, schema.getVersion()));
   }
 
   @Inject
@@ -74,7 +74,7 @@
     TreeMap<Integer, AbstractVersionManager.Version<V>> versions = new TreeMap<>();
     for (Schema<V> schema : def.getSchemas().values()) {
       // This part is Lucene-specific.
-      Path p = getDir(sitePaths, def.getName() + "_", schema);
+      Path p = getDir(sitePaths, def.getName(), schema);
       boolean isDir = Files.isDirectory(p);
       if (Files.exists(p) && !isDir) {
         log.warn("Not a directory: %s", p.toAbsolutePath());
diff --git a/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java b/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
index c3210ae..68b28a9d 100644
--- a/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
+++ b/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
@@ -32,7 +32,7 @@
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.auth.oauth.OAuthTokenCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
index e862bac..878f9ee 100644
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
+++ b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
@@ -31,7 +31,7 @@
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
index efe8c5f..a3bf361 100644
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
+++ b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
@@ -26,7 +26,7 @@
 import com.google.gerrit.server.UrlEncoded;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.auth.openid.OpenIdProviderPattern;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.ConfigUtil;
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
index 5352904..9c1541c 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
@@ -75,6 +75,7 @@
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
 import com.google.gerrit.server.plugins.PluginRestApiModule;
+import com.google.gerrit.server.project.DefaultPermissionBackendModule;
 import com.google.gerrit.server.schema.DataSourceProvider;
 import com.google.gerrit.server.schema.H2AccountPatchReviewStore;
 import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
@@ -229,12 +230,9 @@
     try {
       start();
       RuntimeShutdown.add(
-          new Runnable() {
-            @Override
-            public void run() {
-              log.info("caught shutdown, cleaning up");
-              stop();
-            }
+          () -> {
+            log.info("caught shutdown, cleaning up");
+            stop();
           });
 
       log.info("Gerrit Code Review " + myVersion() + " ready");
@@ -370,6 +368,7 @@
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new SearchingChangeCacheImpl.Module(slave));
     modules.add(new InternalAccountDirectory.Module());
+    modules.add(new DefaultPermissionBackendModule());
     modules.add(new DefaultCacheFactory.Module());
     modules.add(cfgInjector.getInstance(MailReceiver.Module.class));
     if (emailModule != null) {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Gsql.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Gsql.java
index b1a50d7..004486b 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Gsql.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Gsql.java
@@ -45,16 +45,13 @@
     manager.add(dbInjector);
     manager.start();
     RuntimeShutdown.add(
-        new Runnable() {
-          @Override
-          public void run() {
-            try {
-              System.in.close();
-            } catch (IOException e) {
-              // Ignored
-            }
-            manager.stop();
+        () -> {
+          try {
+            System.in.close();
+          } catch (IOException e) {
+            // Ignored
           }
+          manager.stop();
         });
     final QueryShell shell = shellFactory().create(System.in, System.out);
     shell.setOutputFormat(format);
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
index 7457f40..3da3596 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
@@ -14,16 +14,17 @@
 
 package com.google.gerrit.pgm;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
 import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
 
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.pgm.util.SiteProgram;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIdsBatchUpdate;
+import com.google.gerrit.server.account.externalids.DisabledExternalIdCache;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsBatchUpdate;
 import com.google.gerrit.server.schema.SchemaVersionCheck;
-import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import java.util.Collection;
@@ -35,7 +36,7 @@
   private final LifecycleManager manager = new LifecycleManager();
   private final TextProgressMonitor monitor = new TextProgressMonitor();
 
-  @Inject private SchemaFactory<ReviewDb> database;
+  @Inject private ExternalIds externalIds;
 
   @Inject private ExternalIdsBatchUpdate externalIdsBatchUpdate;
 
@@ -44,19 +45,29 @@
     Injector dbInjector = createDbInjector(MULTI_USER);
     manager.add(dbInjector, dbInjector.createChildInjector(SchemaVersionCheck.module()));
     manager.start();
-    dbInjector.injectMembers(this);
+    dbInjector
+        .createChildInjector(
+            new AbstractModule() {
+              @Override
+              protected void configure() {
+                // The LocalUsernamesToLowerCase program needs to access all external IDs only
+                // once to update them. After the update they are not accessed again. Hence the
+                // LocalUsernamesToLowerCase program doesn't benefit from caching external IDs and
+                // the external ID cache can be disabled.
+                install(DisabledExternalIdCache.module());
+              }
+            })
+        .injectMembers(this);
 
-    try (ReviewDb db = database.open()) {
-      Collection<ExternalId> todo = ExternalId.from(db.accountExternalIds().all().toList());
-      monitor.beginTask("Converting local usernames", todo.size());
+    Collection<ExternalId> todo = externalIds.all();
+    monitor.beginTask("Converting local usernames", todo.size());
 
-      for (ExternalId extId : todo) {
-        convertLocalUserToLowerCase(extId);
-        monitor.update(1);
-      }
-
-      externalIdsBatchUpdate.commit(db, "Convert local usernames to lower case");
+    for (ExternalId extId : todo) {
+      convertLocalUserToLowerCase(extId);
+      monitor.update(1);
     }
+
+    externalIdsBatchUpdate.commit("Convert local usernames to lower case");
     monitor.endTask();
     manager.stop();
     return 0;
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtobufImport.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtobufImport.java
index ad47f0c..d970856 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtobufImport.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtobufImport.java
@@ -85,13 +85,7 @@
     Injector dbInjector = createDbInjector(SINGLE_USER);
     manager.add(dbInjector);
     manager.start();
-    RuntimeShutdown.add(
-        new Runnable() {
-          @Override
-          public void run() {
-            manager.stop();
-          }
-        });
+    RuntimeShutdown.add(manager::stop);
     dbInjector.injectMembers(this);
 
     ProgressMonitor progress = new TextProgressMonitor();
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java
index d77717e..17ce24a 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java
@@ -48,7 +48,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.index.DummyIndexModule;
-import com.google.gerrit.server.index.change.ReindexAfterUpdate;
+import com.google.gerrit.server.index.change.ReindexAfterRefUpdate;
 import com.google.gerrit.server.notedb.ChangeBundleReader;
 import com.google.gerrit.server.notedb.NoteDbUpdateManager;
 import com.google.gerrit.server.notedb.NotesMigration;
@@ -66,7 +66,6 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
-import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -153,18 +152,15 @@
       List<ListenableFuture<Boolean>> futures = new ArrayList<>();
       List<Project.NameKey> projectNames =
           Ordering.usingToString().sortedCopy(changesByProject.keySet());
-      for (final Project.NameKey project : projectNames) {
+      for (Project.NameKey project : projectNames) {
         ListenableFuture<Boolean> future =
             executor.submit(
-                new Callable<Boolean>() {
-                  @Override
-                  public Boolean call() {
-                    try (ReviewDb db = unwrapDb(schemaFactory.open())) {
-                      return rebuildProject(db, changesByProject, project, allUsersRepo);
-                    } catch (Exception e) {
-                      log.error("Error rebuilding project " + project, e);
-                      return false;
-                    }
+                () -> {
+                  try (ReviewDb db = unwrapDb(schemaFactory.open())) {
+                    return rebuildProject(db, changesByProject, project, allUsersRepo);
+                  } catch (Exception e) {
+                    log.error("Error rebuilding project " + project, e);
+                    return false;
                   }
                 });
         futures.add(future);
@@ -216,7 +212,7 @@
           public void configure() {
             install(dbInjector.getInstance(BatchProgramModule.class));
             DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
-                .to(ReindexAfterUpdate.class);
+                .to(ReindexAfterRefUpdate.class);
             install(new DummyIndexModule());
             factory(ChangeResource.Factory.class);
           }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/AccountsOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/AccountsOnInit.java
new file mode 100644
index 0000000..09626d7
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/AccountsOnInit.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2017 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 static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.pgm.init.api.InitFlags;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdentProvider;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.RepositoryCache.FileKey;
+import org.eclipse.jgit.util.FS;
+
+public class AccountsOnInit {
+  private final InitFlags flags;
+  private final SitePaths site;
+  private final String allUsers;
+
+  @Inject
+  public AccountsOnInit(InitFlags flags, SitePaths site, AllUsersNameOnInitProvider allUsers) {
+    this.flags = flags;
+    this.site = site;
+    this.allUsers = allUsers.get();
+  }
+
+  public void insert(ReviewDb db, Account account) throws OrmException, IOException {
+    db.accounts().insert(ImmutableSet.of(account));
+
+    File path = getPath();
+    if (path != null) {
+      try (Repository repo = new FileRepository(path);
+          ObjectInserter oi = repo.newObjectInserter()) {
+        PersonIdent serverIdent = new GerritPersonIdentProvider(flags.cfg).get();
+        AccountsUpdate.createUserBranch(repo, oi, serverIdent, serverIdent, account);
+      }
+    }
+  }
+
+  private File getPath() {
+    Path basePath = site.resolve(flags.cfg.getString("gerrit", null, "basePath"));
+    checkArgument(basePath != null, "gerrit.basePath must be configured");
+    return FileKey.resolve(basePath.resolve(allUsers).toFile(), FS.DETECTED);
+  }
+}
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 ae5a598..56b644a 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
@@ -129,6 +129,7 @@
     init.flags.dev = isDev() && init.site.isNew;
     init.flags.skipPlugins = skipPlugins();
     init.flags.deleteCaches = getDeleteCaches();
+    init.flags.isNew = init.site.isNew;
 
     final SiteRun run;
     try {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
index 86c5f45e..9906089 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
@@ -14,14 +14,11 @@
 
 package com.google.gerrit.pgm.init;
 
-import static com.google.gerrit.server.account.ExternalId.toAccountExternalIds;
-
 import com.google.gerrit.pgm.init.api.InitFlags;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdentProvider;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIds;
-import com.google.gerrit.server.account.ExternalIdsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdReader;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -52,18 +49,17 @@
     this.allUsers = allUsers.get();
   }
 
-  public synchronized void insert(ReviewDb db, String commitMessage, Collection<ExternalId> extIds)
+  public synchronized void insert(String commitMessage, Collection<ExternalId> extIds)
       throws OrmException, IOException, ConfigInvalidException {
-    db.accountExternalIds().insert(toAccountExternalIds(extIds));
 
     File path = getPath();
     if (path != null) {
       try (Repository repo = new FileRepository(path);
           RevWalk rw = new RevWalk(repo);
           ObjectInserter ins = repo.newObjectInserter()) {
-        ObjectId rev = ExternalIds.readRevision(repo);
+        ObjectId rev = ExternalIdReader.readRevision(repo);
 
-        NoteMap noteMap = ExternalIds.readNoteMap(rw, rev);
+        NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
         for (ExternalId extId : extIds) {
           ExternalIdsUpdate.insert(rw, ins, noteMap, extId);
         }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
index 68b2b96..466404b 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -29,7 +29,7 @@
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gwtorm.server.SchemaFactory;
@@ -47,6 +47,7 @@
 public class InitAdminUser implements InitStep {
   private final ConsoleUI ui;
   private final InitFlags flags;
+  private final AccountsOnInit accounts;
   private final VersionedAuthorizedKeysOnInit.Factory authorizedKeysFactory;
   private final ExternalIdsOnInit externalIds;
   private SchemaFactory<ReviewDb> dbFactory;
@@ -56,10 +57,12 @@
   InitAdminUser(
       InitFlags flags,
       ConsoleUI ui,
+      AccountsOnInit accounts,
       VersionedAuthorizedKeysOnInit.Factory authorizedKeysFactory,
       ExternalIdsOnInit externalIds) {
     this.flags = flags;
     this.ui = ui;
+    this.accounts = accounts;
     this.authorizedKeysFactory = authorizedKeysFactory;
     this.externalIds = externalIds;
   }
@@ -101,12 +104,12 @@
           if (email != null) {
             extIds.add(ExternalId.createEmail(id, email));
           }
-          externalIds.insert(db, "Add external IDs for initial admin user", extIds);
+          externalIds.insert("Add external IDs for initial admin user", extIds);
 
           Account a = new Account(id, TimeUtil.nowTs());
           a.setFullName(name);
           a.setPreferredEmail(email);
-          db.accounts().insert(Collections.singleton(a));
+          accounts.insert(db, a);
 
           AccountGroupName adminGroupName =
               db.accountGroupNames().get(new AccountGroup.NameKey("Administrators"));
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitExperimental.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitExperimental.java
new file mode 100644
index 0000000..769cdb4
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitExperimental.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2017 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 static com.google.gerrit.server.notedb.ConfigNotesMigration.SECTION_NOTE_DB;
+import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
+
+import com.google.gerrit.extensions.client.UiType;
+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.notedb.ConfigNotesMigration;
+import com.google.inject.Inject;
+import java.util.Locale;
+import javax.inject.Singleton;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+class InitExperimental implements InitStep {
+  private final ConsoleUI ui;
+  private final InitFlags flags;
+  private final Section noteDbChanges;
+  private final Section gerrit;
+
+  @Inject
+  InitExperimental(ConsoleUI ui, InitFlags flags, Section.Factory sections) {
+    this.ui = ui;
+    this.flags = flags; // Don't grab any flags yet; they aren't initialized until BaseInit#run.
+    this.noteDbChanges = sections.get(SECTION_NOTE_DB, CHANGES.key());
+    this.gerrit = sections.get("gerrit", null);
+  }
+
+  @Override
+  public void run() {
+    ui.header("Experimental features");
+    if (!ui.yesno(false, "Enable any experimental features")) {
+      return;
+    }
+
+    if (flags.isNew) {
+      initNoteDb();
+    }
+    initUis();
+  }
+
+  private void initNoteDb() {
+    ui.message(
+        "Use experimental NoteDb for change metadata?\n"
+            + "  NoteDb is not recommended for production servers."
+            + "  Please familiarize yourself with the documentation:\n"
+            + "  https://gerrit-review.googlesource.com/Documentation/dev-note-db.html\n");
+    if (!ui.yesno(false, "Enable")) {
+      return;
+    }
+
+    Config defaultConfig = ConfigNotesMigration.allEnabledConfig();
+    for (String name : defaultConfig.getNames(SECTION_NOTE_DB, CHANGES.key())) {
+      noteDbChanges.set(name, defaultConfig.getString(SECTION_NOTE_DB, CHANGES.key(), name));
+    }
+  }
+
+  private void initUis() {
+    boolean pg = ui.yesno(true, "Default to PolyGerrit UI");
+    UiType uiType = pg ? UiType.POLYGERRIT : UiType.GWT;
+    gerrit.set("ui", uiType.name().toLowerCase(Locale.US));
+    if (pg) {
+      gerrit.set("enableGwtUi", Boolean.toString(ui.yesno(true, "Enable GWT UI")));
+    }
+  }
+}
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 f6b7e6a..f75d2dc 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
@@ -15,7 +15,6 @@
 package com.google.gerrit.pgm.init;
 
 import com.google.gerrit.extensions.config.FactoryModule;
-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;
@@ -37,7 +36,6 @@
   @Override
   protected void configure() {
     bind(SitePaths.class);
-    bind(InitFlags.class);
     bind(Libraries.class);
     bind(LibraryDownloader.class);
     factory(Section.Factory.class);
@@ -64,6 +62,7 @@
     step().to(InitCache.class);
     step().to(InitPlugins.class);
     step().to(InitDev.class);
+    step().to(InitExperimental.class);
   }
 
   protected LinkedBindingBuilder<InitStep> step() {
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 691243f..7bb1366 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
@@ -31,6 +31,9 @@
   /** Recursively delete the site path if initialization fails. */
   public boolean deleteOnFailure;
 
+  /** Site is being newly created */
+  public boolean isNew;
+
   /** Run the daemon (and open the web UI in a browser) after initialization. */
   public boolean autoStart;
 
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index 5317edf..e625219 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.server.account.GroupCacheImpl;
 import com.google.gerrit.server.account.GroupIncludeCacheImpl;
 import com.google.gerrit.server.account.Realm;
+import com.google.gerrit.server.account.externalids.ExternalIdModule;
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
 import com.google.gerrit.server.change.ChangeJson;
@@ -61,13 +62,13 @@
 import com.google.gerrit.server.notedb.NoteDbModule;
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.patch.PatchListCacheImpl;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.CommentLinkProvider;
+import com.google.gerrit.server.project.DefaultPermissionBackendModule;
 import com.google.gerrit.server.project.ProjectCacheImpl;
-import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SectionSortCache;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryProcessor;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.inject.Inject;
 import com.google.inject.Module;
@@ -112,6 +113,8 @@
     bind(new TypeLiteral<List<CommentLinkInfo>>() {})
         .toProvider(CommentLinkProvider.class)
         .in(SINGLETON);
+    bind(new TypeLiteral<DynamicMap<ChangeQueryProcessor.ChangeAttributeFactory>>() {})
+        .toInstance(DynamicMap.<ChangeQueryProcessor.ChangeAttributeFactory>emptyMap());
     bind(String.class)
         .annotatedWith(CanonicalWebUrl.class)
         .toProvider(CanonicalWebUrlProvider.class);
@@ -141,16 +144,16 @@
     bind(new TypeLiteral<Set<AccountGroup.UUID>>() {})
         .annotatedWith(GitReceivePackGroups.class)
         .toInstance(Collections.<AccountGroup.UUID>emptySet());
-    bind(ChangeControl.Factory.class);
-    factory(ProjectControl.AssistedFactory.class);
 
     install(new BatchGitModule());
+    install(new DefaultPermissionBackendModule());
     install(new DefaultCacheFactory.Module());
+    install(new ExternalIdModule());
     install(new GroupModule());
     install(new NoteDbModule(cfg));
     install(new PrologModule());
     install(AccountByEmailCacheImpl.module());
-    install(AccountCacheImpl.module(false));
+    install(AccountCacheImpl.module());
     install(GroupCacheImpl.module());
     install(GroupIncludeCacheImpl.module());
     install(ProjectCacheImpl.module());
diff --git a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config b/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config
index 26ac9d6..5f73bef 100644
--- a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config
+++ b/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config
@@ -13,15 +13,15 @@
 # limitations under the License.
 
 [library "mysqlDriver"]
-  name = MySQL Connector/J 5.1.41
-  url = https://repo1.maven.org/maven2/mysql/mysql-connector-java/5.1.41/mysql-connector-java-5.1.41.jar
-  sha1 = b0878056f15616989144d6114d36d3942321d0d1
+  name = MySQL Connector/J 5.1.42
+  url = https://repo1.maven.org/maven2/mysql/mysql-connector-java/5.1.42/mysql-connector-java-5.1.42.jar
+  sha1 = 80a448a3ec2178b649bb2e3cb3610fab06e11669
   remove = mysql-connector-java-.*[.]jar
 
 [library "mariadbDriver"]
-  name = MariaDB Connector/J 1.5.9
-  url = https://repo1.maven.org/maven2/org/mariadb/jdbc/mariadb-java-client/1.5.9/mariadb-java-client-1.5.9.jar
-  sha1 = 75d4d6e4cdb9a551a102e92a14c640768174e214
+  name = MariaDB Connector/J 2.0.1
+  url = https://repo1.maven.org/maven2/org/mariadb/jdbc/mariadb-java-client/2.0.1/mariadb-java-client-2.0.1.jar
+  sha1 = 86958da99eb75eeffd33b77ef4ddb508b45bc6da
   remove = mariadb-java-client-.*[.]jar
 
 [library "oracleDriver"]
diff --git a/gerrit-plugin-api/BUILD b/gerrit-plugin-api/BUILD
index 2e768ee..d17bda6 100644
--- a/gerrit-plugin-api/BUILD
+++ b/gerrit-plugin-api/BUILD
@@ -19,7 +19,7 @@
     "//gerrit-extension-api:api",
     "//gerrit-gwtexpui:server",
     "//gerrit-reviewdb:server",
-    "//gerrit-server/src/main/prolog:common",
+    "//gerrit-server:prolog-common",
     "//lib/commons:lang",
     "//lib/commons:lang3",
     "//lib/dropwizard:dropwizard-core",
diff --git a/gerrit-plugin-api/pom.xml b/gerrit-plugin-api/pom.xml
index 8964b94..f9dc7e4 100644
--- a/gerrit-plugin-api/pom.xml
+++ b/gerrit-plugin-api/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-api</artifactId>
-  <version>2.14.1-SNAPSHOT</version>
+  <version>2.15-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
diff --git a/gerrit-plugin-gwtui/pom.xml b/gerrit-plugin-gwtui/pom.xml
index dbfda13..daabb46 100644
--- a/gerrit-plugin-gwtui/pom.xml
+++ b/gerrit-plugin-gwtui/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-gwtui</artifactId>
-  <version>2.14.1-SNAPSHOT</version>
+  <version>2.15-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin GWT UI</name>
   <description>Common Classes for Gerrit GWT UI Plugins</description>
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/Plugin.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/Plugin.java
index 7c478c1..bfcb3d6 100644
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/Plugin.java
+++ b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/Plugin.java
@@ -121,12 +121,15 @@
    *
    * @param extensionPoint the UI extension point for which the panel should be registered.
    * @param entry callback function invoked to create the panel widgets.
+   * @param name the name of the panel which can be used to specify panel ordering via project
+   *     config
    */
-  public void panel(GerritUiExtensionPoint extensionPoint, Panel.EntryPoint entry) {
-    panel(extensionPoint.name(), wrap(entry));
+  public final void panel(
+      GerritUiExtensionPoint extensionPoint, Panel.EntryPoint entry, String name) {
+    panel(extensionPoint.name(), wrap(entry), name);
   }
 
-  private native void panel(String i, JavaScriptObject e) /*-{ this.panel(i, e) }-*/;
+  private native void panel(String i, JavaScriptObject e, String n) /*-{ this.panel(i, e, n) }-*/;
 
   protected Plugin() {}
 
diff --git a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/common/SparseFileContent.java b/gerrit-prettify/src/main/java/com/google/gerrit/prettify/common/SparseFileContent.java
index 1dce0a0..5ff0447 100644
--- a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/common/SparseFileContent.java
+++ b/gerrit-prettify/src/main/java/com/google/gerrit/prettify/common/SparseFileContent.java
@@ -16,13 +16,10 @@
 
 import java.util.ArrayList;
 import java.util.List;
-import org.eclipse.jgit.diff.Edit;
 
 public class SparseFileContent {
-  protected String path;
   protected List<Range> ranges;
   protected int size;
-  protected boolean missingNewlineAtEnd;
 
   private transient int currentRangeIdx;
 
@@ -38,35 +35,6 @@
     size = s;
   }
 
-  public boolean isMissingNewlineAtEnd() {
-    return missingNewlineAtEnd;
-  }
-
-  public void setMissingNewlineAtEnd(final boolean missing) {
-    missingNewlineAtEnd = missing;
-  }
-
-  public String getPath() {
-    return path;
-  }
-
-  public void setPath(String filePath) {
-    path = filePath;
-  }
-
-  public boolean isWholeFile() {
-    if (size == 0) {
-      return true;
-
-    } else if (1 == ranges.size()) {
-      Range r = ranges.get(0);
-      return r.base == 0 && r.end() == size;
-
-    } else {
-      return false;
-    }
-  }
-
   public String get(final int idx) {
     final String line = getLine(idx);
     if (line == null) {
@@ -138,17 +106,6 @@
     return size();
   }
 
-  public int mapIndexToLine(int arrayIndex) {
-    final int origIndex = arrayIndex;
-    for (Range r : ranges) {
-      if (arrayIndex < r.lines.size()) {
-        return r.base + arrayIndex;
-      }
-      arrayIndex -= r.lines.size();
-    }
-    throw new ArrayIndexOutOfBoundsException(origIndex);
-  }
-
   private String getLine(final int idx) {
     // Most requests are sequential in nature, fetching the next
     // line from the current range, or the next range.
@@ -206,58 +163,6 @@
     return ranges.get(ranges.size() - 1);
   }
 
-  public String asString() {
-    final StringBuilder b = new StringBuilder();
-    for (Range r : ranges) {
-      for (String l : r.lines) {
-        b.append(l);
-        b.append('\n');
-      }
-    }
-    if (0 < b.length() && isMissingNewlineAtEnd()) {
-      b.setLength(b.length() - 1);
-    }
-    return b.toString();
-  }
-
-  public SparseFileContent apply(SparseFileContent a, List<Edit> edits) {
-    EditList list = new EditList(edits, size, a.size(), size);
-    ArrayList<String> lines = new ArrayList<>(size);
-    for (final EditList.Hunk hunk : list.getHunks()) {
-      while (hunk.next()) {
-        if (hunk.isContextLine()) {
-          if (contains(hunk.getCurB())) {
-            lines.add(get(hunk.getCurB()));
-          } else {
-            lines.add(a.get(hunk.getCurA()));
-          }
-          hunk.incBoth();
-          continue;
-        }
-
-        if (hunk.isDeletedA()) {
-          hunk.incA();
-        }
-
-        if (hunk.isInsertedB()) {
-          lines.add(get(hunk.getCurB()));
-          hunk.incB();
-        }
-      }
-    }
-
-    Range range = new Range();
-    range.lines = lines;
-
-    SparseFileContent r = new SparseFileContent();
-    r.setSize(lines.size());
-    r.setMissingNewlineAtEnd(isMissingNewlineAtEnd());
-    r.setPath(getPath());
-    r.ranges.add(range);
-
-    return r;
-  }
-
   @Override
   public String toString() {
     final StringBuilder b = new StringBuilder();
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountExternalId.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountExternalId.java
deleted file mode 100644
index 3c8f2fa..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountExternalId.java
+++ /dev/null
@@ -1,186 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.client;
-
-import com.google.gerrit.extensions.client.AuthType;
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.StringKey;
-import java.util.Objects;
-
-/** Association of an external account identifier to a local {@link Account}. */
-public final class AccountExternalId {
-  /**
-   * Scheme used for {@link AuthType#LDAP}, {@link AuthType#CLIENT_SSL_CERT_LDAP}, {@link
-   * AuthType#HTTP_LDAP}, and {@link AuthType#LDAP_BIND} usernames.
-   *
-   * <p>The name {@code gerrit:} was a very poor choice.
-   */
-  public static final String SCHEME_GERRIT = "gerrit:";
-
-  /** Scheme used for randomly created identities constructed by a UUID. */
-  public static final String SCHEME_UUID = "uuid:";
-
-  /** Scheme used to represent only an email address. */
-  public static final String SCHEME_MAILTO = "mailto:";
-
-  /** Scheme for the username used to authenticate an account, e.g. over SSH. */
-  public static final String SCHEME_USERNAME = "username:";
-
-  /** Scheme used for GPG public keys. */
-  public static final String SCHEME_GPGKEY = "gpgkey:";
-
-  /** Scheme for external auth used during authentication, e.g. OAuth Token */
-  public static final String SCHEME_EXTERNAL = "external:";
-
-  public static class Key extends StringKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
-
-    @Column(id = 1)
-    protected String externalId;
-
-    protected Key() {}
-
-    public Key(String scheme, final String identity) {
-      if (!scheme.endsWith(":")) {
-        scheme += ":";
-      }
-      externalId = scheme + identity;
-    }
-
-    public Key(final String e) {
-      externalId = e;
-    }
-
-    @Override
-    public String get() {
-      return externalId;
-    }
-
-    @Override
-    protected void set(String newValue) {
-      externalId = newValue;
-    }
-
-    public String getScheme() {
-      int c = externalId.indexOf(':');
-      return 0 < c ? externalId.substring(0, c) : null;
-    }
-  }
-
-  @Column(id = 1, name = Column.NONE)
-  protected Key key;
-
-  @Column(id = 2)
-  protected Account.Id accountId;
-
-  @Column(id = 3, notNull = false)
-  protected String emailAddress;
-
-  // Encoded version of the hashed and salted password, to be interpreted by the
-  // {@link HashedPassword} class.
-  @Column(id = 4, notNull = false)
-  protected String password;
-
-  /** <i>computed value</i> is this identity trusted by the site administrator? */
-  protected boolean trusted;
-
-  /** <i>computed value</i> can this identity be removed from the account? */
-  protected boolean canDelete;
-
-  protected AccountExternalId() {}
-
-  /**
-   * Create a new binding to an external identity.
-   *
-   * @param who the account this binds to.
-   * @param k the binding key.
-   */
-  public AccountExternalId(final Account.Id who, final AccountExternalId.Key k) {
-    accountId = who;
-    key = k;
-  }
-
-  public AccountExternalId.Key getKey() {
-    return key;
-  }
-
-  /** Get local id of this account, to link with in other entities */
-  public Account.Id getAccountId() {
-    return accountId;
-  }
-
-  public String getExternalId() {
-    return key.externalId;
-  }
-
-  public String getEmailAddress() {
-    return emailAddress;
-  }
-
-  public void setEmailAddress(final String e) {
-    emailAddress = e;
-  }
-
-  public boolean isScheme(final String scheme) {
-    final String id = getExternalId();
-    return id != null && id.startsWith(scheme);
-  }
-
-  public String getSchemeRest() {
-    String scheme = key.getScheme();
-    return null != scheme ? getExternalId().substring(scheme.length() + 1) : null;
-  }
-
-  public void setPassword(String hashed) {
-    password = hashed;
-  }
-
-  public String getPassword() {
-    return password;
-  }
-
-  public boolean isTrusted() {
-    return trusted;
-  }
-
-  public void setTrusted(final boolean t) {
-    trusted = t;
-  }
-
-  public boolean canDelete() {
-    return canDelete;
-  }
-
-  public void setCanDelete(final boolean t) {
-    canDelete = t;
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (o instanceof AccountExternalId) {
-      AccountExternalId extId = (AccountExternalId) o;
-      return Objects.equals(key, extId.key)
-          && Objects.equals(accountId, extId.accountId)
-          && Objects.equals(emailAddress, extId.emailAddress)
-          && Objects.equals(password, extId.password);
-    }
-    return false;
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hash(key, accountId, emailAddress, password);
-  }
-}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
index 9655edd..a101ca0 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
@@ -512,6 +512,14 @@
   @Column(id = 19, notNull = false)
   protected Account.Id assignee;
 
+  /** Whether the change is private. */
+  @Column(id = 20)
+  protected boolean isPrivate;
+
+  /** Whether the change is work in progress. */
+  @Column(id = 21)
+  protected boolean workInProgress;
+
   /** @see com.google.gerrit.server.notedb.NoteDbChangeState */
   @Column(id = 101, notNull = false, length = Integer.MAX_VALUE)
   protected String noteDbState;
@@ -548,6 +556,8 @@
     originalSubject = other.originalSubject;
     submissionId = other.submissionId;
     topic = other.topic;
+    isPrivate = other.isPrivate;
+    workInProgress = other.workInProgress;
     noteDbState = other.noteDbState;
   }
 
@@ -694,6 +704,22 @@
     this.topic = topic;
   }
 
+  public boolean isPrivate() {
+    return isPrivate;
+  }
+
+  public void setPrivate(boolean isPrivate) {
+    this.isPrivate = isPrivate;
+  }
+
+  public boolean isWorkInProgress() {
+    return workInProgress;
+  }
+
+  public void setWorkInProgress(boolean workInProgress) {
+    this.workInProgress = workInProgress;
+  }
+
   public String getNoteDbState() {
     return noteDbState;
   }
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 cadd52c..4b3c652 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
@@ -15,6 +15,7 @@
 package com.google.gerrit.reviewdb.client;
 
 import java.sql.Timestamp;
+import java.util.Comparator;
 import java.util.Objects;
 
 /**
@@ -130,7 +131,13 @@
     }
   }
 
-  public static class Range {
+  public static class Range implements Comparable<Range> {
+    private static final Comparator<Range> RANGE_COMPARATOR =
+        Comparator.<Range>comparingInt(range -> range.startLine)
+            .thenComparingInt(range -> range.startChar)
+            .thenComparingInt(range -> range.endLine)
+            .thenComparingInt(range -> range.endChar);
+
     public int startLine; // 1-based, inclusive
     public int startChar; // 0-based, inclusive
     public int endLine; // 1-based, exclusive
@@ -186,6 +193,11 @@
           .append('}')
           .toString();
     }
+
+    @Override
+    public int compareTo(Range otherRange) {
+      return RANGE_COMPARATOR.compare(this, otherRange);
+    }
   }
 
   public Key key;
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Project.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Project.java
index ba83c58..9918317 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Project.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Project.java
@@ -99,6 +99,8 @@
 
   protected InheritableBoolean rejectImplicitMerges;
 
+  protected InheritableBoolean enableReviewerByEmail;
+
   protected Project() {}
 
   public Project(Project.NameKey nameKey) {
@@ -112,6 +114,7 @@
     createNewChangeForAllNotInTarget = InheritableBoolean.INHERIT;
     enableSignedPush = InheritableBoolean.INHERIT;
     requireSignedPush = InheritableBoolean.INHERIT;
+    enableReviewerByEmail = InheritableBoolean.INHERIT;
   }
 
   public Project.NameKey getNameKey() {
@@ -154,6 +157,14 @@
     return rejectImplicitMerges;
   }
 
+  public InheritableBoolean getEnableReviewerByEmail() {
+    return enableReviewerByEmail;
+  }
+
+  public void setEnableReviewerByEmail(final InheritableBoolean enable) {
+    enableReviewerByEmail = enable;
+  }
+
   public void setUseContributorAgreements(final InheritableBoolean u) {
     useContributorAgreements = u;
   }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
index b892e3d..8407bc6 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
@@ -105,6 +105,17 @@
     return r.toString();
   }
 
+  public static boolean isNoteDbMetaRef(String ref) {
+    if (ref.startsWith(REFS_CHANGES)
+        && (ref.endsWith(META_SUFFIX) || ref.endsWith(ROBOT_COMMENTS_SUFFIX))) {
+      return true;
+    }
+    if (ref.startsWith(REFS_DRAFT_COMMENTS) || ref.startsWith(REFS_STARRED_CHANGES)) {
+      return true;
+    }
+    return false;
+  }
+
   public static String refsUsers(Account.Id accountId) {
     StringBuilder r = new StringBuilder();
     r.append(REFS_USERS);
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountExternalIdAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountExternalIdAccess.java
deleted file mode 100644
index 9124301..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountExternalIdAccess.java
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.server;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.PrimaryKey;
-import com.google.gwtorm.server.Query;
-import com.google.gwtorm.server.ResultSet;
-
-public interface AccountExternalIdAccess extends Access<AccountExternalId, AccountExternalId.Key> {
-  @Override
-  @PrimaryKey("key")
-  AccountExternalId get(AccountExternalId.Key key) throws OrmException;
-
-  @Query("WHERE accountId = ?")
-  ResultSet<AccountExternalId> byAccount(Account.Id id) throws OrmException;
-
-  @Query
-  ResultSet<AccountExternalId> all() throws OrmException;
-}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupNameAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupNameAccess.java
index 82660cb..b8bc9f0 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupNameAccess.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupNameAccess.java
@@ -29,8 +29,4 @@
 
   @Query("ORDER BY name")
   ResultSet<AccountGroupName> all() throws OrmException;
-
-  @Query("WHERE name.name >= ? AND name.name <= ? ORDER BY name LIMIT ?")
-  ResultSet<AccountGroupName> suggestByName(String nameA, String nameB, int limit)
-      throws OrmException;
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDb.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDb.java
index 9b4e1ed..165578a 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDb.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDb.java
@@ -50,8 +50,7 @@
   @Relation(id = 6)
   AccountAccess accounts();
 
-  @Relation(id = 7)
-  AccountExternalIdAccess accountExternalIds();
+  // Deleted @Relation(id = 7)
 
   // Deleted @Relation(id = 8)
 
@@ -118,4 +117,8 @@
   @Sequence(startWith = FIRST_CHANGE_ID)
   @Deprecated
   int nextChangeId() throws OrmException;
+
+  default boolean changesTablesEnabled() {
+    return true;
+  }
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java
index 6dd3701..3b22889 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java
@@ -83,11 +83,6 @@
   }
 
   @Override
-  public AccountExternalIdAccess accountExternalIds() {
-    return delegate.accountExternalIds();
-  }
-
-  @Override
   public AccountGroupAccess accountGroups() {
     return delegate.accountGroups();
   }
@@ -158,6 +153,11 @@
     return delegate.nextChangeId();
   }
 
+  @Override
+  public boolean changesTablesEnabled() {
+    return delegate.changesTablesEnabled();
+  }
+
   public static class ChangeAccessWrapper implements ChangeAccess {
     protected final ChangeAccess delegate;
 
diff --git a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_generic.sql b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_generic.sql
index deceab9..871ed20 100644
--- a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_generic.sql
+++ b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_generic.sql
@@ -16,13 +16,6 @@
 
 
 -- *********************************************************************
--- AccountExternalIdAccess
---    covers:             byAccount
-CREATE INDEX account_external_ids_byAccount
-ON account_external_ids (account_id);
-
-
--- *********************************************************************
 -- AccountGroupMemberAccess
 --    @PrimaryKey covers: byAccount
 CREATE INDEX account_group_members_byGroup
diff --git a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql
index 1ec8ea6..c349241 100644
--- a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql
+++ b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql
@@ -19,14 +19,6 @@
 
 
 -- *********************************************************************
--- AccountExternalIdAccess
---    covers:             byAccount
-CREATE INDEX account_external_ids_byAccount
-ON account_external_ids (account_id)
-#
-
-
--- *********************************************************************
 -- AccountGroupMemberAccess
 --    @PrimaryKey covers: byAccount
 CREATE INDEX account_group_members_byGroup
diff --git a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_postgres.sql b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_postgres.sql
index a11c86b..da99fef 100644
--- a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_postgres.sql
+++ b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_postgres.sql
@@ -63,13 +63,6 @@
 
 
 -- *********************************************************************
--- AccountExternalIdAccess
---    covers:             byAccount
-CREATE INDEX account_external_ids_byAccount
-ON account_external_ids (account_id);
-
-
--- *********************************************************************
 -- AccountGroupMemberAccess
 --    @PrimaryKey covers: byAccount
 CREATE INDEX account_group_members_byGroup
diff --git a/gerrit-server/BUILD b/gerrit-server/BUILD
index adfe7a4..5c870ac 100644
--- a/gerrit-server/BUILD
+++ b/gerrit-server/BUILD
@@ -1,3 +1,4 @@
+load("//lib/prolog:prolog.bzl", "prolog_cafe_library")
 load("//tools/bzl:junit.bzl", "junit_tests")
 
 CONSTANTS_SRC = [
@@ -17,6 +18,13 @@
     visibility = ["//visibility:public"],
 )
 
+prolog_cafe_library(
+    name = "prolog-common",
+    srcs = ["src/main/prolog/gerrit_common.pl"],
+    visibility = ["//visibility:public"],
+    deps = [":server"],
+)
+
 java_library(
     name = "server",
     srcs = SRCS,
@@ -138,6 +146,22 @@
     ],
 )
 
+CUSTOM_TRUTH_SUBJECTS = glob([
+    "src/test/java/com/google/gerrit/server/**/*Subject.java",
+])
+
+java_library(
+    name = "custom-truth-subjects",
+    testonly = 1,
+    srcs = CUSTOM_TRUTH_SUBJECTS,
+    deps = [
+        ":server",
+        "//gerrit-extension-api:api",
+        "//gerrit-test-util:test_util",
+        "//lib:truth",
+    ],
+)
+
 PROLOG_TEST_CASE = [
     "src/test/java/com/google/gerrit/rules/PrologTestCase.java",
 ]
@@ -169,9 +193,9 @@
     srcs = PROLOG_TESTS,
     resources = glob(["src/test/resources/com/google/gerrit/rules/**/*"]),
     deps = TESTUTIL_DEPS + [
+        ":prolog-common",
         ":prolog_test_case",
         ":testutil",
-        "//gerrit-server/src/main/prolog:common",
         "//lib/prolog:runtime",
     ],
 )
@@ -186,10 +210,10 @@
     srcs = QUERY_TESTS,
     visibility = ["//visibility:public"],
     deps = TESTUTIL_DEPS + [
+        ":prolog-common",
         ":testutil",
         "//gerrit-antlr:query_exception",
         "//gerrit-antlr:query_parser",
-        "//gerrit-server/src/main/prolog:common",
         "//lib/antlr:java_runtime",
     ],
 )
@@ -200,10 +224,10 @@
     srcs = QUERY_TESTS,
     visibility = ["//visibility:public"],
     deps = TESTUTIL_DEPS + [
+        ":prolog-common",
         ":testutil",
         "//gerrit-antlr:query_exception",
         "//gerrit-antlr:query_parser",
-        "//gerrit-server/src/main/prolog:common",
         "//lib/antlr:java_runtime",
     ],
 )
@@ -213,15 +237,16 @@
     size = "large",
     srcs = glob(
         ["src/test/java/**/*.java"],
-        exclude = TESTUTIL + PROLOG_TESTS + PROLOG_TEST_CASE + QUERY_TESTS,
+        exclude = TESTUTIL + CUSTOM_TRUTH_SUBJECTS + PROLOG_TESTS + PROLOG_TEST_CASE + QUERY_TESTS,
     ),
     resources = glob(["src/test/resources/com/google/gerrit/server/**/*"]),
     visibility = ["//visibility:public"],
     deps = TESTUTIL_DEPS + [
+        ":custom-truth-subjects",
+        ":prolog-common",
         ":testutil",
         "//gerrit-antlr:query_exception",
         "//gerrit-patch-jgit:server",
-        "//gerrit-server/src/main/prolog:common",
         "//gerrit-test-util:test_util",
         "//lib:args4j",
         "//lib:grappa",
@@ -229,6 +254,7 @@
         "//lib:guava",
         "//lib:guava-retrying",
         "//lib:protobuf",
+        "//lib:truth-java8-extension",
         "//lib/bouncycastle:bcprov",
         "//lib/bouncycastle:bcpkix",
         "//lib/dropwizard:dropwizard-core",
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/EventBroker.java b/gerrit-server/src/main/java/com/google/gerrit/common/EventBroker.java
index 4603141..3cc7335 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/common/EventBroker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/EventBroker.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
@@ -28,6 +29,9 @@
 import com.google.gerrit.server.events.ProjectEvent;
 import com.google.gerrit.server.events.RefEvent;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectControl;
@@ -58,6 +62,7 @@
   /** Listeners to receive all changes as they happen. */
   protected final DynamicSet<EventListener> unrestrictedListeners;
 
+  private final PermissionBackend permissionBackend;
   protected final ProjectCache projectCache;
 
   protected final ChangeNotes.Factory notesFactory;
@@ -68,11 +73,13 @@
   public EventBroker(
       DynamicSet<UserScopedEventListener> listeners,
       DynamicSet<EventListener> unrestrictedListeners,
+      PermissionBackend permissionBackend,
       ProjectCache projectCache,
       ChangeNotes.Factory notesFactory,
       Provider<ReviewDb> dbProvider) {
     this.listeners = listeners;
     this.unrestrictedListeners = unrestrictedListeners;
+    this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
     this.notesFactory = notesFactory;
     this.dbProvider = dbProvider;
@@ -141,11 +148,12 @@
   }
 
   protected boolean isVisibleTo(Project.NameKey project, CurrentUser user) {
-    ProjectState pe = projectCache.get(project);
-    if (pe == null) {
+    try {
+      permissionBackend.user(user).project(project).check(ProjectPermission.ACCESS);
+      return true;
+    } catch (AuthException | PermissionBackendException e) {
       return false;
     }
-    return pe.controlFor(user).isVisible();
   }
 
   protected boolean isVisibleTo(Change change, CurrentUser user) throws OrmException {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java
index 9773869..880ba24 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java
@@ -79,20 +79,13 @@
    * @param value only value of the metric.
    * @param desc description of the metric.
    */
-  public <V> void newConstantMetric(String name, final V value, Description desc) {
+  public <V> void newConstantMetric(String name, V value, Description desc) {
     desc.setConstant();
 
     @SuppressWarnings("unchecked")
     Class<V> type = (Class<V>) value.getClass();
-    final CallbackMetric0<V> metric = newCallbackMetric(name, type, desc);
-    newTrigger(
-        metric,
-        new Runnable() {
-          @Override
-          public void run() {
-            metric.set(value);
-          }
-        });
+    CallbackMetric0<V> metric = newCallbackMetric(name, type, desc);
+    newTrigger(metric, () -> metric.set(value));
   }
 
   /**
@@ -116,16 +109,9 @@
    * @param trigger function to compute the value of the metric.
    */
   public <V> void newCallbackMetric(
-      String name, Class<V> valueClass, Description desc, final Supplier<V> trigger) {
-    final CallbackMetric0<V> metric = newCallbackMetric(name, valueClass, desc);
-    newTrigger(
-        metric,
-        new Runnable() {
-          @Override
-          public void run() {
-            metric.set(trigger.get());
-          }
-        });
+      String name, Class<V> valueClass, Description desc, Supplier<V> trigger) {
+    CallbackMetric0<V> metric = newCallbackMetric(name, valueClass, desc);
+    newTrigger(metric, () -> metric.set(trigger.get()));
   }
 
   /**
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/GetMetric.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/GetMetric.java
index 52e35c3..f0ae97e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/GetMetric.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/GetMetric.java
@@ -17,10 +17,14 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import org.kohsuke.args4j.Option;
 
 class GetMetric implements RestReadView<MetricResource> {
+  private final PermissionBackend permissionBackend;
   private final CurrentUser user;
   private final DropWizardMetricMaker metrics;
 
@@ -28,16 +32,16 @@
   boolean dataOnly;
 
   @Inject
-  GetMetric(CurrentUser user, DropWizardMetricMaker metrics) {
+  GetMetric(PermissionBackend permissionBackend, CurrentUser user, DropWizardMetricMaker metrics) {
+    this.permissionBackend = permissionBackend;
     this.user = user;
     this.metrics = metrics;
   }
 
   @Override
-  public MetricJson apply(MetricResource resource) throws AuthException {
-    if (!user.getCapabilities().canViewCaches()) {
-      throw new AuthException("restricted to viewCaches");
-    }
+  public MetricJson apply(MetricResource resource)
+      throws AuthException, PermissionBackendException {
+    permissionBackend.user(user).check(GlobalPermission.VIEW_CACHES);
     return new MetricJson(
         resource.getMetric(), metrics.getAnnotations(resource.getName()), dataOnly);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
index 891f4ac..59f6b97 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
@@ -19,6 +19,9 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import java.util.ArrayList;
 import java.util.List;
@@ -28,6 +31,7 @@
 import org.kohsuke.args4j.Option;
 
 class ListMetrics implements RestReadView<ConfigResource> {
+  private final PermissionBackend permissionBackend;
   private final CurrentUser user;
   private final DropWizardMetricMaker metrics;
 
@@ -43,16 +47,17 @@
   List<String> query = new ArrayList<>();
 
   @Inject
-  ListMetrics(CurrentUser user, DropWizardMetricMaker metrics) {
+  ListMetrics(
+      PermissionBackend permissionBackend, CurrentUser user, DropWizardMetricMaker metrics) {
+    this.permissionBackend = permissionBackend;
     this.user = user;
     this.metrics = metrics;
   }
 
   @Override
-  public Map<String, MetricJson> apply(ConfigResource resource) throws AuthException {
-    if (!user.getCapabilities().canViewCaches()) {
-      throw new AuthException("restricted to viewCaches");
-    }
+  public Map<String, MetricJson> apply(ConfigResource resource)
+      throws AuthException, PermissionBackendException {
+    permissionBackend.user(user).check(GlobalPermission.VIEW_CACHES);
 
     SortedMap<String, MetricJson> out = new TreeMap<>();
     List<String> prefixes = new ArrayList<>(query.size());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricsCollection.java
index 2686f1f..6abf17c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricsCollection.java
@@ -23,6 +23,9 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -31,6 +34,7 @@
 class MetricsCollection implements ChildCollection<ConfigResource, MetricResource> {
   private final DynamicMap<RestView<MetricResource>> views;
   private final Provider<ListMetrics> list;
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> user;
   private final DropWizardMetricMaker metrics;
 
@@ -38,10 +42,12 @@
   MetricsCollection(
       DynamicMap<RestView<MetricResource>> views,
       Provider<ListMetrics> list,
+      PermissionBackend permissionBackend,
       Provider<CurrentUser> user,
       DropWizardMetricMaker metrics) {
     this.views = views;
     this.list = list;
+    this.permissionBackend = permissionBackend;
     this.user = user;
     this.metrics = metrics;
   }
@@ -58,10 +64,8 @@
 
   @Override
   public MetricResource parse(ConfigResource parent, IdString id)
-      throws ResourceNotFoundException, AuthException {
-    if (!user.get().getCapabilities().canViewCaches()) {
-      throw new AuthException("restricted to viewCaches");
-    }
+      throws ResourceNotFoundException, AuthException, PermissionBackendException {
+    permissionBackend.user(user).check(GlobalPermission.VIEW_CACHES);
 
     Metric metric = metrics.getMetric(id.get());
     if (metric == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
index 11f8e50..8978e99 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
@@ -103,7 +103,7 @@
   }
 
   private void procJvmMemory(MetricMaker metrics) {
-    final CallbackMetric0<Long> heapCommitted =
+    CallbackMetric0<Long> heapCommitted =
         metrics.newCallbackMetric(
             "proc/jvm/memory/heap_committed",
             Long.class,
@@ -111,7 +111,7 @@
                 .setGauge()
                 .setUnit(Units.BYTES));
 
-    final CallbackMetric0<Long> heapUsed =
+    CallbackMetric0<Long> heapUsed =
         metrics.newCallbackMetric(
             "proc/jvm/memory/heap_used",
             Long.class,
@@ -119,7 +119,7 @@
                 .setGauge()
                 .setUnit(Units.BYTES));
 
-    final CallbackMetric0<Long> nonHeapCommitted =
+    CallbackMetric0<Long> nonHeapCommitted =
         metrics.newCallbackMetric(
             "proc/jvm/memory/non_heap_committed",
             Long.class,
@@ -127,7 +127,7 @@
                 .setGauge()
                 .setUnit(Units.BYTES));
 
-    final CallbackMetric0<Long> nonHeapUsed =
+    CallbackMetric0<Long> nonHeapUsed =
         metrics.newCallbackMetric(
             "proc/jvm/memory/non_heap_used",
             Long.class,
@@ -135,7 +135,7 @@
                 .setGauge()
                 .setUnit(Units.BYTES));
 
-    final CallbackMetric0<Integer> objectPendingFinalizationCount =
+    CallbackMetric0<Integer> objectPendingFinalizationCount =
         metrics.newCallbackMetric(
             "proc/jvm/memory/object_pending_finalization_count",
             Integer.class,
@@ -143,39 +143,36 @@
                 .setGauge()
                 .setUnit("objects"));
 
-    final MemoryMXBean memory = ManagementFactory.getMemoryMXBean();
+    MemoryMXBean memory = ManagementFactory.getMemoryMXBean();
     metrics.newTrigger(
         ImmutableSet.<CallbackMetric<?>>of(
             heapCommitted, heapUsed, nonHeapCommitted, nonHeapUsed, objectPendingFinalizationCount),
-        new Runnable() {
-          @Override
-          public void run() {
-            try {
-              MemoryUsage stats = memory.getHeapMemoryUsage();
-              heapCommitted.set(stats.getCommitted());
-              heapUsed.set(stats.getUsed());
-            } catch (IllegalArgumentException e) {
-              // MXBean may throw due to a bug in Java 7; ignore.
-            }
-
-            MemoryUsage stats = memory.getNonHeapMemoryUsage();
-            nonHeapCommitted.set(stats.getCommitted());
-            nonHeapUsed.set(stats.getUsed());
-
-            objectPendingFinalizationCount.set(memory.getObjectPendingFinalizationCount());
+        () -> {
+          try {
+            MemoryUsage stats = memory.getHeapMemoryUsage();
+            heapCommitted.set(stats.getCommitted());
+            heapUsed.set(stats.getUsed());
+          } catch (IllegalArgumentException e) {
+            // MXBean may throw due to a bug in Java 7; ignore.
           }
+
+          MemoryUsage stats = memory.getNonHeapMemoryUsage();
+          nonHeapCommitted.set(stats.getCommitted());
+          nonHeapUsed.set(stats.getUsed());
+
+          objectPendingFinalizationCount.set(memory.getObjectPendingFinalizationCount());
         });
   }
 
   private void procJvmGc(MetricMaker metrics) {
-    final CallbackMetric1<String, Long> gcCount =
+    CallbackMetric1<String, Long> gcCount =
         metrics.newCallbackMetric(
             "proc/jvm/gc/count",
             Long.class,
             new Description("Number of GCs").setCumulative(),
             Field.ofString("gc_name", "The name of the garbage collector"));
 
-    final CallbackMetric1<String, Long> gcTime =
+    CallbackMetric1<String, Long> gcTime =
         metrics.newCallbackMetric(
             "proc/jvm/gc/time",
             Long.class,
@@ -187,34 +184,26 @@
     metrics.newTrigger(
         gcCount,
         gcTime,
-        new Runnable() {
-          @Override
-          public void run() {
-            for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
-              long count = gc.getCollectionCount();
-              if (count != -1) {
-                gcCount.set(gc.getName(), count);
-              }
-              long time = gc.getCollectionTime();
-              if (time != -1) {
-                gcTime.set(gc.getName(), time);
-              }
+        () -> {
+          for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
+            long count = gc.getCollectionCount();
+            if (count != -1) {
+              gcCount.set(gc.getName(), count);
+            }
+            long time = gc.getCollectionTime();
+            if (time != -1) {
+              gcTime.set(gc.getName(), time);
             }
           }
         });
   }
 
   private void procJvmThread(MetricMaker metrics) {
-    final ThreadMXBean thread = ManagementFactory.getThreadMXBean();
+    ThreadMXBean thread = ManagementFactory.getThreadMXBean();
     metrics.newCallbackMetric(
         "proc/jvm/thread/num_live",
         Integer.class,
         new Description("Current live thread count").setGauge().setUnit("threads"),
-        new Supplier<Integer>() {
-          @Override
-          public Integer get() {
-            return thread.getThreadCount();
-          }
-        });
+        () -> thread.getThreadCount());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/PrologEnvironment.java b/gerrit-server/src/main/java/com/google/gerrit/rules/PrologEnvironment.java
index 9538121a4..23c59f5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/PrologEnvironment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/rules/PrologEnvironment.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -166,6 +167,7 @@
     }
 
     private final ProjectCache projectCache;
+    private final PermissionBackend permissionBackend;
     private final GitRepositoryManager repositoryManager;
     private final PatchListCache patchListCache;
     private final PatchSetInfoFactory patchSetInfoFactory;
@@ -177,6 +179,7 @@
     @Inject
     Args(
         ProjectCache projectCache,
+        PermissionBackend permissionBackend,
         GitRepositoryManager repositoryManager,
         PatchListCache patchListCache,
         PatchSetInfoFactory patchSetInfoFactory,
@@ -184,6 +187,7 @@
         Provider<AnonymousUser> anonymousUser,
         @GerritServerConfig Config config) {
       this.projectCache = projectCache;
+      this.permissionBackend = permissionBackend;
       this.repositoryManager = repositoryManager;
       this.patchListCache = patchListCache;
       this.patchSetInfoFactory = patchSetInfoFactory;
@@ -213,6 +217,10 @@
       return projectCache;
     }
 
+    public PermissionBackend getPermissionBackend() {
+      return permissionBackend;
+    }
+
     public GitRepositoryManager getGitRepositoryManager() {
       return repositoryManager;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java b/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java
index 34fcb52..e35171b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
@@ -119,23 +120,26 @@
           GitRepositoryManager gitMgr = env.getArgs().getGitRepositoryManager();
           Change change = getChange(engine);
           Project.NameKey projectKey = change.getProject();
-          final Repository repo;
+          Repository repo;
           try {
             repo = gitMgr.openRepository(projectKey);
           } catch (IOException e) {
             throw new SystemException(e.getMessage());
           }
-          env.addToCleanup(
-              new Runnable() {
-                @Override
-                public void run() {
-                  repo.close();
-                }
-              });
+          env.addToCleanup(repo::close);
           return repo;
         }
       };
 
+  public static final StoredValue<PermissionBackend> PERMISSION_BACKEND =
+      new StoredValue<PermissionBackend>() {
+        @Override
+        protected PermissionBackend createValue(Prolog engine) {
+          PrologEnvironment env = (PrologEnvironment) engine.control;
+          return env.getArgs().getPermissionBackend();
+        }
+      };
+
   public static final StoredValue<AnonymousUser> ANONYMOUS_USER =
       new StoredValue<AnonymousUser>() {
         @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java
index cb65ed3..e6dd1db 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java
@@ -43,6 +43,7 @@
 import java.util.TreeMap;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
  * Copies approvals between patch sets.
@@ -140,7 +141,8 @@
 
       TreeMap<Integer, PatchSet> patchSets = getPatchSets(cd);
 
-      try (Repository repo = repoManager.openRepository(project.getProject().getNameKey())) {
+      try (Repository repo = repoManager.openRepository(project.getProject().getNameKey());
+          RevWalk rw = new RevWalk(repo)) {
         // Walk patch sets strictly less than current in descending order.
         Collection<PatchSet> allPrior =
             patchSets.descendingMap().tailMap(ps.getId().get(), false).values();
@@ -153,7 +155,8 @@
           ChangeKind kind =
               changeKindCache.getChangeKind(
                   project.getProject().getNameKey(),
-                  repo,
+                  rw,
+                  repo.getConfig(),
                   ObjectId.fromString(priorPs.getRevision().get()),
                   ObjectId.fromString(ps.getRevision().get()));
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
index 910fbc2..1ef284c 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
@@ -322,7 +322,7 @@
         accountId,
         ps.getUploader());
     if (approvals.isEmpty()) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
     checkApprovals(approvals, changeCtl);
     List<PatchSetApproval> cells = new ArrayList<>(approvals.size());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeFinder.java b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeFinder.java
index 2f3a76f..2d80ceb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeFinder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeFinder.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.server;
 
+import com.google.common.collect.Sets;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.change.ChangeTriplet;
+import com.google.gerrit.server.index.IndexConfig;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -29,13 +31,16 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
+import java.util.Set;
 
 @Singleton
 public class ChangeFinder {
+  private final IndexConfig indexConfig;
   private final Provider<InternalChangeQuery> queryProvider;
 
   @Inject
-  ChangeFinder(Provider<InternalChangeQuery> queryProvider) {
+  ChangeFinder(IndexConfig indexConfig, Provider<InternalChangeQuery> queryProvider) {
+    this.indexConfig = indexConfig;
     this.queryProvider = queryProvider;
   }
 
@@ -93,8 +98,24 @@
   private List<ChangeControl> asChangeControls(List<ChangeData> cds, CurrentUser user)
       throws OrmException {
     List<ChangeControl> ctls = new ArrayList<>(cds.size());
+    if (!indexConfig.separateChangeSubIndexes()) {
+      for (ChangeData cd : cds) {
+        ctls.add(cd.changeControl(user));
+      }
+      return ctls;
+    }
+
+    // If an index implementation uses separate non-atomic subindexes, it's possible to temporarily
+    // observe a change as present in both subindexes, if this search is concurrent with a write.
+    // Dedup to avoid confusing the caller. We can choose an arbitrary ChangeData instance because
+    // the index results have no stored fields, so the data is already reloaded. (It's also possible
+    // that a change might appear in zero subindexes, but there's nothing we can do here to help
+    // this case.)
+    Set<Change.Id> seen = Sets.newHashSetWithExpectedSize(cds.size());
     for (ChangeData cd : cds) {
-      ctls.add(cd.changeControl(user));
+      if (seen.add(cd.getId())) {
+        ctls.add(cd.changeControl(user));
+      }
     }
     return ctls;
   }
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 d277bf9..9077d02 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
@@ -55,8 +55,13 @@
   public static final String TAG_SET_ASSIGNEE = "autogenerated:gerrit:setAssignee";
   public static final String TAG_SET_DESCRIPTION = "autogenerated:gerrit:setPsDescription";
   public static final String TAG_SET_HASHTAGS = "autogenerated:gerrit:setHashtag";
+  public static final String TAG_SET_PRIVATE = "autogenerated:gerrit:setPrivate";
+  public static final String TAG_SET_READY = "autogenerated:gerrit:setReadyForReview";
   public static final String TAG_SET_TOPIC = "autogenerated:gerrit:setTopic";
+  public static final String TAG_SET_WIP = "autogenerated:gerrit:setWorkInProgress";
+  public static final String TAG_UNSET_PRIVATE = "autogenerated:gerrit:unsetPrivate";
   public static final String TAG_UPLOADED_PATCH_SET = "autogenerated:gerrit:newPatchSet";
+  public static final String TAG_UPLOADED_WIP_PATCH_SET = "autogenerated:gerrit:newWipPatchSet";
 
   public static ChangeMessage newMessage(ChangeContext ctx, String body, @Nullable String tag) {
     return newMessage(ctx.getChange().currentPatchSetId(), ctx.getUser(), ctx.getWhen(), body, tag);
@@ -78,6 +83,10 @@
     return m;
   }
 
+  public static String uploadedPatchSetTag(boolean workInProgress) {
+    return workInProgress ? TAG_UPLOADED_WIP_PATCH_SET : TAG_UPLOADED_PATCH_SET;
+  }
+
   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/ChangeUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeUtil.java
index 10ae60c..c9b726b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeUtil.java
@@ -16,16 +16,18 @@
 
 import static java.util.Comparator.comparingInt;
 
+import com.google.common.collect.Maps;
 import com.google.common.collect.Ordering;
 import com.google.common.io.BaseEncoding;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.security.SecureRandom;
 import java.util.Map;
 import java.util.Random;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.lib.Repository;
 
 @Singleton
@@ -47,7 +49,16 @@
     return UUID_ENCODING.encode(buf, 0, 4) + '_' + UUID_ENCODING.encode(buf, 4, 4);
   }
 
-  public static PatchSet.Id nextPatchSetId(Map<String, Ref> allRefs, PatchSet.Id id) {
+  /**
+   * Get the next patch set ID from a previously-read map of all refs.
+   *
+   * @param allRefs map of full ref name to ref, in the same format returned by {@link
+   *     org.eclipse.jgit.lib.RefDatabase#getRefs(String)} when passing {@code ""}.
+   * @param id previous patch set ID.
+   * @return next unused patch set ID for the same change, skipping any IDs whose corresponding ref
+   *     names appear in the {@code allRefs} map.
+   */
+  public static PatchSet.Id nextPatchSetIdFromAllRefsMap(Map<String, Ref> allRefs, PatchSet.Id id) {
     PatchSet.Id next = nextPatchSetId(id);
     while (allRefs.containsKey(next.toRefName())) {
       next = nextPatchSetId(next);
@@ -55,12 +66,55 @@
     return next;
   }
 
+  /**
+   * Get the next patch set ID from a previously-read map of refs below the change prefix.
+   *
+   * @param changeRefs map of ref suffix to SHA-1, where the keys are ref names with the {@code
+   *     refs/changes/CD/ABCD/} prefix stripped. All refs should be under {@code id}'s change ref
+   *     prefix. The keys match the format returned by {@link
+   *     org.eclipse.jgit.lib.RefDatabase#getRefs(String)} when passing the appropriate {@code
+   *     refs/changes/CD/ABCD}.
+   * @param id previous patch set ID.
+   * @return next unused patch set ID for the same change, skipping any IDs whose corresponding ref
+   *     names appear in the {@code changeRefs} map.
+   */
+  public static PatchSet.Id nextPatchSetIdFromChangeRefsMap(
+      Map<String, ObjectId> changeRefs, PatchSet.Id id) {
+    int prefixLen = id.getParentKey().toRefPrefix().length();
+    PatchSet.Id next = nextPatchSetId(id);
+    while (changeRefs.containsKey(next.toRefName().substring(prefixLen))) {
+      next = nextPatchSetId(next);
+    }
+    return next;
+  }
+
+  /**
+   * Get the next patch set ID just looking at a single previous patch set ID.
+   *
+   * <p>This patch set ID may or may not be available in the database; callers that want a
+   * previously-unused ID should use {@link #nextPatchSetIdFromAllRefsMap} or {@link
+   * #nextPatchSetIdFromChangeRefsMap}.
+   *
+   * @param id previous patch set ID.
+   * @return next patch set ID for the same change, incrementing by 1.
+   */
   public static PatchSet.Id nextPatchSetId(PatchSet.Id id) {
     return new PatchSet.Id(id.getParentKey(), id.get() + 1);
   }
 
+  /**
+   * Get the next patch set ID from scanning refs in the repo.
+   *
+   * @param git repository to scan for patch set refs.
+   * @param id previous patch set ID.
+   * @return next unused patch set ID for the same change, skipping any IDs whose corresponding ref
+   *     names appear in the repository.
+   */
   public static PatchSet.Id nextPatchSetId(Repository git, PatchSet.Id id) throws IOException {
-    return nextPatchSetId(git.getRefDatabase().getRefs(RefDatabase.ALL), id);
+    return nextPatchSetIdFromChangeRefsMap(
+        Maps.transformValues(
+            git.getRefDatabase().getRefs(id.getParentKey().toRefPrefix()), Ref::getObjectId),
+        id);
   }
 
   public static String cropSubject(String subject) {
@@ -78,5 +132,9 @@
     return subject;
   }
 
+  public static String status(Change c) {
+    return c != null ? c.getStatus().name().toLowerCase() : "deleted";
+  }
+
   private ChangeUtil() {}
 }
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 8d2289a..c51e33a 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
@@ -16,7 +16,9 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.reviewdb.client.PatchLineComment.Status.PUBLISHED;
 import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.FluentIterable;
@@ -38,14 +40,17 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.update.BatchUpdateReviewDb;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
@@ -56,6 +61,7 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import java.util.function.Predicate;
 import java.util.stream.StreamSupport;
@@ -125,6 +131,8 @@
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
   private final NotesMigration migration;
+  private final PatchListCache patchListCache;
+  private final PatchSetUtil psUtil;
   private final String serverId;
 
   @Inject
@@ -132,10 +140,14 @@
       GitRepositoryManager repoManager,
       AllUsersName allUsers,
       NotesMigration migration,
+      PatchListCache patchListCache,
+      PatchSetUtil psUtil,
       @GerritServerId String serverId) {
     this.repoManager = repoManager;
     this.allUsers = allUsers;
     this.migration = migration;
+    this.patchListCache = patchListCache;
+    this.psUtil = psUtil;
     this.serverId = serverId;
   }
 
@@ -215,8 +227,7 @@
 
   public List<Comment> publishedByChange(ReviewDb db, ChangeNotes notes) throws OrmException {
     if (!migration.readChanges()) {
-      return sort(
-          byCommentStatus(db.patchComments().byChange(notes.getChangeId()), Status.PUBLISHED));
+      return sort(byCommentStatus(db.patchComments().byChange(notes.getChangeId()), PUBLISHED));
     }
 
     notes.load();
@@ -395,6 +406,31 @@
         .delete(toPatchLineComments(update.getId(), PatchLineComment.Status.DRAFT, comments));
   }
 
+  public void deleteCommentByRewritingHistory(
+      ReviewDb db, ChangeUpdate update, Comment.Key commentKey, PatchSet.Id psId, String newMessage)
+      throws OrmException {
+    if (PrimaryStorage.of(update.getChange()).equals(PrimaryStorage.REVIEW_DB)) {
+      PatchLineComment.Key key =
+          new PatchLineComment.Key(new Patch.Key(psId, commentKey.filename), commentKey.uuid);
+
+      if (db instanceof BatchUpdateReviewDb) {
+        db = ((BatchUpdateReviewDb) db).unsafeGetDelegate();
+      }
+      db = ReviewDbUtil.unwrapDb(db);
+
+      PatchLineComment patchLineComment = db.patchComments().get(key);
+
+      if (!patchLineComment.getStatus().equals(PUBLISHED)) {
+        throw new OrmException(String.format("comment %s is not published", key));
+      }
+
+      patchLineComment.setMessage(newMessage);
+      db.patchComments().upsert(Collections.singleton(patchLineComment));
+    }
+
+    update.deleteCommentByRewritingHistory(commentKey.uuid, newMessage);
+  }
+
   public void deleteAllDraftsFromAllUsers(Change.Id changeId) throws IOException {
     try (Repository repo = repoManager.openRepository(allUsers);
         RevWalk rw = new RevWalk(repo)) {
@@ -500,4 +536,35 @@
     return COMMENT_ORDER.sortedCopy(
         FluentIterable.from(comments).transform(plc -> plc.asComment(serverId)));
   }
+
+  public void publish(
+      ChangeContext ctx, PatchSet.Id psId, Collection<Comment> drafts, @Nullable String tag)
+      throws OrmException {
+    ChangeNotes notes = ctx.getNotes();
+    checkArgument(notes != null);
+    if (drafts.isEmpty()) {
+      return;
+    }
+
+    Map<PatchSet.Id, PatchSet> patchSets =
+        psUtil.getAsMap(
+            ctx.getDb(), notes, drafts.stream().map(d -> psId(notes, d)).collect(toSet()));
+    for (Comment d : drafts) {
+      PatchSet ps = patchSets.get(psId(notes, d));
+      if (ps == null) {
+        throw new OrmException("patch set " + ps + " not found");
+      }
+      d.writtenOn = ctx.getWhen();
+      d.tag = 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(d::setRealAuthor);
+      setCommentRevId(d, patchListCache, notes.getChange(), ps);
+    }
+    putComments(ctx.getDb(), ctx.getUpdate(psId), PUBLISHED, drafts);
+  }
+
+  private static PatchSet.Id psId(ChangeNotes notes, Comment c) {
+    return new PatchSet.Id(notes.getChangeId(), c.key.patchSetId);
+  }
 }
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 029b54d..7e1be17 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
@@ -17,8 +17,8 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.CapabilityControl;
-import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.inject.servlet.RequestScoped;
 import java.util.function.Consumer;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/DynamicOptions.java b/gerrit-server/src/main/java/com/google/gerrit/server/DynamicOptions.java
new file mode 100644
index 0000000..6267dca
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/DynamicOptions.java
@@ -0,0 +1,173 @@
+// 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.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.server.plugins.DelegatingClassLoader;
+import com.google.gerrit.util.cli.CmdLineParser;
+import com.google.inject.Injector;
+import com.google.inject.Provider;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+
+/** Helper class to define and parse options from plugins on ssh and RestAPI commands. */
+public class DynamicOptions {
+  /**
+   * To provide additional options, bind a DynamicBean. For example:
+   *
+   * <pre>
+   *   bind(com.google.gerrit.server.DynamicOptions.DynamicBean.class)
+   *       .annotatedWith(Exports.named(com.google.gerrit.sshd.commands.Query.class))
+   *       .to(MyOptions.class);
+   * </pre>
+   *
+   * To define the additional options, implement this interface. For example:
+   *
+   * <pre>
+   *   public class MyOptions implements DynamicOptions.DynamicBean {
+   *     {@literal @}Option(name = "--verbose", aliases = {"-v"}
+   *             usage = "Make the operation more talkative")
+   *     public boolean verbose;
+   *   }
+   * </pre>
+   *
+   * The option will be prefixed by the plugin name. In the example above, if the plugin name was
+   * my-plugin, then the --verbose option as used by the caller would be --my-plugin--verbose.
+   */
+  public interface DynamicBean {}
+
+  /**
+   * Implement this if your DynamicBean needs an opportunity to act on the Bean directly before or
+   * after argument parsing.
+   */
+  public interface BeanParseListener extends DynamicBean {
+    void onBeanParseStart(String plugin, Object bean);
+
+    void onBeanParseEnd(String plugin, Object bean);
+  }
+
+  /**
+   * The entity which provided additional options may need a way to receive a reference to the
+   * DynamicBean it provided. To do so, the existing class should implement BeanReceiver (a setter)
+   * and then provide some way for the plugin to request its DynamicBean (a getter.) For example:
+   *
+   * <pre>
+   *   public class Query extends SshCommand implements DynamicOptions.BeanReceiver {
+   *       public void setDynamicBean(String plugin, DynamicOptions.DynamicBean dynamicBean) {
+   *         dynamicBeans.put(plugin, dynamicBean);
+   *       }
+   *
+   *       public DynamicOptions.DynamicBean getDynamicBean(String plugin) {
+   *         return dynamicBeans.get(plugin);
+   *       }
+   *   ...
+   *   }
+   * }
+   * </pre>
+   */
+  public interface BeanReceiver {
+    void setDynamicBean(String plugin, DynamicBean dynamicBean);
+  }
+
+  protected Object bean;
+  protected Map<String, DynamicBean> beansByPlugin;
+  protected Injector injector;
+
+  /**
+   * Internal: For Gerrit to include options from DynamicBeans, setup a DynamicMap and instantiate
+   * this class so the following methods can be called if desired:
+   *
+   * <pre>
+   *    DynamicOptions pluginOptions = new DynamicOptions(bean, injector, dynamicBeans);
+   *    pluginOptions.parseDynamicBeans(clp);
+   *    pluginOptions.setDynamicBeans();
+   *    pluginOptions.onBeanParseStart();
+   *
+   *    // parse arguments here:  clp.parseArgument(argv);
+   *
+   *    pluginOptions.onBeanParseEnd();
+   * </pre>
+   */
+  public DynamicOptions(Object bean, Injector injector, DynamicMap<DynamicBean> dynamicBeans) {
+    this.bean = bean;
+    this.injector = injector;
+    beansByPlugin = new HashMap<>();
+    for (String plugin : dynamicBeans.plugins()) {
+      Provider<DynamicBean> provider =
+          dynamicBeans.byPlugin(plugin).get(bean.getClass().getCanonicalName());
+      if (provider != null) {
+        beansByPlugin.put(plugin, getDynamicBean(bean, provider.get()));
+      }
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  public DynamicBean getDynamicBean(Object bean, DynamicBean dynamicBean) {
+    ClassLoader coreCl = getClass().getClassLoader();
+    ClassLoader beanCl = bean.getClass().getClassLoader();
+    if (beanCl != coreCl) { // bean from a plugin?
+      ClassLoader dynamicBeanCl = dynamicBean.getClass().getClassLoader();
+      if (beanCl != dynamicBeanCl) { // in a different plugin?
+        ClassLoader mergedCL = new DelegatingClassLoader(beanCl, dynamicBeanCl);
+        try {
+          return injector
+              .createChildInjector()
+              .getInstance(
+                  (Class<DynamicOptions.DynamicBean>)
+                      mergedCL.loadClass(dynamicBean.getClass().getCanonicalName()));
+        } catch (ClassNotFoundException e) {
+          throw new RuntimeException(e);
+        }
+      }
+    }
+    return dynamicBean;
+  }
+
+  public void parseDynamicBeans(CmdLineParser clp) {
+    for (Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
+      clp.parseWithPrefix(e.getKey(), e.getValue());
+    }
+  }
+
+  public void setDynamicBeans() {
+    if (bean instanceof BeanReceiver) {
+      BeanReceiver receiver = (BeanReceiver) bean;
+      for (Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
+        receiver.setDynamicBean(e.getKey(), e.getValue());
+      }
+    }
+  }
+
+  public void onBeanParseStart() {
+    for (Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
+      DynamicBean instance = e.getValue();
+      if (instance instanceof BeanParseListener) {
+        BeanParseListener listener = (BeanParseListener) instance;
+        listener.onBeanParseStart(e.getKey(), bean);
+      }
+    }
+  }
+
+  public void onBeanParseEnd() {
+    for (Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
+      DynamicBean instance = e.getValue();
+      if (instance instanceof BeanParseListener) {
+        BeanParseListener listener = (BeanParseListener) instance;
+        listener.onBeanParseEnd(e.getKey(), bean);
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/PatchSetUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/PatchSetUtil.java
index ab942ca..c23d990 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/PatchSetUtil.java
@@ -16,11 +16,17 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static com.google.gerrit.server.ChangeUtil.PS_ID_ORDER;
 import static com.google.gerrit.server.notedb.PatchSetState.DRAFT;
 import static com.google.gerrit.server.notedb.PatchSetState.PUBLISHED;
+import static java.util.function.Function.identity;
 
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Streams;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.RevId;
@@ -36,6 +42,7 @@
 import java.sql.Timestamp;
 import java.util.Collections;
 import java.util.List;
+import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevWalk;
 
@@ -63,8 +70,7 @@
   public ImmutableCollection<PatchSet> byChange(ReviewDb db, ChangeNotes notes)
       throws OrmException {
     if (!migration.readChanges()) {
-      return ChangeUtil.PS_ID_ORDER.immutableSortedCopy(
-          db.patchSets().byChange(notes.getChangeId()));
+      return PS_ID_ORDER.immutableSortedCopy(db.patchSets().byChange(notes.getChangeId()));
     }
     return notes.load().getPatchSets().values();
   }
@@ -73,8 +79,7 @@
       throws OrmException {
     if (!migration.readChanges()) {
       ImmutableMap.Builder<PatchSet.Id, PatchSet> result = ImmutableMap.builder();
-      for (PatchSet ps :
-          ChangeUtil.PS_ID_ORDER.sortedCopy(db.patchSets().byChange(notes.getChangeId()))) {
+      for (PatchSet ps : PS_ID_ORDER.sortedCopy(db.patchSets().byChange(notes.getChangeId()))) {
         result.put(ps.getId(), ps);
       }
       return result.build();
@@ -82,6 +87,17 @@
     return notes.load().getPatchSets();
   }
 
+  public ImmutableMap<PatchSet.Id, PatchSet> getAsMap(
+      ReviewDb db, ChangeNotes notes, Set<PatchSet.Id> patchSetIds) throws OrmException {
+    if (!migration.readChanges()) {
+      patchSetIds = Sets.filter(patchSetIds, p -> p.getParentKey().equals(notes.getChangeId()));
+      return Streams.stream(db.patchSets().get(patchSetIds))
+          .sorted(PS_ID_ORDER)
+          .collect(toImmutableMap(PatchSet::getId, identity()));
+    }
+    return ImmutableMap.copyOf(Maps.filterKeys(notes.load().getPatchSets(), patchSetIds::contains));
+  }
+
   public PatchSet insert(
       ReviewDb db,
       RevWalk rw,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerByEmailSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerByEmailSet.java
new file mode 100644
index 0000000..c16c9c8
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerByEmailSet.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2017 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.collect.ImmutableSet;
+import com.google.common.collect.ImmutableTable;
+import com.google.common.collect.Table;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import java.sql.Timestamp;
+
+/**
+ * Set of reviewers on a change that do not have a Gerrit account and were added by email instead.
+ *
+ * <p>A given account may appear in multiple states and at different timestamps. No reviewers with
+ * state {@link ReviewerStateInternal#REMOVED} are ever exposed by this interface.
+ */
+public class ReviewerByEmailSet {
+  private static final ReviewerByEmailSet EMPTY = new ReviewerByEmailSet(ImmutableTable.of());
+
+  public static ReviewerByEmailSet fromTable(
+      Table<ReviewerStateInternal, Address, Timestamp> table) {
+    return new ReviewerByEmailSet(table);
+  }
+
+  public static ReviewerByEmailSet empty() {
+    return EMPTY;
+  }
+
+  private final ImmutableTable<ReviewerStateInternal, Address, Timestamp> table;
+  private ImmutableSet<Address> users;
+
+  private ReviewerByEmailSet(Table<ReviewerStateInternal, Address, Timestamp> table) {
+    this.table = ImmutableTable.copyOf(table);
+  }
+
+  public ImmutableSet<Address> all() {
+    if (users == null) {
+      // Idempotent and immutable, don't bother locking.
+      users = ImmutableSet.copyOf(table.columnKeySet());
+    }
+    return users;
+  }
+
+  public ImmutableSet<Address> byState(ReviewerStateInternal state) {
+    return table.row(state).keySet();
+  }
+
+  public ImmutableTable<ReviewerStateInternal, Address, Timestamp> asTable() {
+    return table;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    return (o instanceof ReviewerByEmailSet) && table.equals(((ReviewerByEmailSet) o).table);
+  }
+
+  @Override
+  public int hashCode() {
+    return table.hashCode();
+  }
+
+  @Override
+  public String toString() {
+    return getClass().getSimpleName() + table;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
index 5ca9e19..cbaae1e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -147,6 +147,7 @@
 
   public static final String DEFAULT_LABEL = "star";
   public static final String IGNORE_LABEL = "ignore";
+  public static final String MUTE_LABEL = "mute";
   public static final ImmutableSortedSet<String> DEFAULT_LABELS =
       ImmutableSortedSet.of(DEFAULT_LABEL);
 
@@ -341,6 +342,48 @@
     }
   }
 
+  public void ignore(Account.Id accountId, Project.NameKey project, Change.Id changeId)
+      throws OrmException {
+    star(accountId, project, changeId, ImmutableSet.of(IGNORE_LABEL), ImmutableSet.of());
+  }
+
+  public void unignore(Account.Id accountId, Project.NameKey project, Change.Id changeId)
+      throws OrmException {
+    star(accountId, project, changeId, ImmutableSet.of(), ImmutableSet.of(IGNORE_LABEL));
+  }
+
+  public boolean isIgnoredBy(Change.Id changeId, Account.Id accountId) throws OrmException {
+    return byChange(changeId, IGNORE_LABEL).contains(accountId);
+  }
+
+  private static String getMuteLabel(Change change) {
+    return MUTE_LABEL + "/" + change.currentPatchSetId().get();
+  }
+
+  public void mute(Account.Id accountId, Project.NameKey project, Change change)
+      throws OrmException {
+    star(
+        accountId,
+        project,
+        change.getId(),
+        ImmutableSet.of(getMuteLabel(change)),
+        ImmutableSet.of());
+  }
+
+  public void unmute(Account.Id accountId, Project.NameKey project, Change change)
+      throws OrmException {
+    star(
+        accountId,
+        project,
+        change.getId(),
+        ImmutableSet.of(),
+        ImmutableSet.of(getMuteLabel(change)));
+  }
+
+  public boolean isMutedBy(Change change, Account.Id accountId) throws OrmException {
+    return byChange(change.getId(), getMuteLabel(change)).contains(accountId);
+  }
+
   private static StarRef readLabels(Repository repo, String refName) throws IOException {
     Ref ref = repo.exactRef(refName);
     if (ref == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java
index 0f9ec8d..e61736d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.mail.send.EmailSender;
 import com.google.inject.Inject;
 import java.util.Collection;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java
index d45ecd8..9eec82d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java
@@ -14,14 +14,13 @@
 
 package com.google.gerrit.server.account;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
-import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.query.account.InternalAccountQuery;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Provider;
@@ -29,7 +28,6 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import java.util.Collections;
-import java.util.HashSet;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
 import org.slf4j.Logger;
@@ -77,34 +75,22 @@
   }
 
   static class Loader extends CacheLoader<String, Set<Account.Id>> {
-    private final SchemaFactory<ReviewDb> schema;
-    private final Provider<InternalAccountQuery> accountQueryProvider;
+    // This must be a provider to prevent a cyclic dependency within Google-internal glue code.
+    private final Provider<ExternalIds> externalIds;
 
     @Inject
-    Loader(SchemaFactory<ReviewDb> schema, Provider<InternalAccountQuery> accountQueryProvider) {
-      this.schema = schema;
-      this.accountQueryProvider = accountQueryProvider;
+    Loader(Provider<ExternalIds> externalIds) {
+      this.externalIds = externalIds;
     }
 
     @Override
     public Set<Account.Id> load(String email) throws Exception {
-      try (ReviewDb db = schema.open()) {
-        Set<Account.Id> r = new HashSet<>();
-        for (Account a : db.accounts().byPreferredEmail(email)) {
-          r.add(a.getId());
-        }
-        for (AccountState accountState : accountQueryProvider.get().byEmailPrefix(email)) {
-          if (accountState
-              .getExternalIds()
-              .stream()
-              .filter(e -> email.equals(e.email()))
-              .findAny()
-              .isPresent()) {
-            r.add(accountState.getAccount().getId());
-          }
-        }
-        return ImmutableSet.copyOf(r);
-      }
+      return externalIds
+          .get()
+          .byEmail(email)
+          .stream()
+          .map(e -> e.accountId())
+          .collect(toImmutableSet());
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
index 9b2f609..866a423 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
@@ -22,12 +22,12 @@
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
@@ -58,14 +58,14 @@
   private static final String BYID_NAME = "accounts";
   private static final String BYUSER_NAME = "accounts_byname";
 
-  public static Module module(boolean useReviewdb) {
+  public static Module module() {
     return new CacheModule() {
       @Override
       protected void configure() {
         cache(BYID_NAME, Account.Id.class, AccountState.class).loader(ByIdLoader.class);
 
         cache(BYUSER_NAME, String.class, new TypeLiteral<Optional<Account.Id>>() {})
-            .loader(useReviewdb ? ByNameReviewDbLoader.class : ByNameLoader.class);
+            .loader(ByNameLoader.class);
 
         bind(AccountCacheImpl.class);
         bind(AccountCache.class).to(AccountCacheImpl.class);
@@ -150,6 +150,7 @@
     private final GeneralPreferencesLoader loader;
     private final LoadingCache<String, Optional<Account.Id>> byName;
     private final Provider<WatchConfig.Accessor> watchConfig;
+    private final ExternalIds externalIds;
 
     @Inject
     ByIdLoader(
@@ -157,12 +158,14 @@
         GroupCache groupCache,
         GeneralPreferencesLoader loader,
         @Named(BYUSER_NAME) LoadingCache<String, Optional<Account.Id>> byUsername,
-        Provider<WatchConfig.Accessor> watchConfig) {
+        Provider<WatchConfig.Accessor> watchConfig,
+        ExternalIds externalIds) {
       this.schema = sf;
       this.groupCache = groupCache;
       this.loader = loader;
       this.byName = byUsername;
       this.watchConfig = watchConfig;
+      this.externalIds = externalIds;
     }
 
     @Override
@@ -185,9 +188,6 @@
         return missing(who);
       }
 
-      Set<ExternalId> externalIds =
-          ExternalId.from(db.accountExternalIds().byAccount(who).toList());
-
       Set<AccountGroup.UUID> internalGroups = new HashSet<>();
       for (AccountGroupMember g : db.accountGroupMembers().byAccount(who)) {
         final AccountGroup.Id groupId = g.getAccountGroupId();
@@ -206,25 +206,10 @@
       }
 
       return new AccountState(
-          account, internalGroups, externalIds, watchConfig.get().getProjectWatches(who));
-    }
-  }
-
-  static class ByNameReviewDbLoader extends CacheLoader<String, Optional<Account.Id>> {
-    private final Provider<ReviewDb> dbProvider;
-
-    @Inject
-    public ByNameReviewDbLoader(Provider<ReviewDb> dbProvider) {
-      this.dbProvider = dbProvider;
-    }
-
-    @Override
-    public Optional<Account.Id> load(String username) throws Exception {
-      ReviewDb db = dbProvider.get();
-      return Optional.ofNullable(
-              db.accountExternalIds()
-                  .get(new AccountExternalId.Key(SCHEME_USERNAME + ":" + username)))
-          .map(AccountExternalId::getAccountId);
+          account,
+          internalGroups,
+          externalIds.byAccount(who),
+          watchConfig.get().getProjectWatches(who));
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
index 77d28f9..49a20fa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.account;
 
-import static java.util.stream.Collectors.toSet;
-
 import com.google.common.base.Strings;
 import com.google.gerrit.audit.AuditService;
 import com.google.gerrit.common.TimeUtil;
@@ -29,6 +27,9 @@
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtorm.server.OrmException;
@@ -51,6 +52,7 @@
   private static final Logger log = LoggerFactory.getLogger(AccountManager.class);
 
   private final SchemaFactory<ReviewDb> schema;
+  private final AccountsUpdate.Server accountsUpdateFactory;
   private final AccountCache byIdCache;
   private final AccountByEmailCache byEmailCache;
   private final Realm realm;
@@ -60,11 +62,13 @@
   private final AtomicBoolean awaitsFirstAccountCheck;
   private final AuditService auditService;
   private final Provider<InternalAccountQuery> accountQueryProvider;
+  private final ExternalIds externalIds;
   private final ExternalIdsUpdate.Server externalIdsUpdateFactory;
 
   @Inject
   AccountManager(
       SchemaFactory<ReviewDb> schema,
+      AccountsUpdate.Server accountsUpdateFactory,
       AccountCache byIdCache,
       AccountByEmailCache byEmailCache,
       Realm accountMapper,
@@ -73,8 +77,10 @@
       ProjectCache projectCache,
       AuditService auditService,
       Provider<InternalAccountQuery> accountQueryProvider,
+      ExternalIds externalIds,
       ExternalIdsUpdate.Server externalIdsUpdateFactory) {
     this.schema = schema;
+    this.accountsUpdateFactory = accountsUpdateFactory;
     this.byIdCache = byIdCache;
     this.byEmailCache = byEmailCache;
     this.realm = accountMapper;
@@ -84,6 +90,7 @@
     this.awaitsFirstAccountCheck = new AtomicBoolean(true);
     this.auditService = auditService;
     this.accountQueryProvider = accountQueryProvider;
+    this.externalIds = externalIds;
     this.externalIdsUpdateFactory = externalIdsUpdateFactory;
   }
 
@@ -164,9 +171,7 @@
       externalIdsUpdateFactory
           .create()
           .replace(
-              db,
-              extId,
-              ExternalId.create(extId.key(), extId.accountId(), newEmail, extId.password()));
+              extId, ExternalId.create(extId.key(), extId.accountId(), newEmail, extId.password()));
     }
 
     if (!realm.allowsEdit(AccountFieldName.FULL_NAME)
@@ -225,13 +230,13 @@
         awaitsFirstAccountCheck.getAndSet(false) && db.accounts().anyAccounts().toList().isEmpty();
 
     try {
-      db.accounts().upsert(Collections.singleton(account));
+      AccountsUpdate accountsUpdate = accountsUpdateFactory.create();
+      accountsUpdate.upsert(db, account);
 
-      ExternalId existingExtId =
-          ExternalId.from(db.accountExternalIds().get(extId.key().asAccountExternalIdKey()));
+      ExternalId existingExtId = externalIds.get(extId.key());
       if (existingExtId != null && !existingExtId.accountId().equals(extId.accountId())) {
         // external ID is assigned to another account, do not overwrite
-        db.accounts().delete(Collections.singleton(account));
+        accountsUpdate.delete(db, account);
         throw new AccountException(
             "Cannot assign external ID \""
                 + extId.key().get()
@@ -239,7 +244,7 @@
                 + newId
                 + "; external ID already in use.");
       }
-      externalIdsUpdateFactory.create().upsert(db, extId);
+      externalIdsUpdateFactory.create().upsert(extId);
     } finally {
       // If adding the account failed, it may be that it actually was the
       // first account. So we reset the 'check for first account'-guard, as
@@ -272,7 +277,7 @@
       //
       IdentifiedUser user = userFactory.create(newId);
       try {
-        changeUserNameFactory.create(db, user, who.getUserName()).call();
+        changeUserNameFactory.create(user, who.getUserName()).call();
       } catch (NameAlreadyUsedException e) {
         String message =
             "Cannot assign user name \""
@@ -339,8 +344,8 @@
       // such an account cannot be used for uploading changes,
       // this is why the best we can do here is to fail early and cleanup
       // the database
-      db.accounts().delete(Collections.singleton(account));
-      externalIdsUpdateFactory.create().delete(db, extId);
+      accountsUpdateFactory.create().delete(db, account);
+      externalIdsUpdateFactory.create().delete(extId);
       throw new AccountUserNameException(errorMessage, e);
     }
   }
@@ -366,8 +371,7 @@
       } else {
         externalIdsUpdateFactory
             .create()
-            .insert(
-                db, ExternalId.createWithEmail(who.getExternalIdKey(), to, who.getEmailAddress()));
+            .insert(ExternalId.createWithEmail(who.getExternalIdKey(), to, who.getEmailAddress()));
 
         if (who.getEmailAddress() != null) {
           Account a = db.accounts().get(to);
@@ -402,25 +406,20 @@
    */
   public AuthResult updateLink(Account.Id to, AuthRequest who)
       throws OrmException, AccountException, IOException, ConfigInvalidException {
-    try (ReviewDb db = schema.open()) {
-      Collection<ExternalId> filteredExtIdsByScheme =
-          ExternalId.from(db.accountExternalIds().byAccount(to).toList())
-              .stream()
-              .filter(e -> e.isScheme(who.getExternalIdKey().scheme()))
-              .collect(toSet());
+    Collection<ExternalId> filteredExtIdsByScheme =
+        externalIds.byAccount(to, who.getExternalIdKey().scheme());
 
-      if (!filteredExtIdsByScheme.isEmpty()
-          && (filteredExtIdsByScheme.size() > 1
-              || !filteredExtIdsByScheme
-                  .stream()
-                  .filter(e -> e.key().equals(who.getExternalIdKey()))
-                  .findAny()
-                  .isPresent())) {
-        externalIdsUpdateFactory.create().delete(db, filteredExtIdsByScheme);
-      }
-      byIdCache.evict(to);
-      return link(to, who);
+    if (!filteredExtIdsByScheme.isEmpty()
+        && (filteredExtIdsByScheme.size() > 1
+            || !filteredExtIdsByScheme
+                .stream()
+                .filter(e -> e.key().equals(who.getExternalIdKey()))
+                .findAny()
+                .isPresent())) {
+      externalIdsUpdateFactory.create().delete(filteredExtIdsByScheme);
     }
+    byIdCache.evict(to);
+    return link(to, who);
   }
 
   /**
@@ -441,7 +440,7 @@
           throw new AccountException(
               "Identity '" + who.getExternalIdKey().get() + "' in use by another account");
         }
-        externalIdsUpdateFactory.create().delete(db, extId);
+        externalIdsUpdateFactory.create().delete(extId);
 
         if (who.getEmailAddress() != null) {
           Account a = db.accounts().get(from);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
index 4b9b0fb..1eaf34f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_MAILTO;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.base.Function;
 import com.google.common.base.Strings;
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.Map;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsUpdate.java
new file mode 100644
index 0000000..de87fc1
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -0,0 +1,251 @@
+// Copyright (C) 2017 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.account;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.RefUpdate.Result;
+import org.eclipse.jgit.lib.Repository;
+
+/** Updates accounts. */
+@Singleton
+public class AccountsUpdate {
+  /**
+   * Factory to create an AccountsUpdate instance for updating accounts by the Gerrit server.
+   *
+   * <p>The Gerrit server identity will be used as author and committer for all commits that update
+   * the accounts.
+   */
+  @Singleton
+  public static class Server {
+    private final GitRepositoryManager repoManager;
+    private final AllUsersName allUsersName;
+    private final Provider<PersonIdent> serverIdent;
+
+    @Inject
+    public Server(
+        GitRepositoryManager repoManager,
+        AllUsersName allUsersName,
+        @GerritPersonIdent Provider<PersonIdent> serverIdent) {
+      this.repoManager = repoManager;
+      this.allUsersName = allUsersName;
+      this.serverIdent = serverIdent;
+    }
+
+    public AccountsUpdate create() {
+      PersonIdent i = serverIdent.get();
+      return new AccountsUpdate(repoManager, allUsersName, i, i);
+    }
+  }
+
+  /**
+   * Factory to create an AccountsUpdate instance for updating accounts by the current user.
+   *
+   * <p>The identity of the current user will be used as author for all commits that update the
+   * accounts. The Gerrit server identity will be used as committer.
+   */
+  @Singleton
+  public static class User {
+    private final GitRepositoryManager repoManager;
+    private final AllUsersName allUsersName;
+    private final Provider<PersonIdent> serverIdent;
+    private final Provider<IdentifiedUser> identifiedUser;
+
+    @Inject
+    public User(
+        GitRepositoryManager repoManager,
+        AllUsersName allUsersName,
+        @GerritPersonIdent Provider<PersonIdent> serverIdent,
+        Provider<IdentifiedUser> identifiedUser) {
+      this.repoManager = repoManager;
+      this.allUsersName = allUsersName;
+      this.serverIdent = serverIdent;
+      this.identifiedUser = identifiedUser;
+    }
+
+    public AccountsUpdate create() {
+      PersonIdent i = serverIdent.get();
+      return new AccountsUpdate(
+          repoManager, allUsersName, createPersonIdent(i, identifiedUser.get()), i);
+    }
+
+    private PersonIdent createPersonIdent(PersonIdent ident, IdentifiedUser user) {
+      return user.newCommitterIdent(ident.getWhen(), ident.getTimeZone());
+    }
+  }
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final PersonIdent committerIdent;
+  private final PersonIdent authorIdent;
+
+  private AccountsUpdate(
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      PersonIdent committerIdent,
+      PersonIdent authorIdent) {
+    this.repoManager = checkNotNull(repoManager, "repoManager");
+    this.allUsersName = checkNotNull(allUsersName, "allUsersName");
+    this.committerIdent = checkNotNull(committerIdent, "committerIdent");
+    this.authorIdent = checkNotNull(authorIdent, "authorIdent");
+  }
+
+  /**
+   * Inserts a new account.
+   *
+   * @throws OrmDuplicateKeyException if the account already exists
+   * @throws IOException if updating the user branch fails
+   */
+  public void insert(ReviewDb db, Account account) throws OrmException, IOException {
+    db.accounts().insert(ImmutableSet.of(account));
+    createUserBranch(account);
+  }
+
+  /**
+   * Inserts or updates an account.
+   *
+   * <p>If the account already exists, it is overwritten, otherwise it is inserted.
+   */
+  public void upsert(ReviewDb db, Account account) throws OrmException, IOException {
+    db.accounts().upsert(ImmutableSet.of(account));
+    createUserBranchIfNeeded(account);
+  }
+
+  /** Deletes the account. */
+  public void delete(ReviewDb db, Account account) throws OrmException, IOException {
+    db.accounts().delete(ImmutableSet.of(account));
+    deleteUserBranch(account.getId());
+  }
+
+  /** Deletes the account. */
+  public void deleteByKey(ReviewDb db, Account.Id accountId) throws OrmException, IOException {
+    db.accounts().deleteKeys(ImmutableSet.of(accountId));
+    deleteUserBranch(accountId);
+  }
+
+  private void createUserBranch(Account account) throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsersName);
+        ObjectInserter oi = repo.newObjectInserter()) {
+      String refName = RefNames.refsUsers(account.getId());
+      if (repo.exactRef(refName) != null) {
+        throw new IOException(
+            String.format(
+                "User branch %s for newly created account %s already exists.",
+                refName, account.getId().get()));
+      }
+      createUserBranch(repo, oi, committerIdent, authorIdent, account);
+    }
+  }
+
+  private void createUserBranchIfNeeded(Account account) throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsersName);
+        ObjectInserter oi = repo.newObjectInserter()) {
+      if (repo.exactRef(RefNames.refsUsers(account.getId())) == null) {
+        createUserBranch(repo, oi, committerIdent, authorIdent, account);
+      }
+    }
+  }
+
+  public static void createUserBranch(
+      Repository repo,
+      ObjectInserter oi,
+      PersonIdent committerIdent,
+      PersonIdent authorIdent,
+      Account account)
+      throws IOException {
+    ObjectId id =
+        createInitialEmptyCommit(oi, committerIdent, authorIdent, account.getRegisteredOn());
+
+    String refName = RefNames.refsUsers(account.getId());
+    RefUpdate ru = repo.updateRef(refName);
+    ru.setExpectedOldObjectId(ObjectId.zeroId());
+    ru.setNewObjectId(id);
+    ru.setForceUpdate(true);
+    ru.setRefLogIdent(committerIdent);
+    ru.setRefLogMessage("Create Account", true);
+    Result result = ru.update();
+    if (result != Result.NEW) {
+      throw new IOException(String.format("Failed to update ref %s: %s", refName, result.name()));
+    }
+  }
+
+  private static ObjectId createInitialEmptyCommit(
+      ObjectInserter oi,
+      PersonIdent committerIdent,
+      PersonIdent authorIdent,
+      Timestamp registrationDate)
+      throws IOException {
+    CommitBuilder cb = new CommitBuilder();
+    cb.setTreeId(emptyTree(oi));
+    cb.setCommitter(new PersonIdent(committerIdent, registrationDate));
+    cb.setAuthor(new PersonIdent(authorIdent, registrationDate));
+    cb.setMessage("Create Account");
+    ObjectId id = oi.insert(cb);
+    oi.flush();
+    return id;
+  }
+
+  private static ObjectId emptyTree(ObjectInserter oi) throws IOException {
+    return oi.insert(Constants.OBJ_TREE, new byte[] {});
+  }
+
+  private void deleteUserBranch(Account.Id accountId) throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      deleteUserBranch(repo, committerIdent, accountId);
+    }
+  }
+
+  public static void deleteUserBranch(
+      Repository repo, PersonIdent refLogIdent, Account.Id accountId) throws IOException {
+    String refName = RefNames.refsUsers(accountId);
+    Ref ref = repo.exactRef(refName);
+    if (ref == null) {
+      return;
+    }
+
+    RefUpdate ru = repo.updateRef(refName);
+    ru.setExpectedOldObjectId(ref.getObjectId());
+    ru.setNewObjectId(ObjectId.zeroId());
+    ru.setForceUpdate(true);
+    ru.setRefLogIdent(refLogIdent);
+    ru.setRefLogMessage("Delete Account", true);
+    Result result = ru.delete();
+    if (result != Result.FORCED) {
+      throw new IOException(String.format("Failed to delete ref %s: %s", refName, result.name()));
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java
index 8c10c73..1c5495f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java
@@ -30,6 +30,9 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AddSshKey.Input;
 import com.google.gerrit.server.mail.send.AddKeySender;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -50,6 +53,7 @@
   }
 
   private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final SshKeyCache sshKeyCache;
   private final AddKeySender.Factory addKeyFactory;
@@ -57,10 +61,12 @@
   @Inject
   AddSshKey(
       Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
       VersionedAuthorizedKeys.Accessor authorizedKeys,
       SshKeyCache sshKeyCache,
       AddKeySender.Factory addKeyFactory) {
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.authorizedKeys = authorizedKeys;
     this.sshKeyCache = sshKeyCache;
     this.addKeyFactory = addKeyFactory;
@@ -68,9 +74,10 @@
 
   @Override
   public Response<SshKeyInfo> apply(AccountResource rsrc, Input input)
-      throws AuthException, BadRequestException, OrmException, IOException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("not allowed to add SSH keys");
+      throws AuthException, BadRequestException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
     return apply(rsrc.getUser(), input);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java
index d1dd4b0..4dd9926 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_EXTERNAL;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_EXTERNAL;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
+
+import com.google.gerrit.server.account.externalids.ExternalId;
 
 /**
  * Information for {@link AccountManager#authenticate(AuthRequest)}.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthResult.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthResult.java
index 4aced52..2b1bc96 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthResult.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthResult.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.externalids.ExternalId;
 
 /** Result from {@link AccountManager#authenticate(AuthRequest)}. */
 public class AuthResult {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Capabilities.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Capabilities.java
index d35656c..08eecd7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Capabilities.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Capabilities.java
@@ -14,7 +14,8 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.api.access.PluginPermission;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
@@ -22,7 +23,11 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource.Capability;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -30,15 +35,18 @@
 @Singleton
 class Capabilities implements ChildCollection<AccountResource, AccountResource.Capability> {
   private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
   private final DynamicMap<RestView<AccountResource.Capability>> views;
   private final Provider<GetCapabilities> get;
 
   @Inject
   Capabilities(
       Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
       DynamicMap<RestView<AccountResource.Capability>> views,
       Provider<GetCapabilities> get) {
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.views = views;
     this.get = get;
   }
@@ -50,20 +58,39 @@
 
   @Override
   public Capability parse(AccountResource parent, IdString id)
-      throws ResourceNotFoundException, AuthException {
-    if (self.get() != parent.getUser() && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("restricted to administrator");
+      throws ResourceNotFoundException, AuthException, PermissionBackendException {
+    IdentifiedUser target = parent.getUser();
+    if (self.get() != target) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
 
-    String name = id.get();
-    CapabilityControl cap = parent.getUser().getCapabilities();
-    if (cap.canPerform(name)
-        || (cap.canAdministrateServer() && GlobalCapability.isCapability(name))) {
-      return new AccountResource.Capability(parent.getUser(), name);
+    GlobalOrPluginPermission perm = parse(id);
+    if (permissionBackend.user(target).test(perm)) {
+      return new AccountResource.Capability(target, perm.permissionName());
     }
     throw new ResourceNotFoundException(id);
   }
 
+  private GlobalOrPluginPermission parse(IdString id) throws ResourceNotFoundException {
+    String name = id.get();
+    GlobalOrPluginPermission perm = GlobalPermission.byName(name);
+    if (perm != null) {
+      return perm;
+    }
+
+    int dash = name.lastIndexOf('-');
+    if (dash < 0) {
+      throw new ResourceNotFoundException(id);
+    }
+
+    String pluginName = name.substring(0, dash);
+    String capability = name.substring(dash + 1);
+    if (pluginName.isEmpty() || capability.isEmpty()) {
+      throw new ResourceNotFoundException(id);
+    }
+    return new PluginPermission(pluginName, capability);
+  }
+
   @Override
   public DynamicMap<RestView<Capability>> views() {
     return views;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityControl.java
index 66d0bf9..01e16d7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityControl.java
@@ -22,11 +22,18 @@
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.api.access.PluginPermission;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PeerDaemonUser;
 import com.google.gerrit.server.git.QueueProvider;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.RefControl;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.ArrayList;
@@ -61,8 +68,23 @@
     return user;
   }
 
-  /** @return true if the user can administer this server. */
-  public boolean canAdministrateServer() {
+  /**
+   * <b>Do not use.</b> Determine if the user can administer this server.
+   *
+   * <p>This method is visible only for the benefit of the following transitional classes:
+   *
+   * <ul>
+   *   <li>{@link ProjectControl}
+   *   <li>{@link RefControl}
+   *   <li>{@link ChangeControl}
+   *   <li>{@link GroupControl}
+   * </ul>
+   *
+   * Other callers should not use this method, as it is slated to go away.
+   *
+   * @return true if the user can administer this server.
+   */
+  public boolean isAdmin_DoNotUse() {
     if (canAdministrateServer == null) {
       if (user.getRealUser() != user) {
         canAdministrateServer = false;
@@ -75,21 +97,6 @@
     return canAdministrateServer;
   }
 
-  /** @return true if the user can create an account for another user. */
-  public boolean canCreateAccount() {
-    return canPerform(GlobalCapability.CREATE_ACCOUNT) || canAdministrateServer();
-  }
-
-  /** @return true if the user can create a group. */
-  public boolean canCreateGroup() {
-    return canPerform(GlobalCapability.CREATE_GROUP) || canAdministrateServer();
-  }
-
-  /** @return true if the user can create a project. */
-  public boolean canCreateProject() {
-    return canPerform(GlobalCapability.CREATE_PROJECT) || canAdministrateServer();
-  }
-
   /** @return true if the user can email reviewers. */
   public boolean canEmailReviewers() {
     if (canEmailReviewers == null) {
@@ -100,69 +107,18 @@
     return canEmailReviewers;
   }
 
-  /** @return true if the user can kill any running task. */
-  public boolean canKillTask() {
-    return canPerform(GlobalCapability.KILL_TASK) || canMaintainServer();
-  }
-
-  /** @return true if the user can modify an account for another user. */
-  public boolean canModifyAccount() {
-    return canPerform(GlobalCapability.MODIFY_ACCOUNT) || canAdministrateServer();
-  }
-
   /** @return true if the user can view all accounts. */
   public boolean canViewAllAccounts() {
-    return canPerform(GlobalCapability.VIEW_ALL_ACCOUNTS) || canAdministrateServer();
-  }
-
-  /** @return true if the user can view the server caches. */
-  public boolean canViewCaches() {
-    return canPerform(GlobalCapability.VIEW_CACHES) || canMaintainServer();
-  }
-
-  /** @return true if the user can flush the server's caches. */
-  public boolean canFlushCaches() {
-    return canPerform(GlobalCapability.FLUSH_CACHES) || canMaintainServer();
-  }
-
-  /** @return true if the user can perform basic server maintenance. */
-  public boolean canMaintainServer() {
-    return canPerform(GlobalCapability.MAINTAIN_SERVER) || canAdministrateServer();
-  }
-
-  /** @return true if the user can view open connections. */
-  public boolean canViewConnections() {
-    return canPerform(GlobalCapability.VIEW_CONNECTIONS) || canAdministrateServer();
-  }
-
-  /** @return true if the user can view the installed plugins. */
-  public boolean canViewPlugins() {
-    return canPerform(GlobalCapability.VIEW_PLUGINS) || canAdministrateServer();
-  }
-
-  /** @return true if the user can view the entire queue. */
-  public boolean canViewQueue() {
-    return canPerform(GlobalCapability.VIEW_QUEUE) || canMaintainServer();
+    return canPerform(GlobalCapability.VIEW_ALL_ACCOUNTS) || isAdmin_DoNotUse();
   }
 
   /** @return true if the user can access the database (with gsql). */
   public boolean canAccessDatabase() {
-    return canPerform(GlobalCapability.ACCESS_DATABASE);
-  }
-
-  /** @return true if the user can stream Gerrit events. */
-  public boolean canStreamEvents() {
-    return canPerform(GlobalCapability.STREAM_EVENTS) || canAdministrateServer();
-  }
-
-  /** @return true if the user can run the Git garbage collection. */
-  public boolean canRunGC() {
-    return canPerform(GlobalCapability.RUN_GC) || canMaintainServer();
-  }
-
-  /** @return true if the user can impersonate another user. */
-  public boolean canRunAs() {
-    return canPerform(GlobalCapability.RUN_AS);
+    try {
+      return doCanForDefaultPermissionBackend(GlobalPermission.ACCESS_DATABASE);
+    } catch (PermissionBackendException e) {
+      return false;
+    }
   }
 
   /** @return which priority queue the user's tasks should be submitted to. */
@@ -204,14 +160,16 @@
     return QueueProvider.QueueType.INTERACTIVE;
   }
 
-  /** True if the user has this permission. Works only for non labels. */
-  public boolean canPerform(String permissionName) {
-    if (GlobalCapability.ADMINISTRATE_SERVER.equals(permissionName)) {
-      return canAdministrateServer();
-    }
+  /** @return true if the user has this permission. */
+  private boolean canPerform(String permissionName) {
     return !access(permissionName).isEmpty();
   }
 
+  /** @return true if the user has a permission rule specifying the range. */
+  public boolean hasExplicitRange(String permission) {
+    return GlobalCapability.hasRange(permission) && !access(permission).isEmpty();
+  }
+
   /** The range of permitted values associated with a label permission. */
   public PermissionRange getRange(String permission) {
     if (GlobalCapability.hasRange(permission)) {
@@ -273,4 +231,50 @@
   private static boolean match(GroupMembership groups, PermissionRule rule) {
     return groups.contains(rule.getGroup().getUUID());
   }
+
+  /** Do not use unless inside DefaultPermissionBackend. */
+  public boolean doCanForDefaultPermissionBackend(GlobalOrPluginPermission perm)
+      throws PermissionBackendException {
+    if (perm instanceof GlobalPermission) {
+      return can((GlobalPermission) perm);
+    } else if (perm instanceof PluginPermission) {
+      return canPerform(perm.permissionName()) || isAdmin_DoNotUse();
+    }
+    throw new PermissionBackendException(perm + " unsupported");
+  }
+
+  private boolean can(GlobalPermission perm) throws PermissionBackendException {
+    switch (perm) {
+      case ADMINISTRATE_SERVER:
+        return isAdmin_DoNotUse();
+      case EMAIL_REVIEWERS:
+        return canEmailReviewers();
+      case VIEW_ALL_ACCOUNTS:
+        return canViewAllAccounts();
+
+      case FLUSH_CACHES:
+      case KILL_TASK:
+      case RUN_GC:
+      case VIEW_CACHES:
+      case VIEW_QUEUE:
+        return canPerform(perm.permissionName())
+            || canPerform(GlobalCapability.MAINTAIN_SERVER)
+            || isAdmin_DoNotUse();
+
+      case CREATE_ACCOUNT:
+      case CREATE_GROUP:
+      case CREATE_PROJECT:
+      case MAINTAIN_SERVER:
+      case MODIFY_ACCOUNT:
+      case STREAM_EVENTS:
+      case VIEW_CONNECTIONS:
+      case VIEW_PLUGINS:
+        return canPerform(perm.permissionName()) || isAdmin_DoNotUse();
+
+      case ACCESS_DATABASE:
+      case RUN_AS:
+        return canPerform(perm.permissionName());
+    }
+    throw new PermissionBackendException(perm + " unsupported");
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityUtils.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityUtils.java
deleted file mode 100644
index 21399f4..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityUtils.java
+++ /dev/null
@@ -1,132 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.extensions.annotations.CapabilityScope;
-import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.CurrentUser;
-import com.google.inject.Provider;
-import java.lang.annotation.Annotation;
-import java.util.Arrays;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class CapabilityUtils {
-  private static final Logger log = LoggerFactory.getLogger(CapabilityUtils.class);
-
-  public static void checkRequiresCapability(
-      Provider<CurrentUser> userProvider, String pluginName, Class<?> clazz) throws AuthException {
-    checkRequiresCapability(userProvider.get(), pluginName, clazz);
-  }
-
-  public static void checkRequiresCapability(CurrentUser user, String pluginName, Class<?> clazz)
-      throws AuthException {
-    RequiresCapability rc = getClassAnnotation(clazz, RequiresCapability.class);
-    RequiresAnyCapability rac = getClassAnnotation(clazz, RequiresAnyCapability.class);
-    if (rc != null && rac != null) {
-      log.error(
-          String.format(
-              "Class %s uses both @%s and @%s",
-              clazz.getName(),
-              RequiresCapability.class.getSimpleName(),
-              RequiresAnyCapability.class.getSimpleName()));
-      throw new AuthException("cannot check capability");
-    }
-    CapabilityControl ctl = user.getCapabilities();
-    if (ctl.canAdministrateServer()) {
-      return;
-    }
-    checkRequiresCapability(ctl, pluginName, clazz, rc);
-    checkRequiresAnyCapability(ctl, pluginName, clazz, rac);
-  }
-
-  private static void checkRequiresCapability(
-      CapabilityControl ctl, String pluginName, Class<?> clazz, RequiresCapability rc)
-      throws AuthException {
-    if (rc == null) {
-      return;
-    }
-    String capability = resolveCapability(pluginName, rc.value(), rc.scope(), clazz);
-    if (!ctl.canPerform(capability)) {
-      throw new AuthException(
-          String.format("Capability %s is required to access this resource", capability));
-    }
-  }
-
-  private static void checkRequiresAnyCapability(
-      CapabilityControl ctl, String pluginName, Class<?> clazz, RequiresAnyCapability rac)
-      throws AuthException {
-    if (rac == null) {
-      return;
-    }
-    if (rac.value().length == 0) {
-      log.error(
-          String.format(
-              "Class %s uses @%s with no capabilities listed",
-              clazz.getName(), RequiresAnyCapability.class.getSimpleName()));
-      throw new AuthException("cannot check capability");
-    }
-    for (String capability : rac.value()) {
-      capability = resolveCapability(pluginName, capability, rac.scope(), clazz);
-      if (ctl.canPerform(capability)) {
-        return;
-      }
-    }
-    throw new AuthException(
-        "One of the following capabilities is required to access this"
-            + " resource: "
-            + Arrays.asList(rac.value()));
-  }
-
-  private static String resolveCapability(
-      String pluginName, String capability, CapabilityScope scope, Class<?> clazz)
-      throws AuthException {
-    if (pluginName != null
-        && !"gerrit".equals(pluginName)
-        && (scope == CapabilityScope.PLUGIN || scope == CapabilityScope.CONTEXT)) {
-      capability = String.format("%s-%s", pluginName, capability);
-    } else if (scope == CapabilityScope.PLUGIN) {
-      log.error(
-          String.format(
-              "Class %s uses @%s(scope=%s), but is not within a plugin",
-              clazz.getName(),
-              RequiresCapability.class.getSimpleName(),
-              CapabilityScope.PLUGIN.name()));
-      throw new AuthException("cannot check capability");
-    }
-    return capability;
-  }
-
-  /**
-   * Find an instance of the specified annotation, walking up the inheritance tree if necessary.
-   *
-   * @param <T> Annotation type to search for
-   * @param clazz root class to search, may be null
-   * @param annotationClass class object of Annotation subclass to search for
-   * @return the requested annotation or null if none
-   */
-  private static <T extends Annotation> T getClassAnnotation(
-      Class<?> clazz, Class<T> annotationClass) {
-    for (; clazz != null; clazz = clazz.getSuperclass()) {
-      T t = clazz.getAnnotation(annotationClass);
-      if (t != null) {
-        return t;
-      }
-    }
-    return null;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java
index f60ee45..d2a9610 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java
@@ -14,14 +14,15 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
-import static java.util.stream.Collectors.toSet;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.errors.NameAlreadyUsedException;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.gwtjsonrpc.common.VoidResult;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
@@ -42,14 +43,14 @@
 
   /** Generic factory to change any user's username. */
   public interface Factory {
-    ChangeUserName create(ReviewDb db, IdentifiedUser user, String newUsername);
+    ChangeUserName create(IdentifiedUser user, String newUsername);
   }
 
   private final AccountCache accountCache;
   private final SshKeyCache sshKeyCache;
+  private final ExternalIds externalIds;
   private final ExternalIdsUpdate.Server externalIdsUpdateFactory;
 
-  private final ReviewDb db;
   private final IdentifiedUser user;
   private final String newUsername;
 
@@ -57,14 +58,14 @@
   ChangeUserName(
       AccountCache accountCache,
       SshKeyCache sshKeyCache,
+      ExternalIds externalIds,
       ExternalIdsUpdate.Server externalIdsUpdateFactory,
-      @Assisted ReviewDb db,
       @Assisted IdentifiedUser user,
       @Nullable @Assisted String newUsername) {
     this.accountCache = accountCache;
     this.sshKeyCache = sshKeyCache;
+    this.externalIds = externalIds;
     this.externalIdsUpdateFactory = externalIdsUpdateFactory;
-    this.db = db;
     this.user = user;
     this.newUsername = newUsername;
   }
@@ -73,11 +74,7 @@
   public VoidResult call()
       throws OrmException, NameAlreadyUsedException, InvalidUserNameException, IOException,
           ConfigInvalidException {
-    Collection<ExternalId> old =
-        ExternalId.from(db.accountExternalIds().byAccount(user.getAccountId()).toList())
-            .stream()
-            .filter(e -> e.isScheme(SCHEME_USERNAME))
-            .collect(toSet());
+    Collection<ExternalId> old = externalIds.byAccount(user.getAccountId(), SCHEME_USERNAME);
     if (!old.isEmpty()) {
       throw new IllegalStateException(USERNAME_CANNOT_BE_CHANGED);
     }
@@ -96,12 +93,11 @@
             password = i.password();
           }
         }
-        externalIdsUpdate.insert(db, ExternalId.create(key, user.getAccountId(), null, password));
+        externalIdsUpdate.insert(ExternalId.create(key, user.getAccountId(), null, password));
       } catch (OrmDuplicateKeyException dupeErr) {
         // If we are using this identity, don't report the exception.
         //
-        ExternalId other =
-            ExternalId.from(db.accountExternalIds().get(key.asAccountExternalIdKey()));
+        ExternalId other = externalIds.get(key);
         if (other != null && other.accountId().equals(user.getAccountId())) {
           return VoidResult.INSTANCE;
         }
@@ -114,7 +110,7 @@
 
     // If we have any older user names, remove them.
     //
-    externalIdsUpdate.delete(db, old);
+    externalIdsUpdate.delete(old);
     for (ExternalId extId : old) {
       sshKeyCache.evict(extId.key().id());
       accountCache.evictByUsername(extId.key().id());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
index 9e7e9a4d..5ea5e96 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
 
 import com.google.gerrit.audit.AuditService;
 import com.google.gerrit.common.TimeUtil;
@@ -36,6 +36,9 @@
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.api.accounts.AccountExternalIdCreator;
 import com.google.gerrit.server.group.GroupsCollection;
 import com.google.gerrit.server.index.account.AccountIndexer;
@@ -66,12 +69,15 @@
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final SshKeyCache sshKeyCache;
   private final AccountCache accountCache;
+  private final AccountsUpdate.User accountsUpdate;
   private final AccountIndexer indexer;
   private final AccountByEmailCache byEmailCache;
   private final AccountLoader.Factory infoLoader;
   private final DynamicSet<AccountExternalIdCreator> externalIdCreators;
   private final AuditService auditService;
+  private final ExternalIds externalIds;
   private final ExternalIdsUpdate.User externalIdsUpdateFactory;
+  private final OutgoingEmailValidator validator;
   private final String username;
 
   @Inject
@@ -82,12 +88,15 @@
       VersionedAuthorizedKeys.Accessor authorizedKeys,
       SshKeyCache sshKeyCache,
       AccountCache accountCache,
+      AccountsUpdate.User accountsUpdate,
       AccountIndexer indexer,
       AccountByEmailCache byEmailCache,
       AccountLoader.Factory infoLoader,
       DynamicSet<AccountExternalIdCreator> externalIdCreators,
       AuditService auditService,
+      ExternalIds externalIds,
       ExternalIdsUpdate.User externalIdsUpdateFactory,
+      OutgoingEmailValidator validator,
       @Assisted String username) {
     this.db = db;
     this.currentUser = currentUser;
@@ -95,12 +104,15 @@
     this.authorizedKeys = authorizedKeys;
     this.sshKeyCache = sshKeyCache;
     this.accountCache = accountCache;
+    this.accountsUpdate = accountsUpdate;
     this.indexer = indexer;
     this.byEmailCache = byEmailCache;
     this.infoLoader = infoLoader;
     this.externalIdCreators = externalIdCreators;
     this.auditService = auditService;
+    this.externalIds = externalIds;
     this.externalIdsUpdateFactory = externalIdsUpdateFactory;
+    this.validator = validator;
     this.username = username;
   }
 
@@ -125,16 +137,14 @@
     Account.Id id = new Account.Id(db.nextAccountId());
 
     ExternalId extUser = ExternalId.createUsername(username, id, input.httpPassword);
-    if (db.accountExternalIds().get(extUser.key().asAccountExternalIdKey()) != null) {
+    if (externalIds.get(extUser.key()) != null) {
       throw new ResourceConflictException("username '" + username + "' already exists");
     }
     if (input.email != null) {
-      if (db.accountExternalIds()
-              .get(ExternalId.Key.create(SCHEME_MAILTO, input.email).asAccountExternalIdKey())
-          != null) {
+      if (externalIds.get(ExternalId.Key.create(SCHEME_MAILTO, input.email)) != null) {
         throw new UnprocessableEntityException("email '" + input.email + "' already exists");
       }
-      if (!OutgoingEmailValidator.isValid(input.email)) {
+      if (!validator.isValid(input.email)) {
         throw new BadRequestException("invalid email address");
       }
     }
@@ -147,18 +157,18 @@
 
     ExternalIdsUpdate externalIdsUpdate = externalIdsUpdateFactory.create();
     try {
-      externalIdsUpdate.insert(db, extIds);
+      externalIdsUpdate.insert(extIds);
     } catch (OrmDuplicateKeyException duplicateKey) {
       throw new ResourceConflictException("username '" + username + "' already exists");
     }
 
     if (input.email != null) {
       try {
-        externalIdsUpdate.insert(db, ExternalId.createEmail(id, input.email));
+        externalIdsUpdate.insert(ExternalId.createEmail(id, input.email));
       } catch (OrmDuplicateKeyException duplicateKey) {
         try {
-          externalIdsUpdate.delete(db, extUser);
-        } catch (IOException | ConfigInvalidException | OrmException cleanupError) {
+          externalIdsUpdate.delete(extUser);
+        } catch (IOException | ConfigInvalidException cleanupError) {
           // Ignored
         }
         throw new UnprocessableEntityException("email '" + input.email + "' already exists");
@@ -168,7 +178,7 @@
     Account a = new Account(id, TimeUtil.nowTs());
     a.setFullName(input.name);
     a.setPreferredEmail(input.email);
-    db.accounts().insert(Collections.singleton(a));
+    accountsUpdate.create().insert(db, a);
 
     for (AccountGroup.Id groupId : groups) {
       AccountGroupMember m = new AccountGroupMember(new AccountGroupMember.Key(id, groupId));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java
index b1a5d3b..56e0c60 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java
@@ -32,6 +32,9 @@
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
 import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -50,9 +53,11 @@
 
   private final Provider<CurrentUser> self;
   private final Realm realm;
+  private final PermissionBackend permissionBackend;
   private final AccountManager accountManager;
   private final RegisterNewEmailSender.Factory registerNewEmailFactory;
   private final PutPreferred putPreferred;
+  private final OutgoingEmailValidator validator;
   private final String email;
   private final boolean isDevMode;
 
@@ -60,16 +65,20 @@
   CreateEmail(
       Provider<CurrentUser> self,
       Realm realm,
+      PermissionBackend permissionBackend,
       AuthConfig authConfig,
       AccountManager accountManager,
       RegisterNewEmailSender.Factory registerNewEmailFactory,
       PutPreferred putPreferred,
+      OutgoingEmailValidator validator,
       @Assisted String email) {
     this.self = self;
     this.realm = realm;
+    this.permissionBackend = permissionBackend;
     this.accountManager = accountManager;
     this.registerNewEmailFactory = registerNewEmailFactory;
     this.putPreferred = putPreferred;
+    this.validator = validator;
     this.email = email;
     this.isDevMode = authConfig.getAuthType() == DEVELOPMENT_BECOME_ANY_ACCOUNT;
   }
@@ -78,23 +87,19 @@
   public Response<EmailInfo> apply(AccountResource rsrc, EmailInput input)
       throws AuthException, BadRequestException, ResourceConflictException,
           ResourceNotFoundException, OrmException, EmailException, MethodNotAllowedException,
-          IOException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("not allowed to add email address");
+          IOException, ConfigInvalidException, PermissionBackendException {
+    if (self.get() != rsrc.getUser() || input.noConfirmation) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
     if (input == null) {
       input = new EmailInput();
     }
 
-    if (!OutgoingEmailValidator.isValid(email)) {
+    if (!validator.isValid(email)) {
       throw new BadRequestException("invalid email address");
     }
 
-    if (input.noConfirmation && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("not allowed to use no_confirmation");
-    }
-
     if (!realm.allowsEdit(AccountFieldName.REGISTER_NEW_EMAIL)) {
       throw new MethodNotAllowedException("realm does not allow adding emails");
     }
@@ -105,7 +110,7 @@
   public Response<EmailInfo> apply(IdentifiedUser user, EmailInput input)
       throws AuthException, BadRequestException, ResourceConflictException,
           ResourceNotFoundException, OrmException, EmailException, MethodNotAllowedException,
-          IOException, ConfigInvalidException {
+          IOException, ConfigInvalidException, PermissionBackendException {
     if (input.email != null && !email.equals(input.email)) {
       throw new BadRequestException("email address must match URL");
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
index 794a2c1..ca56eb1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
@@ -23,10 +23,14 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.DeleteEmail.Input;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -41,27 +45,31 @@
 
   private final Provider<CurrentUser> self;
   private final Realm realm;
-  private final Provider<ReviewDb> dbProvider;
+  private final PermissionBackend permissionBackend;
   private final AccountManager accountManager;
+  private final ExternalIds externalIds;
 
   @Inject
   DeleteEmail(
       Provider<CurrentUser> self,
       Realm realm,
-      Provider<ReviewDb> dbProvider,
-      AccountManager accountManager) {
+      PermissionBackend permissionBackend,
+      AccountManager accountManager,
+      ExternalIds externalIds) {
     this.self = self;
     this.realm = realm;
-    this.dbProvider = dbProvider;
+    this.permissionBackend = permissionBackend;
     this.accountManager = accountManager;
+    this.externalIds = externalIds;
   }
 
   @Override
   public Response<?> apply(AccountResource.Email rsrc, Input input)
       throws AuthException, ResourceNotFoundException, ResourceConflictException,
-          MethodNotAllowedException, OrmException, IOException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("not allowed to delete email address");
+          MethodNotAllowedException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
     return apply(rsrc.getUser(), rsrc.getEmail());
   }
@@ -74,13 +82,9 @@
     }
 
     Set<ExternalId> extIds =
-        dbProvider
-            .get()
-            .accountExternalIds()
+        externalIds
             .byAccount(user.getAccountId())
-            .toList()
             .stream()
-            .map(ExternalId::from)
             .filter(e -> email.equals(e.email()))
             .collect(toSet());
     if (extIds.isEmpty()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteExternalIds.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteExternalIds.java
index 42726dc..78eb8a7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteExternalIds.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteExternalIds.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 import static java.util.stream.Collectors.toMap;
 
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -24,9 +24,9 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -37,51 +37,38 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 public class DeleteExternalIds implements RestModifyView<AccountResource, List<String>> {
-  private final AccountByEmailCache accountByEmailCache;
-  private final AccountCache accountCache;
-  private final ExternalIdsUpdate.User externalIdsUpdateFactory;
+  private final AccountManager accountManager;
+  private final ExternalIds externalIds;
   private final Provider<CurrentUser> self;
-  private final Provider<ReviewDb> dbProvider;
 
   @Inject
   DeleteExternalIds(
-      AccountByEmailCache accountByEmailCache,
-      AccountCache accountCache,
-      ExternalIdsUpdate.User externalIdsUpdateFactory,
-      Provider<CurrentUser> self,
-      Provider<ReviewDb> dbProvider) {
-    this.accountByEmailCache = accountByEmailCache;
-    this.accountCache = accountCache;
-    this.externalIdsUpdateFactory = externalIdsUpdateFactory;
+      AccountManager accountManager, ExternalIds externalIds, Provider<CurrentUser> self) {
+    this.accountManager = accountManager;
+    this.externalIds = externalIds;
     this.self = self;
-    this.dbProvider = dbProvider;
   }
 
   @Override
-  public Response<?> apply(AccountResource resource, List<String> externalIds)
+  public Response<?> apply(AccountResource resource, List<String> extIds)
       throws RestApiException, IOException, OrmException, ConfigInvalidException {
     if (self.get() != resource.getUser()) {
       throw new AuthException("not allowed to delete external IDs");
     }
 
-    if (externalIds == null || externalIds.size() == 0) {
+    if (extIds == null || extIds.size() == 0) {
       throw new BadRequestException("external IDs are required");
     }
 
-    Account.Id accountId = resource.getUser().getAccountId();
     Map<ExternalId.Key, ExternalId> externalIdMap =
-        dbProvider
-            .get()
-            .accountExternalIds()
+        externalIds
             .byAccount(resource.getUser().getAccountId())
-            .toList()
             .stream()
-            .map(ExternalId::from)
             .collect(toMap(i -> i.key(), i -> i));
 
     List<ExternalId> toDelete = new ArrayList<>();
     ExternalId.Key last = resource.getUser().getLastLoginExternalIdKey();
-    for (String externalIdStr : externalIds) {
+    for (String externalIdStr : extIds) {
       ExternalId id = externalIdMap.get(ExternalId.Key.parse(externalIdStr));
 
       if (id == null) {
@@ -98,12 +85,14 @@
       }
     }
 
-    if (!toDelete.isEmpty()) {
-      externalIdsUpdateFactory.create().delete(dbProvider.get(), toDelete);
-      accountCache.evict(accountId);
-      for (ExternalId e : toDelete) {
-        accountByEmailCache.evict(e.email());
+    try {
+      for (ExternalId extId : toDelete) {
+        AuthRequest authRequest = new AuthRequest(extId.key());
+        authRequest.setEmailAddress(extId.email());
+        accountManager.unlink(extId.accountId(), authRequest);
       }
+    } catch (AccountException e) {
+      throw new ResourceConflictException(e.getMessage());
     }
 
     return Response.none();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteSshKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteSshKey.java
index 3d5d38e..f1ecd29 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteSshKey.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteSshKey.java
@@ -19,6 +19,9 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.DeleteSshKey.Input;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -33,15 +36,18 @@
   public static class Input {}
 
   private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final SshKeyCache sshKeyCache;
 
   @Inject
   DeleteSshKey(
       Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
       VersionedAuthorizedKeys.Accessor authorizedKeys,
       SshKeyCache sshKeyCache) {
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.authorizedKeys = authorizedKeys;
     this.sshKeyCache = sshKeyCache;
   }
@@ -49,9 +55,9 @@
   @Override
   public Response<?> apply(AccountResource.SshKey rsrc, Input input)
       throws AuthException, OrmException, RepositoryNotFoundException, IOException,
-          ConfigInvalidException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("not allowed to delete SSH keys");
+          ConfigInvalidException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
 
     authorizedKeys.deleteKey(rsrc.getUser().getAccountId(), rsrc.getSshKey().getKey().get());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteWatchedProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteWatchedProjects.java
index 97102a2..1666eb1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteWatchedProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteWatchedProjects.java
@@ -25,6 +25,9 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -37,13 +40,18 @@
 public class DeleteWatchedProjects
     implements RestModifyView<AccountResource, List<ProjectWatchInfo>> {
   private final Provider<IdentifiedUser> self;
+  private final PermissionBackend permissionBackend;
   private final AccountCache accountCache;
   private final WatchConfig.Accessor watchConfig;
 
   @Inject
   DeleteWatchedProjects(
-      Provider<IdentifiedUser> self, AccountCache accountCache, WatchConfig.Accessor watchConfig) {
+      Provider<IdentifiedUser> self,
+      PermissionBackend permissionBackend,
+      AccountCache accountCache,
+      WatchConfig.Accessor watchConfig) {
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.accountCache = accountCache;
     this.watchConfig = watchConfig;
   }
@@ -51,9 +59,9 @@
   @Override
   public Response<?> apply(AccountResource rsrc, List<ProjectWatchInfo> input)
       throws AuthException, UnprocessableEntityException, OrmException, IOException,
-          ConfigInvalidException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("It is not allowed to edit project watches of other users");
+          ConfigInvalidException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
     if (input == null) {
       return Response.none();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Emails.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Emails.java
index b894f56..e31f481 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Emails.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Emails.java
@@ -17,12 +17,16 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AcceptsCreate;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResource.Email;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -34,6 +38,7 @@
   private final DynamicMap<RestView<AccountResource.Email>> views;
   private final GetEmails list;
   private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
   private final CreateEmail.Factory createEmailFactory;
 
   @Inject
@@ -41,10 +46,12 @@
       DynamicMap<RestView<AccountResource.Email>> views,
       GetEmails list,
       Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
       CreateEmail.Factory createEmailFactory) {
     this.views = views;
     this.list = list;
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.createEmailFactory = createEmailFactory;
   }
 
@@ -55,21 +62,21 @@
 
   @Override
   public AccountResource.Email parse(AccountResource rsrc, IdString id)
-      throws ResourceNotFoundException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new ResourceNotFoundException();
+      throws ResourceNotFoundException, PermissionBackendException, AuthException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
 
     if ("preferred".equals(id.get())) {
       String email = rsrc.getUser().getAccount().getPreferredEmail();
       if (Strings.isNullOrEmpty(email)) {
-        throw new ResourceNotFoundException();
+        throw new ResourceNotFoundException(id);
       }
       return new AccountResource.Email(rsrc.getUser(), email);
     } else if (rsrc.getUser().hasEmailAddress(id.get())) {
       return new AccountResource.Email(rsrc.getUser(), id.get());
     } else {
-      throw new ResourceNotFoundException();
+      throw new ResourceNotFoundException(id);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIds.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIds.java
deleted file mode 100644
index c937935..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIds.java
+++ /dev/null
@@ -1,105 +0,0 @@
-// 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.account;
-
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-/**
- * Class to read external IDs from NoteDb.
- *
- * <p>In NoteDb external IDs are stored in the All-Users repository in a Git Notes branch called
- * refs/meta/external-ids where the sha1 of the external ID is used as note name. Each note content
- * is a git config file that contains an external ID. It has exactly one externalId subsection with
- * an accountId and optionally email and password:
- *
- * <pre>
- * [externalId "username:jdoe"]
- *   accountId = 1003407
- *   email = jdoe@example.com
- *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
- * </pre>
- */
-@Singleton
-public class ExternalIds {
-  public static final int MAX_NOTE_SZ = 1 << 19;
-
-  public static ObjectId readRevision(Repository repo) throws IOException {
-    Ref ref = repo.exactRef(RefNames.REFS_EXTERNAL_IDS);
-    return ref != null ? ref.getObjectId() : ObjectId.zeroId();
-  }
-
-  public static NoteMap readNoteMap(RevWalk rw, ObjectId rev) throws IOException {
-    if (!rev.equals(ObjectId.zeroId())) {
-      return NoteMap.read(rw.getObjectReader(), rw.parseCommit(rev));
-    }
-    return NoteMap.newEmptyMap();
-  }
-
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-
-  @Inject
-  public ExternalIds(GitRepositoryManager repoManager, AllUsersName allUsersName) {
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-  }
-
-  public ObjectId readRevision() throws IOException {
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      return readRevision(repo);
-    }
-  }
-
-  /** Reads and returns the specified external ID. */
-  @Nullable
-  public ExternalId get(ExternalId.Key key) throws IOException, ConfigInvalidException {
-    try (Repository repo = repoManager.openRepository(allUsersName);
-        RevWalk rw = new RevWalk(repo)) {
-      ObjectId rev = readRevision(repo);
-      if (rev.equals(ObjectId.zeroId())) {
-        return null;
-      }
-
-      return parse(key, rw, rev);
-    }
-  }
-
-  private ExternalId parse(ExternalId.Key key, RevWalk rw, ObjectId rev)
-      throws IOException, ConfigInvalidException {
-    NoteMap noteMap = readNoteMap(rw, rev);
-    ObjectId noteId = key.sha1();
-    if (!noteMap.contains(noteId)) {
-      return null;
-    }
-
-    byte[] raw =
-        rw.getObjectReader().open(noteMap.get(noteId), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
-    return ExternalId.parse(noteId.name(), raw);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetCapabilities.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetCapabilities.java
index cd3c0c8..f519b6c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetCapabilities.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetCapabilities.java
@@ -14,27 +14,13 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.common.data.GlobalCapability.ACCESS_DATABASE;
-import static com.google.gerrit.common.data.GlobalCapability.CREATE_ACCOUNT;
-import static com.google.gerrit.common.data.GlobalCapability.CREATE_GROUP;
-import static com.google.gerrit.common.data.GlobalCapability.CREATE_PROJECT;
-import static com.google.gerrit.common.data.GlobalCapability.EMAIL_REVIEWERS;
-import static com.google.gerrit.common.data.GlobalCapability.FLUSH_CACHES;
-import static com.google.gerrit.common.data.GlobalCapability.KILL_TASK;
-import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
-import static com.google.gerrit.common.data.GlobalCapability.MODIFY_ACCOUNT;
 import static com.google.gerrit.common.data.GlobalCapability.PRIORITY;
-import static com.google.gerrit.common.data.GlobalCapability.RUN_GC;
-import static com.google.gerrit.common.data.GlobalCapability.STREAM_EVENTS;
-import static com.google.gerrit.common.data.GlobalCapability.VIEW_ALL_ACCOUNTS;
-import static com.google.gerrit.common.data.GlobalCapability.VIEW_CACHES;
-import static com.google.gerrit.common.data.GlobalCapability.VIEW_CONNECTIONS;
-import static com.google.gerrit.common.data.GlobalCapability.VIEW_PLUGINS;
-import static com.google.gerrit.common.data.GlobalCapability.VIEW_QUEUE;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.PermissionRange;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.api.access.PluginPermission;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -45,12 +31,14 @@
 import com.google.gerrit.server.OutputFormat;
 import com.google.gerrit.server.account.AccountResource.Capability;
 import com.google.gerrit.server.git.QueueProvider;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.HashSet;
-import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.Set;
@@ -67,83 +55,80 @@
 
   private Set<String> query;
 
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> self;
   private final DynamicMap<CapabilityDefinition> pluginCapabilities;
 
   @Inject
-  GetCapabilities(Provider<CurrentUser> self, DynamicMap<CapabilityDefinition> pluginCapabilities) {
+  GetCapabilities(
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> self,
+      DynamicMap<CapabilityDefinition> pluginCapabilities) {
+    this.permissionBackend = permissionBackend;
     this.self = self;
     this.pluginCapabilities = pluginCapabilities;
   }
 
   @Override
-  public Object apply(AccountResource resource) throws AuthException {
-    if (self.get() != resource.getUser() && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("restricted to administrator");
+  public Object apply(AccountResource rsrc) throws AuthException, PermissionBackendException {
+    PermissionBackend.WithUser perm = permissionBackend.user(self);
+    if (self.get() != rsrc.getUser()) {
+      perm.check(GlobalPermission.ADMINISTRATE_SERVER);
+      perm = permissionBackend.user(rsrc.getUser());
     }
 
-    CapabilityControl cc = resource.getUser().getCapabilities();
     Map<String, Object> have = new LinkedHashMap<>();
-    for (String name : GlobalCapability.getAllNames()) {
-      if (!name.equals(PRIORITY) && want(name) && cc.canPerform(name)) {
-        if (GlobalCapability.hasRange(name)) {
-          have.put(name, new Range(cc.getRange(name)));
-        } else {
-          have.put(name, true);
-        }
-      }
+    for (GlobalOrPluginPermission p : perm.test(permissionsToTest())) {
+      have.put(p.permissionName(), true);
     }
-    for (String pluginName : pluginCapabilities.plugins()) {
-      for (String capability : pluginCapabilities.byPlugin(pluginName).keySet()) {
-        String name = String.format("%s-%s", pluginName, capability);
-        if (want(name) && cc.canPerform(name)) {
-          have.put(name, true);
-        }
-      }
-    }
-
-    have.put(ACCESS_DATABASE, cc.canAccessDatabase());
-    have.put(CREATE_ACCOUNT, cc.canCreateAccount());
-    have.put(CREATE_GROUP, cc.canCreateGroup());
-    have.put(CREATE_PROJECT, cc.canCreateProject());
-    have.put(EMAIL_REVIEWERS, cc.canEmailReviewers());
-    have.put(FLUSH_CACHES, cc.canFlushCaches());
-    have.put(KILL_TASK, cc.canKillTask());
-    have.put(MAINTAIN_SERVER, cc.canMaintainServer());
-    have.put(MODIFY_ACCOUNT, cc.canModifyAccount());
-    have.put(RUN_GC, cc.canRunGC());
-    have.put(STREAM_EVENTS, cc.canStreamEvents());
-    have.put(VIEW_ALL_ACCOUNTS, cc.canViewAllAccounts());
-    have.put(VIEW_CACHES, cc.canViewCaches());
-    have.put(VIEW_CONNECTIONS, cc.canViewConnections());
-    have.put(VIEW_PLUGINS, cc.canViewPlugins());
-    have.put(VIEW_QUEUE, cc.canViewQueue());
-
-    QueueProvider.QueueType queue = cc.getQueueType();
-    if (queue != QueueProvider.QueueType.INTERACTIVE
-        || (query != null && query.contains(PRIORITY))) {
-      have.put(PRIORITY, queue);
-    }
-
-    Iterator<Map.Entry<String, Object>> itr = have.entrySet().iterator();
-    while (itr.hasNext()) {
-      Map.Entry<String, Object> e = itr.next();
-      if (!want(e.getKey())) {
-        itr.remove();
-      } else if (e.getValue() instanceof Boolean && !((Boolean) e.getValue())) {
-        itr.remove();
-      }
-    }
+    addRanges(have, rsrc);
+    addPriority(have, rsrc);
 
     return OutputFormat.JSON
         .newGson()
         .toJsonTree(have, new TypeToken<Map<String, Object>>() {}.getType());
   }
 
+  private Set<GlobalOrPluginPermission> permissionsToTest() {
+    Set<GlobalOrPluginPermission> toTest = new HashSet<>();
+    for (GlobalPermission p : GlobalPermission.values()) {
+      if (want(p.permissionName())) {
+        toTest.add(p);
+      }
+    }
+
+    for (String pluginName : pluginCapabilities.plugins()) {
+      for (String capability : pluginCapabilities.byPlugin(pluginName).keySet()) {
+        PluginPermission p = new PluginPermission(pluginName, capability);
+        if (want(p.permissionName())) {
+          toTest.add(p);
+        }
+      }
+    }
+    return toTest;
+  }
+
   private boolean want(String name) {
     return query == null || query.contains(name.toLowerCase());
   }
 
+  private void addRanges(Map<String, Object> have, AccountResource rsrc) {
+    CapabilityControl cc = rsrc.getUser().getCapabilities();
+    for (String name : GlobalCapability.getRangeNames()) {
+      if (want(name) && cc.hasExplicitRange(name)) {
+        have.put(name, new Range(cc.getRange(name)));
+      }
+    }
+  }
+
+  private void addPriority(Map<String, Object> have, AccountResource rsrc) {
+    QueueProvider.QueueType queue = rsrc.getUser().getCapabilities().getQueueType();
+    if (queue != QueueProvider.QueueType.INTERACTIVE
+        || (query != null && query.contains(PRIORITY))) {
+      have.put(PRIORITY, queue);
+    }
+  }
+
   private static class Range {
     private transient PermissionRange range;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDiffPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDiffPreferences.java
index 0edff4f..8215c6b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDiffPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDiffPreferences.java
@@ -25,6 +25,9 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.UserConfigSections;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -42,23 +45,26 @@
 
   private final Provider<CurrentUser> self;
   private final Provider<AllUsersName> allUsersName;
+  private final PermissionBackend permissionBackend;
   private final GitRepositoryManager gitMgr;
 
   @Inject
   GetDiffPreferences(
       Provider<CurrentUser> self,
       Provider<AllUsersName> allUsersName,
+      PermissionBackend permissionBackend,
       GitRepositoryManager gitMgr) {
     this.self = self;
     this.allUsersName = allUsersName;
+    this.permissionBackend = permissionBackend;
     this.gitMgr = gitMgr;
   }
 
   @Override
   public DiffPreferencesInfo apply(AccountResource rsrc)
-      throws AuthException, ConfigInvalidException, IOException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("restricted to administrator");
+      throws AuthException, ConfigInvalidException, IOException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
 
     Account.Id id = rsrc.getUser().getAccountId();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEditPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEditPreferences.java
index e385020..bb207f0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEditPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEditPreferences.java
@@ -24,6 +24,9 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.UserConfigSections;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -35,22 +38,27 @@
 @Singleton
 public class GetEditPreferences implements RestReadView<AccountResource> {
   private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
   private final AllUsersName allUsersName;
   private final GitRepositoryManager gitMgr;
 
   @Inject
   GetEditPreferences(
-      Provider<CurrentUser> self, AllUsersName allUsersName, GitRepositoryManager gitMgr) {
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      AllUsersName allUsersName,
+      GitRepositoryManager gitMgr) {
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.allUsersName = allUsersName;
     this.gitMgr = gitMgr;
   }
 
   @Override
   public EditPreferencesInfo apply(AccountResource rsrc)
-      throws AuthException, IOException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("requires Modify Account capability");
+      throws AuthException, IOException, ConfigInvalidException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
     return readFromGit(rsrc.getUser().getAccountId(), gitMgr, allUsersName, null);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetExternalIds.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetExternalIds.java
index 6ea911f..46c1dd8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetExternalIds.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetExternalIds.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
@@ -22,40 +22,40 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 
 @Singleton
 public class GetExternalIds implements RestReadView<AccountResource> {
-  private final Provider<ReviewDb> db;
+  private final ExternalIds externalIds;
   private final Provider<CurrentUser> self;
   private final AuthConfig authConfig;
 
   @Inject
-  GetExternalIds(Provider<ReviewDb> db, Provider<CurrentUser> self, AuthConfig authConfig) {
-    this.db = db;
+  GetExternalIds(ExternalIds externalIds, Provider<CurrentUser> self, AuthConfig authConfig) {
+    this.externalIds = externalIds;
     this.self = self;
     this.authConfig = authConfig;
   }
 
   @Override
   public List<AccountExternalIdInfo> apply(AccountResource resource)
-      throws RestApiException, OrmException {
+      throws RestApiException, IOException, OrmException {
     if (self.get() != resource.getUser()) {
       throw new AuthException("not allowed to get external IDs");
     }
 
-    Collection<ExternalId> ids =
-        ExternalId.from(
-            db.get().accountExternalIds().byAccount(resource.getUser().getAccountId()).toList());
+    Collection<ExternalId> ids = externalIds.byAccount(resource.getUser().getAccountId());
     if (ids.isEmpty()) {
       return ImmutableList.of();
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetPreferences.java
index 77cdbd4..3ebf864 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetPreferences.java
@@ -19,6 +19,9 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -26,18 +29,22 @@
 @Singleton
 public class GetPreferences implements RestReadView<AccountResource> {
   private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
   private final AccountCache accountCache;
 
   @Inject
-  GetPreferences(Provider<CurrentUser> self, AccountCache accountCache) {
+  GetPreferences(
+      Provider<CurrentUser> self, PermissionBackend permissionBackend, AccountCache accountCache) {
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.accountCache = accountCache;
   }
 
   @Override
-  public GeneralPreferencesInfo apply(AccountResource rsrc) throws AuthException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("requires Modify Account capability");
+  public GeneralPreferencesInfo apply(AccountResource rsrc)
+      throws AuthException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
     Account.Id id = rsrc.getUser().getAccountId();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKeys.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKeys.java
index 980d880..9f5b9d5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKeys.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKeys.java
@@ -22,6 +22,9 @@
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -35,20 +38,25 @@
 public class GetSshKeys implements RestReadView<AccountResource> {
 
   private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
 
   @Inject
-  GetSshKeys(Provider<CurrentUser> self, VersionedAuthorizedKeys.Accessor authorizedKeys) {
+  GetSshKeys(
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      VersionedAuthorizedKeys.Accessor authorizedKeys) {
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.authorizedKeys = authorizedKeys;
   }
 
   @Override
   public List<SshKeyInfo> apply(AccountResource rsrc)
       throws AuthException, OrmException, RepositoryNotFoundException, IOException,
-          ConfigInvalidException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("not allowed to get SSH keys");
+          ConfigInvalidException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
     return apply(rsrc.getUser());
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetWatchedProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetWatchedProjects.java
index e0aeee0..c2c0547 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetWatchedProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetWatchedProjects.java
@@ -23,6 +23,9 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -38,22 +41,28 @@
 
 @Singleton
 public class GetWatchedProjects implements RestReadView<AccountResource> {
-
+  private final PermissionBackend permissionBackend;
   private final Provider<IdentifiedUser> self;
   private final WatchConfig.Accessor watchConfig;
 
   @Inject
-  public GetWatchedProjects(Provider<IdentifiedUser> self, WatchConfig.Accessor watchConfig) {
+  public GetWatchedProjects(
+      PermissionBackend permissionBackend,
+      Provider<IdentifiedUser> self,
+      WatchConfig.Accessor watchConfig) {
+    this.permissionBackend = permissionBackend;
     this.self = self;
     this.watchConfig = watchConfig;
   }
 
   @Override
   public List<ProjectWatchInfo> apply(AccountResource rsrc)
-      throws OrmException, AuthException, IOException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("It is not allowed to list project watches of other users");
+      throws OrmException, AuthException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
+
     Account.Id accountId = rsrc.getUser().getAccountId();
     List<ProjectWatchInfo> projectWatchInfos = new ArrayList<>();
     for (Map.Entry<ProjectWatchKey, Set<NotifyType>> e :
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupControl.java
index ee788ec..e88e97e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupControl.java
@@ -127,7 +127,7 @@
     return user.isInternalUser()
         || groupBackend.isVisibleToAll(group.getGroupUUID())
         || user.getEffectiveGroups().contains(group.getGroupUUID())
-        || user.getCapabilities().canAdministrateServer()
+        || user.getCapabilities().isAdmin_DoNotUse()
         || isOwner();
   }
 
@@ -139,7 +139,7 @@
       AccountGroup.UUID ownerUUID = accountGroup.getOwnerGroupUUID();
       isOwner =
           getUser().getEffectiveGroups().contains(ownerUUID)
-              || getUser().getCapabilities().canAdministrateServer();
+              || getUser().getCapabilities().isAdmin_DoNotUse();
     }
     return isOwner;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Index.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Index.java
index 6943dca..ecc6b8c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Index.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Index.java
@@ -19,6 +19,9 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.Index.Input;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -29,18 +32,22 @@
   public static class Input {}
 
   private final AccountCache accountCache;
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> self;
 
   @Inject
-  Index(AccountCache accountCache, Provider<CurrentUser> self) {
+  Index(
+      AccountCache accountCache, PermissionBackend permissionBackend, Provider<CurrentUser> self) {
     this.accountCache = accountCache;
+    this.permissionBackend = permissionBackend;
     this.self = self;
   }
 
   @Override
-  public Response<?> apply(AccountResource rsrc, Input input) throws IOException, AuthException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("not allowed to index account");
+  public Response<?> apply(AccountResource rsrc, Input input)
+      throws IOException, AuthException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
     // evicting the account from the cache, reindexes the account
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java
index 7791a2e..e7ff314 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.avatar.AvatarProvider;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PostWatchedProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PostWatchedProjects.java
index 55ba912..38887f6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PostWatchedProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PostWatchedProjects.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -24,6 +23,9 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectsCollection;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -41,6 +43,7 @@
 public class PostWatchedProjects
     implements RestModifyView<AccountResource, List<ProjectWatchInfo>> {
   private final Provider<IdentifiedUser> self;
+  private final PermissionBackend permissionBackend;
   private final GetWatchedProjects getWatchedProjects;
   private final ProjectsCollection projectsCollection;
   private final AccountCache accountCache;
@@ -49,11 +52,13 @@
   @Inject
   public PostWatchedProjects(
       Provider<IdentifiedUser> self,
+      PermissionBackend permissionBackend,
       GetWatchedProjects getWatchedProjects,
       ProjectsCollection projectsCollection,
       AccountCache accountCache,
       WatchConfig.Accessor watchConfig) {
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.getWatchedProjects = getWatchedProjects;
     this.projectsCollection = projectsCollection;
     this.accountCache = accountCache;
@@ -62,10 +67,12 @@
 
   @Override
   public List<ProjectWatchInfo> apply(AccountResource rsrc, List<ProjectWatchInfo> input)
-      throws OrmException, RestApiException, IOException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("not allowed to edit project watches");
+      throws OrmException, RestApiException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
+
     Account.Id accountId = rsrc.getUser().getAccountId();
     watchConfig.upsertProjectWatches(accountId, asMap(input));
     accountCache.evict(accountId);
@@ -73,7 +80,8 @@
   }
 
   private Map<ProjectWatchKey, Set<NotifyType>> asMap(List<ProjectWatchInfo> input)
-      throws BadRequestException, UnprocessableEntityException, IOException {
+      throws BadRequestException, UnprocessableEntityException, IOException,
+          PermissionBackendException {
     Map<ProjectWatchKey, Set<NotifyType>> m = new HashMap<>();
     for (ProjectWatchInfo info : input) {
       if (info.project == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java
index 32c5345..4c525c2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java
@@ -28,7 +28,6 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Collections;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 @RequiresCapability(GlobalCapability.MODIFY_ACCOUNT)
@@ -69,7 +68,6 @@
     if (a == null) {
       throw new ResourceNotFoundException("account not found");
     }
-    dbProvider.get().accounts().update(Collections.singleton(a));
     byIdCache.evict(a.getId());
     return alreadyActive.get() ? Response.ok("") : Response.created("");
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
index 435671f..395f078 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -22,10 +22,15 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.PutHttpPassword.Input;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -53,26 +58,33 @@
   }
 
   private final Provider<CurrentUser> self;
-  private final Provider<ReviewDb> dbProvider;
+  private final PermissionBackend permissionBackend;
   private final AccountCache accountCache;
+  private final ExternalIds externalIds;
   private final ExternalIdsUpdate.User externalIdsUpdate;
 
   @Inject
   PutHttpPassword(
       Provider<CurrentUser> self,
-      Provider<ReviewDb> dbProvider,
+      PermissionBackend permissionBackend,
       AccountCache accountCache,
+      ExternalIds externalIds,
       ExternalIdsUpdate.User externalIdsUpdate) {
     this.self = self;
-    this.dbProvider = dbProvider;
+    this.permissionBackend = permissionBackend;
     this.accountCache = accountCache;
+    this.externalIds = externalIds;
     this.externalIdsUpdate = externalIdsUpdate;
   }
 
   @Override
   public Response<String> apply(AccountResource rsrc, Input input)
       throws AuthException, ResourceNotFoundException, ResourceConflictException, OrmException,
-          IOException, ConfigInvalidException {
+          IOException, ConfigInvalidException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
+    }
+
     if (input == null) {
       input = new Input();
     }
@@ -80,22 +92,12 @@
 
     String newPassword;
     if (input.generate) {
-      if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canAdministrateServer()) {
-        throw new AuthException("not allowed to generate HTTP password");
-      }
       newPassword = generate();
-
     } else if (input.httpPassword == null) {
-      if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canAdministrateServer()) {
-        throw new AuthException("not allowed to clear HTTP password");
-      }
       newPassword = null;
     } else {
-      if (!self.get().getCapabilities().canAdministrateServer()) {
-        throw new AuthException(
-            "not allowed to set HTTP password directly, "
-                + "requires the Administrate Server permission");
-      }
+      // Only administrators can explicitly set the password.
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
       newPassword = input.httpPassword;
     }
     return apply(rsrc.getUser(), newPassword);
@@ -108,20 +110,13 @@
       throw new ResourceConflictException("username must be set");
     }
 
-    ExternalId extId =
-        ExternalId.from(
-            dbProvider
-                .get()
-                .accountExternalIds()
-                .get(
-                    ExternalId.Key.create(SCHEME_USERNAME, user.getUserName())
-                        .asAccountExternalIdKey()));
+    ExternalId extId = externalIds.get(ExternalId.Key.create(SCHEME_USERNAME, user.getUserName()));
     if (extId == null) {
       throw new ResourceNotFoundException();
     }
     ExternalId newExtId =
         ExternalId.createWithPassword(extId.key(), extId.accountId(), extId.email(), newPassword);
-    externalIdsUpdate.create().upsert(dbProvider.get(), newExtId);
+    externalIdsUpdate.create().upsert(newExtId);
     accountCache.evict(user.getAccountId());
 
     return Strings.isNullOrEmpty(newPassword) ? Response.<String>none() : Response.ok(newPassword);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java
index 443a549..7a2868e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java
@@ -27,6 +27,9 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.PutName.Input;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.AtomicUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -42,6 +45,7 @@
 
   private final Provider<CurrentUser> self;
   private final Realm realm;
+  private final PermissionBackend permissionBackend;
   private final Provider<ReviewDb> dbProvider;
   private final AccountCache byIdCache;
 
@@ -49,10 +53,12 @@
   PutName(
       Provider<CurrentUser> self,
       Realm realm,
+      PermissionBackend permissionBackend,
       Provider<ReviewDb> dbProvider,
       AccountCache byIdCache) {
     this.self = self;
     this.realm = realm;
+    this.permissionBackend = permissionBackend;
     this.dbProvider = dbProvider;
     this.byIdCache = byIdCache;
   }
@@ -60,9 +66,9 @@
   @Override
   public Response<String> apply(AccountResource rsrc, Input input)
       throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException,
-          IOException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("not allowed to change name");
+          IOException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
     return apply(rsrc.getUser(), input);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java
index ec60fb3..4941cc8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java
@@ -23,13 +23,15 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.PutPreferred.Input;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.AtomicUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Collections;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 @Singleton
@@ -38,20 +40,27 @@
 
   private final Provider<CurrentUser> self;
   private final Provider<ReviewDb> dbProvider;
+  private final PermissionBackend permissionBackend;
   private final AccountCache byIdCache;
 
   @Inject
-  PutPreferred(Provider<CurrentUser> self, Provider<ReviewDb> dbProvider, AccountCache byIdCache) {
+  PutPreferred(
+      Provider<CurrentUser> self,
+      Provider<ReviewDb> dbProvider,
+      PermissionBackend permissionBackend,
+      AccountCache byIdCache) {
     this.self = self;
     this.dbProvider = dbProvider;
+    this.permissionBackend = permissionBackend;
     this.byIdCache = byIdCache;
   }
 
   @Override
   public Response<String> apply(AccountResource.Email rsrc, Input input)
-      throws AuthException, ResourceNotFoundException, OrmException, IOException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("not allowed to set preferred email address");
+      throws AuthException, ResourceNotFoundException, OrmException, IOException,
+          PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
     return apply(rsrc.getUser(), rsrc.getEmail());
   }
@@ -79,7 +88,6 @@
     if (a == null) {
       throw new ResourceNotFoundException("account not found");
     }
-    dbProvider.get().accounts().update(Collections.singleton(a));
     byIdCache.evict(a.getId());
     return alreadyPreferred.get() ? Response.ok("") : Response.created("");
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutStatus.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutStatus.java
index ff541fd..73a720b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutStatus.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutStatus.java
@@ -25,6 +25,9 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.PutStatus.Input;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.AtomicUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -46,20 +49,27 @@
 
   private final Provider<CurrentUser> self;
   private final Provider<ReviewDb> dbProvider;
+  private final PermissionBackend permissionBackend;
   private final AccountCache byIdCache;
 
   @Inject
-  PutStatus(Provider<CurrentUser> self, Provider<ReviewDb> dbProvider, AccountCache byIdCache) {
+  PutStatus(
+      Provider<CurrentUser> self,
+      Provider<ReviewDb> dbProvider,
+      PermissionBackend permissionBackend,
+      AccountCache byIdCache) {
     this.self = self;
     this.dbProvider = dbProvider;
+    this.permissionBackend = permissionBackend;
     this.byIdCache = byIdCache;
   }
 
   @Override
   public Response<String> apply(AccountResource rsrc, Input input)
-      throws AuthException, ResourceNotFoundException, OrmException, IOException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("not allowed to set status");
+      throws AuthException, ResourceNotFoundException, OrmException, IOException,
+          PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
     return apply(rsrc.getUser(), input);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
index e3a3c12..a73bdd9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
@@ -22,9 +22,11 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.PutUsername.Input;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -40,27 +42,28 @@
 
   private final Provider<CurrentUser> self;
   private final ChangeUserName.Factory changeUserNameFactory;
+  private final PermissionBackend permissionBackend;
   private final Realm realm;
-  private final Provider<ReviewDb> db;
 
   @Inject
   PutUsername(
       Provider<CurrentUser> self,
       ChangeUserName.Factory changeUserNameFactory,
-      Realm realm,
-      Provider<ReviewDb> db) {
+      PermissionBackend permissionBackend,
+      Realm realm) {
     this.self = self;
     this.changeUserNameFactory = changeUserNameFactory;
+    this.permissionBackend = permissionBackend;
     this.realm = realm;
-    this.db = db;
   }
 
   @Override
   public String apply(AccountResource rsrc, Input input)
       throws AuthException, MethodNotAllowedException, UnprocessableEntityException,
-          ResourceConflictException, OrmException, IOException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("not allowed to set username");
+          ResourceConflictException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
 
     if (!realm.allowsEdit(AccountFieldName.USER_NAME)) {
@@ -72,7 +75,7 @@
     }
 
     try {
-      changeUserNameFactory.create(db.get(), rsrc.getUser(), input.username).call();
+      changeUserNameFactory.create(rsrc.getUser(), input.username).call();
     } catch (IllegalStateException e) {
       if (ChangeUserName.USERNAME_CANNOT_BE_CHANGED.equals(e.getMessage())) {
         throw new MethodNotAllowedException(e.getMessage());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetDiffPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetDiffPreferences.java
index ac0cc96..88e9e20 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetDiffPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetDiffPreferences.java
@@ -29,6 +29,9 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.UserConfigSections;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -41,6 +44,7 @@
   private final Provider<CurrentUser> self;
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final AllUsersName allUsersName;
+  private final PermissionBackend permissionBackend;
   private final GitRepositoryManager gitMgr;
 
   @Inject
@@ -48,19 +52,21 @@
       Provider<CurrentUser> self,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
       AllUsersName allUsersName,
+      PermissionBackend permissionBackend,
       GitRepositoryManager gitMgr) {
     this.self = self;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.allUsersName = allUsersName;
+    this.permissionBackend = permissionBackend;
     this.gitMgr = gitMgr;
   }
 
   @Override
   public DiffPreferencesInfo apply(AccountResource rsrc, DiffPreferencesInfo in)
       throws AuthException, BadRequestException, ConfigInvalidException,
-          RepositoryNotFoundException, IOException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("requires Modify Account capability");
+          RepositoryNotFoundException, IOException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
     if (in == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetEditPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetEditPreferences.java
index ca981b8..53285db 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetEditPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetEditPreferences.java
@@ -28,6 +28,9 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.UserConfigSections;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -40,6 +43,7 @@
 
   private final Provider<CurrentUser> self;
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
+  private final PermissionBackend permissionBackend;
   private final GitRepositoryManager gitMgr;
   private final AllUsersName allUsersName;
 
@@ -47,10 +51,12 @@
   SetEditPreferences(
       Provider<CurrentUser> self,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
+      PermissionBackend permissionBackend,
       GitRepositoryManager gitMgr,
       AllUsersName allUsersName) {
     this.self = self;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.permissionBackend = permissionBackend;
     this.gitMgr = gitMgr;
     this.allUsersName = allUsersName;
   }
@@ -58,9 +64,9 @@
   @Override
   public EditPreferencesInfo apply(AccountResource rsrc, EditPreferencesInfo in)
       throws AuthException, BadRequestException, RepositoryNotFoundException, IOException,
-          ConfigInvalidException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("requires Modify Account capability");
+          ConfigInvalidException, PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
     if (in == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java
index 91672f7..c033d9d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java
@@ -36,6 +36,9 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.UserConfigSections;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -51,6 +54,7 @@
 public class SetPreferences implements RestModifyView<AccountResource, GeneralPreferencesInfo> {
   private final Provider<CurrentUser> self;
   private final AccountCache cache;
+  private final PermissionBackend permissionBackend;
   private final GeneralPreferencesLoader loader;
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final AllUsersName allUsersName;
@@ -60,6 +64,7 @@
   SetPreferences(
       Provider<CurrentUser> self,
       AccountCache cache,
+      PermissionBackend permissionBackend,
       GeneralPreferencesLoader loader,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
       AllUsersName allUsersName,
@@ -67,6 +72,7 @@
     this.self = self;
     this.loader = loader;
     this.cache = cache;
+    this.permissionBackend = permissionBackend;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.allUsersName = allUsersName;
     this.downloadSchemes = downloadSchemes;
@@ -74,9 +80,10 @@
 
   @Override
   public GeneralPreferencesInfo apply(AccountResource rsrc, GeneralPreferencesInfo i)
-      throws AuthException, BadRequestException, IOException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("requires Modify Account capability");
+      throws AuthException, BadRequestException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
     checkDownloadScheme(i.downloadScheme);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SshKeys.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SshKeys.java
index 6336e08..70c02a1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SshKeys.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/SshKeys.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -22,6 +23,9 @@
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -34,6 +38,7 @@
   private final DynamicMap<RestView<AccountResource.SshKey>> views;
   private final GetSshKeys list;
   private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
 
   @Inject
@@ -41,10 +46,12 @@
       DynamicMap<RestView<AccountResource.SshKey>> views,
       GetSshKeys list,
       Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
       VersionedAuthorizedKeys.Accessor authorizedKeys) {
     this.views = views;
     this.list = list;
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.authorizedKeys = authorizedKeys;
   }
 
@@ -55,9 +62,15 @@
 
   @Override
   public AccountResource.SshKey parse(AccountResource rsrc, IdString id)
-      throws ResourceNotFoundException, OrmException, IOException, ConfigInvalidException {
-    if (self.get() != rsrc.getUser() && !self.get().getCapabilities().canModifyAccount()) {
-      throw new ResourceNotFoundException();
+      throws ResourceNotFoundException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (self.get() != rsrc.getUser()) {
+      try {
+        permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
+      } catch (AuthException e) {
+        // If lacking MODIFY_ACCOUNT claim the resource does not exist.
+        throw new ResourceNotFoundException();
+      }
     }
     return parse(rsrc.getUser(), id);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
new file mode 100644
index 0000000..ab212fb
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2017 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.account.externalids;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+
+public class DisabledExternalIdCache implements ExternalIdCache {
+  public static Module module() {
+    return new AbstractModule() {
+
+      @Override
+      protected void configure() {
+        bind(ExternalIdCache.class).to(DisabledExternalIdCache.class);
+      }
+    };
+  }
+
+  @Override
+  public void onCreate(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId) {}
+
+  @Override
+  public void onUpdate(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId) {}
+
+  @Override
+  public void onReplace(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Collection<ExternalId> toRemove,
+      Collection<ExternalId> toAdd) {}
+
+  @Override
+  public void onReplaceByKeys(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Collection<ExternalId.Key> toRemove,
+      Collection<ExternalId> toAdd) {}
+
+  @Override
+  public void onReplaceByKeys(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Collection<ExternalId.Key> toRemove,
+      Collection<ExternalId> toAdd) {}
+
+  @Override
+  public void onReplace(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Collection<ExternalId> toRemove,
+      Collection<ExternalId> toAdd) {}
+
+  @Override
+  public void onRemove(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId) {}
+
+  @Override
+  public void onRemoveByKeys(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Collection<ExternalId.Key> extIdKeys) {}
+
+  @Override
+  public void onRemoveByKeys(
+      ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId.Key> extIdKeys) {}
+
+  @Override
+  public Set<ExternalId> byAccount(Account.Id accountId) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public Set<ExternalId> byEmail(String email) throws IOException {
+    throw new UnsupportedOperationException();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalId.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalId.java
similarity index 79%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalId.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalId.java
index cd10b7b..4aabf59 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalId.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -12,24 +12,20 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.account;
+package com.google.gerrit.server.account.externalids;
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.stream.Collectors.toSet;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.hash.Hashing;
-import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
+import com.google.gerrit.server.account.HashedPassword;
 import java.io.Serializable;
-import java.util.Collection;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
@@ -75,10 +71,6 @@
       return new AutoValue_ExternalId_Key(Strings.emptyToNull(scheme), id);
     }
 
-    public static ExternalId.Key from(AccountExternalId.Key externalIdKey) {
-      return parse(externalIdKey.get());
-    }
-
     /**
      * Parses an external ID key from a string in the format "scheme:id" or "id".
      *
@@ -92,11 +84,6 @@
       return create(externalId.substring(0, c), externalId.substring(c + 1));
     }
 
-    public static Set<AccountExternalId.Key> toAccountExternalIdKeys(
-        Collection<ExternalId.Key> extIdKeys) {
-      return extIdKeys.stream().map(k -> k.asAccountExternalIdKey()).collect(toSet());
-    }
-
     public abstract @Nullable String scheme();
 
     public abstract String id();
@@ -105,13 +92,6 @@
       return scheme.equals(scheme());
     }
 
-    public AccountExternalId.Key asAccountExternalIdKey() {
-      if (scheme() != null) {
-        return new AccountExternalId.Key(scheme(), id());
-      }
-      return new AccountExternalId.Key(id());
-    }
-
     /**
      * Returns the SHA1 of the external ID that is used as note ID in the refs/meta/external-ids
      * notes branch.
@@ -215,35 +195,27 @@
       throw invalidConfig(
           noteId,
           String.format(
-              "Expected exactly 1 %s section, found %d",
+              "Expected exactly 1 '%s' section, found %d",
               EXTERNAL_ID_SECTION, externalIdKeys.size()));
     }
 
     String externalIdKeyStr = Iterables.getOnlyElement(externalIdKeys);
     Key externalIdKey = Key.parse(externalIdKeyStr);
     if (externalIdKey == null) {
-      throw invalidConfig(noteId, String.format("Invalid external id: %s", externalIdKeyStr));
+      throw invalidConfig(noteId, String.format("External ID %s is invalid", externalIdKeyStr));
     }
 
-    String accountIdStr =
-        externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY);
+    if (!externalIdKey.sha1().getName().equals(noteId)) {
+      throw invalidConfig(
+          noteId,
+          String.format(
+              "SHA1 of external ID '%s' does not match note ID '%s'", externalIdKeyStr, noteId));
+    }
+
     String email = externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, EMAIL_KEY);
     String password =
         externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, PASSWORD_KEY);
-    if (accountIdStr == null) {
-      throw invalidConfig(
-          noteId,
-          String.format(
-              "Missing value for %s.%s.%s", EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
-    }
-    Integer accountId = Ints.tryParse(accountIdStr);
-    if (accountId == null) {
-      throw invalidConfig(
-          noteId,
-          String.format(
-              "Value %s for %s.%s.%s is invalid, expected account ID",
-              accountIdStr, EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
-    }
+    int accountId = readAccountId(noteId, externalIdConfig, externalIdKeyStr);
 
     return new AutoValue_ExternalId(
         externalIdKey,
@@ -252,32 +224,41 @@
         Strings.emptyToNull(password));
   }
 
+  private static int readAccountId(String noteId, Config externalIdConfig, String externalIdKeyStr)
+      throws ConfigInvalidException {
+    String accountIdStr =
+        externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY);
+    if (accountIdStr == null) {
+      throw invalidConfig(
+          noteId,
+          String.format(
+              "Value for '%s.%s.%s' is missing, expected account ID",
+              EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
+    }
+
+    try {
+      int accountId =
+          externalIdConfig.getInt(EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY, -1);
+      if (accountId <= 0) {
+        throw invalidConfig(
+            noteId,
+            String.format(
+                "Value %s for '%s.%s.%s' is invalid, expected account ID",
+                accountIdStr, EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
+      }
+      return accountId;
+    } catch (IllegalArgumentException e) {
+      throw invalidConfig(
+          noteId,
+          String.format(
+              "Value %s for '%s.%s.%s' is invalid, expected account ID",
+              accountIdStr, EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
+    }
+  }
+
   private static ConfigInvalidException invalidConfig(String noteId, String message) {
     return new ConfigInvalidException(
-        String.format("Invalid external id config for note %s: %s", noteId, message));
-  }
-
-  public static ExternalId from(AccountExternalId externalId) {
-    if (externalId == null) {
-      return null;
-    }
-
-    return new AutoValue_ExternalId(
-        ExternalId.Key.parse(externalId.getExternalId()),
-        externalId.getAccountId(),
-        Strings.emptyToNull(externalId.getEmailAddress()),
-        Strings.emptyToNull(externalId.getPassword()));
-  }
-
-  public static Set<ExternalId> from(Collection<AccountExternalId> externalIds) {
-    if (externalIds == null) {
-      return ImmutableSet.of();
-    }
-    return externalIds.stream().map(ExternalId::from).collect(toSet());
-  }
-
-  public static Set<AccountExternalId> toAccountExternalIds(Collection<ExternalId> extIds) {
-    return extIds.stream().map(e -> e.asAccountExternalId()).collect(toSet());
+        String.format("Invalid external ID config for note '%s': %s", noteId, message));
   }
 
   public abstract Key key();
@@ -292,13 +273,6 @@
     return key().isScheme(scheme);
   }
 
-  public AccountExternalId asAccountExternalId() {
-    AccountExternalId extId = new AccountExternalId(accountId(), key().asAccountExternalIdKey());
-    extId.setEmailAddress(email());
-    extId.setPassword(password());
-    return extId;
-  }
-
   /**
    * Exports this external ID as Git config file text.
    *
@@ -321,7 +295,11 @@
 
   public void writeToConfig(Config c) {
     String externalIdKey = key().get();
-    c.setInt(EXTERNAL_ID_SECTION, externalIdKey, ACCOUNT_ID_KEY, accountId().get());
+    // Do not use c.setInt(...) to write the account ID because c.setInt(...) persists integers
+    // that can be expressed in KiB as a unit strings, e.g. "1024000" is stored as "100k". Using
+    // c.setString(...) ensures that account IDs are human readable.
+    c.setString(
+        EXTERNAL_ID_SECTION, externalIdKey, ACCOUNT_ID_KEY, Integer.toString(accountId().get()));
     if (email() != null) {
       c.setString(EXTERNAL_ID_SECTION, externalIdKey, EMAIL_KEY, email());
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
new file mode 100644
index 0000000..53a1cea
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
@@ -0,0 +1,100 @@
+// 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.account.externalids;
+
+import com.google.gerrit.reviewdb.client.Account;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Caches external IDs of all accounts */
+interface ExternalIdCache {
+  void onCreate(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId)
+      throws IOException;
+
+  void onUpdate(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId)
+      throws IOException;
+
+  void onReplace(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Collection<ExternalId> toRemove,
+      Collection<ExternalId> toAdd)
+      throws IOException;
+
+  void onReplaceByKeys(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Collection<ExternalId.Key> toRemove,
+      Collection<ExternalId> toAdd)
+      throws IOException;
+
+  void onReplaceByKeys(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Collection<ExternalId.Key> toRemove,
+      Collection<ExternalId> toAdd)
+      throws IOException;
+
+  void onReplace(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Collection<ExternalId> toRemove,
+      Collection<ExternalId> toAdd)
+      throws IOException;
+
+  void onRemove(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId)
+      throws IOException;
+
+  void onRemoveByKeys(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Collection<ExternalId.Key> extIdKeys)
+      throws IOException;
+
+  void onRemoveByKeys(
+      ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId.Key> extIdKeys)
+      throws IOException;
+
+  Set<ExternalId> byAccount(Account.Id accountId) throws IOException;
+
+  Set<ExternalId> byEmail(String email) throws IOException;
+
+  default void onCreate(ObjectId oldNotesRev, ObjectId newNotesRev, ExternalId extId)
+      throws IOException {
+    onCreate(oldNotesRev, newNotesRev, Collections.singleton(extId));
+  }
+
+  default void onRemove(ObjectId oldNotesRev, ObjectId newNotesRev, ExternalId extId)
+      throws IOException {
+    onRemove(oldNotesRev, newNotesRev, Collections.singleton(extId));
+  }
+
+  default void onRemoveByKey(
+      ObjectId oldNotesRev, ObjectId newNotesRev, Account.Id accountId, ExternalId.Key extIdKey)
+      throws IOException {
+    onRemoveByKeys(oldNotesRev, newNotesRev, accountId, Collections.singleton(extIdKey));
+  }
+
+  default void onUpdate(ObjectId oldNotesRev, ObjectId newNotesRev, ExternalId updatedExtId)
+      throws IOException {
+    onUpdate(oldNotesRev, newNotesRev, Collections.singleton(updatedExtId));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
new file mode 100644
index 0000000..9db00d5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
@@ -0,0 +1,283 @@
+// 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.account.externalids;
+
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.cache.CacheBuilder;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.Consumer;
+import org.eclipse.jgit.lib.ObjectId;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Caches external IDs of all accounts. The external IDs are always loaded from NoteDb. */
+@Singleton
+class ExternalIdCacheImpl implements ExternalIdCache {
+  private static final Logger log = LoggerFactory.getLogger(ExternalIdCacheImpl.class);
+
+  private final LoadingCache<ObjectId, ImmutableSetMultimap<Account.Id, ExternalId>>
+      extIdsByAccount;
+  private final ExternalIdReader externalIdReader;
+  private final Lock lock;
+
+  @Inject
+  ExternalIdCacheImpl(ExternalIdReader externalIdReader) {
+    this.extIdsByAccount =
+        CacheBuilder.newBuilder()
+            // The cached data is potentially pretty large and we are always only interested
+            // in the latest value, hence the maximum cache size is set to 1.
+            // This can lead to extra cache loads in case of the following race:
+            // 1. thread 1 reads the notes ref at revision A
+            // 2. thread 2 updates the notes ref to revision B and stores the derived value
+            //    for B in the cache
+            // 3. thread 1 attempts to read the data for revision A from the cache, and misses
+            // 4. later threads attempt to read at B
+            // In this race unneeded reloads are done in step 3 (reload from revision A) and
+            // step 4 (reload from revision B, because the value for revision B was lost when the
+            // reload from revision A was done, since the cache can hold only one entry).
+            // These reloads could be avoided by increasing the cache size to 2. However the race
+            // window between reading the ref and looking it up in the cache is small so that
+            // it's rare that this race happens. Therefore it's not worth to double the memory
+            // usage of this cache, just to avoid this.
+            .maximumSize(1)
+            .build(new Loader(externalIdReader));
+    this.externalIdReader = externalIdReader;
+    this.lock = new ReentrantLock(true /* fair */);
+  }
+
+  @Override
+  public void onCreate(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extIds)
+      throws IOException {
+    updateCache(
+        oldNotesRev,
+        newNotesRev,
+        m -> {
+          for (ExternalId extId : extIds) {
+            m.put(extId.accountId(), extId);
+          }
+        });
+  }
+
+  @Override
+  public void onRemove(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extIds)
+      throws IOException {
+    updateCache(
+        oldNotesRev,
+        newNotesRev,
+        m -> {
+          for (ExternalId extId : extIds) {
+            m.remove(extId.accountId(), extId);
+          }
+        });
+  }
+
+  @Override
+  public void onRemoveByKeys(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Collection<ExternalId.Key> extIdKeys)
+      throws IOException {
+    updateCache(oldNotesRev, newNotesRev, m -> removeKeys(m.get(accountId), extIdKeys));
+  }
+
+  @Override
+  public void onRemoveByKeys(
+      ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId.Key> extIdKeys)
+      throws IOException {
+    updateCache(oldNotesRev, newNotesRev, m -> removeKeys(m.values(), extIdKeys));
+  }
+
+  @Override
+  public void onUpdate(
+      ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> updatedExtIds)
+      throws IOException {
+    updateCache(
+        oldNotesRev,
+        newNotesRev,
+        m -> {
+          removeKeys(m.values(), updatedExtIds.stream().map(e -> e.key()).collect(toSet()));
+          for (ExternalId updatedExtId : updatedExtIds) {
+            m.put(updatedExtId.accountId(), updatedExtId);
+          }
+        });
+  }
+
+  @Override
+  public void onReplace(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Collection<ExternalId> toRemove,
+      Collection<ExternalId> toAdd)
+      throws IOException {
+    ExternalIdsUpdate.checkSameAccount(Iterables.concat(toRemove, toAdd), accountId);
+
+    updateCache(
+        oldNotesRev,
+        newNotesRev,
+        m -> {
+          for (ExternalId extId : toRemove) {
+            m.remove(extId.accountId(), extId);
+          }
+          for (ExternalId extId : toAdd) {
+            m.put(extId.accountId(), extId);
+          }
+        });
+  }
+
+  @Override
+  public void onReplaceByKeys(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Collection<ExternalId.Key> toRemove,
+      Collection<ExternalId> toAdd)
+      throws IOException {
+    ExternalIdsUpdate.checkSameAccount(toAdd, accountId);
+
+    updateCache(
+        oldNotesRev,
+        newNotesRev,
+        m -> {
+          removeKeys(m.get(accountId), toRemove);
+          for (ExternalId extId : toAdd) {
+            m.put(extId.accountId(), extId);
+          }
+        });
+  }
+
+  @Override
+  public void onReplaceByKeys(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Collection<ExternalId.Key> toRemove,
+      Collection<ExternalId> toAdd)
+      throws IOException {
+    updateCache(
+        oldNotesRev,
+        newNotesRev,
+        m -> {
+          removeKeys(m.values(), toRemove);
+          for (ExternalId extId : toAdd) {
+            m.put(extId.accountId(), extId);
+          }
+        });
+  }
+
+  @Override
+  public void onReplace(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Collection<ExternalId> toRemove,
+      Collection<ExternalId> toAdd)
+      throws IOException {
+    updateCache(
+        oldNotesRev,
+        newNotesRev,
+        m -> {
+          for (ExternalId extId : toRemove) {
+            m.remove(extId.accountId(), extId);
+          }
+          for (ExternalId extId : toAdd) {
+            m.put(extId.accountId(), extId);
+          }
+        });
+  }
+
+  @Override
+  public Set<ExternalId> byAccount(Account.Id accountId) throws IOException {
+    try {
+      return extIdsByAccount.get(externalIdReader.readRevision()).get(accountId);
+    } catch (ExecutionException e) {
+      throw new IOException("Cannot list external ids by account", e);
+    }
+  }
+
+  @Override
+  public Set<ExternalId> byEmail(String email) throws IOException {
+    try {
+      return extIdsByAccount
+          .get(externalIdReader.readRevision())
+          .values()
+          .stream()
+          .filter(e -> email.equals(e.email()))
+          .collect(toSet());
+    } catch (ExecutionException e) {
+      throw new IOException("Cannot list external ids by email", e);
+    }
+  }
+
+  private void updateCache(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Consumer<Multimap<Account.Id, ExternalId>> update) {
+    lock.lock();
+    try {
+      ListMultimap<Account.Id, ExternalId> m;
+      if (!ObjectId.zeroId().equals(oldNotesRev)) {
+        m = MultimapBuilder.hashKeys().arrayListValues().build(extIdsByAccount.get(oldNotesRev));
+      } else {
+        m = MultimapBuilder.hashKeys().arrayListValues().build();
+      }
+      update.accept(m);
+      extIdsByAccount.put(newNotesRev, ImmutableSetMultimap.copyOf(m));
+    } catch (ExecutionException e) {
+      log.warn("Cannot update external IDs", e);
+    } finally {
+      lock.unlock();
+    }
+  }
+
+  private static void removeKeys(Collection<ExternalId> ids, Collection<ExternalId.Key> toRemove) {
+    Collections2.transform(ids, e -> e.key()).removeAll(toRemove);
+  }
+
+  private static class Loader
+      extends CacheLoader<ObjectId, ImmutableSetMultimap<Account.Id, ExternalId>> {
+    private final ExternalIdReader externalIdReader;
+
+    Loader(ExternalIdReader externalIdReader) {
+      this.externalIdReader = externalIdReader;
+    }
+
+    @Override
+    public ImmutableSetMultimap<Account.Id, ExternalId> load(ObjectId notesRev) throws Exception {
+      Multimap<Account.Id, ExternalId> extIdsByAccount =
+          MultimapBuilder.hashKeys().arrayListValues().build();
+      for (ExternalId extId : externalIdReader.all(notesRev)) {
+        extIdsByAccount.put(extId.accountId(), extId);
+      }
+      return ImmutableSetMultimap.copyOf(extIdsByAccount);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
new file mode 100644
index 0000000..8c97144
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2017 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.account.externalids;
+
+import com.google.inject.AbstractModule;
+
+public class ExternalIdModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    bind(ExternalIdCacheImpl.class);
+    bind(ExternalIdCache.class).to(ExternalIdCacheImpl.class);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
new file mode 100644
index 0000000..ead2c1c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
@@ -0,0 +1,199 @@
+// Copyright (C) 2017 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.account.externalids;
+
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.Note;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Class to read external IDs from NoteDb.
+ *
+ * <p>In NoteDb external IDs are stored in the All-Users repository in a Git Notes branch called
+ * refs/meta/external-ids where the sha1 of the external ID is used as note name. Each note content
+ * is a git config file that contains an external ID. It has exactly one externalId subsection with
+ * an accountId and optionally email and password:
+ *
+ * <pre>
+ * [externalId "username:jdoe"]
+ *   accountId = 1003407
+ *   email = jdoe@example.com
+ *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
+ * </pre>
+ */
+@Singleton
+public class ExternalIdReader {
+  private static final Logger log = LoggerFactory.getLogger(ExternalIdReader.class);
+
+  public static final int MAX_NOTE_SZ = 1 << 19;
+
+  public static ObjectId readRevision(Repository repo) throws IOException {
+    Ref ref = repo.exactRef(RefNames.REFS_EXTERNAL_IDS);
+    return ref != null ? ref.getObjectId() : ObjectId.zeroId();
+  }
+
+  public static NoteMap readNoteMap(RevWalk rw, ObjectId rev) throws IOException {
+    if (!rev.equals(ObjectId.zeroId())) {
+      return NoteMap.read(rw.getObjectReader(), rw.parseCommit(rev));
+    }
+    return NoteMap.newEmptyMap();
+  }
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private boolean failOnLoad = false;
+  private final Timer0 readAllLatency;
+
+  @Inject
+  ExternalIdReader(
+      GitRepositoryManager repoManager, AllUsersName allUsersName, MetricMaker metricMaker) {
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.readAllLatency =
+        metricMaker.newTimer(
+            "notedb/read_all_external_ids_latency",
+            new Description("Latency for reading all external IDs from NoteDb.")
+                .setCumulative()
+                .setUnit(Units.MILLISECONDS));
+  }
+
+  @VisibleForTesting
+  public void setFailOnLoad(boolean failOnLoad) {
+    this.failOnLoad = failOnLoad;
+  }
+
+  ObjectId readRevision() throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return readRevision(repo);
+    }
+  }
+
+  /** Reads and returns all external IDs. */
+  Set<ExternalId> all() throws IOException {
+    checkReadEnabled();
+
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return all(repo, readRevision(repo));
+    }
+  }
+
+  /**
+   * Reads and returns all external IDs from the specified revision of the refs/meta/external-ids
+   * branch.
+   */
+  Set<ExternalId> all(ObjectId rev) throws IOException {
+    checkReadEnabled();
+
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return all(repo, rev);
+    }
+  }
+
+  /** Reads and returns all external IDs. */
+  private Set<ExternalId> all(Repository repo, ObjectId rev) throws IOException {
+    if (rev.equals(ObjectId.zeroId())) {
+      return ImmutableSet.of();
+    }
+
+    try (Timer0.Context ctx = readAllLatency.start();
+        RevWalk rw = new RevWalk(repo)) {
+      NoteMap noteMap = readNoteMap(rw, rev);
+      Set<ExternalId> extIds = new HashSet<>();
+      for (Note note : noteMap) {
+        byte[] raw =
+            rw.getObjectReader().open(note.getData(), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
+        try {
+          extIds.add(ExternalId.parse(note.getName(), raw));
+        } catch (Exception e) {
+          log.error(String.format("Ignoring invalid external ID note %s", note.getName()), e);
+        }
+      }
+      return extIds;
+    }
+  }
+
+  /** Reads and returns the specified external ID. */
+  @Nullable
+  ExternalId get(ExternalId.Key key) throws IOException, ConfigInvalidException {
+    checkReadEnabled();
+
+    try (Repository repo = repoManager.openRepository(allUsersName);
+        RevWalk rw = new RevWalk(repo)) {
+      ObjectId rev = readRevision(repo);
+      if (rev.equals(ObjectId.zeroId())) {
+        return null;
+      }
+
+      return parse(key, rw, rev);
+    }
+  }
+
+  /** Reads and returns the specified external ID from the given revision. */
+  @Nullable
+  ExternalId get(ExternalId.Key key, ObjectId rev) throws IOException, ConfigInvalidException {
+    checkReadEnabled();
+
+    if (rev.equals(ObjectId.zeroId())) {
+      return null;
+    }
+
+    try (Repository repo = repoManager.openRepository(allUsersName);
+        RevWalk rw = new RevWalk(repo)) {
+      return parse(key, rw, rev);
+    }
+  }
+
+  private static ExternalId parse(ExternalId.Key key, RevWalk rw, ObjectId rev)
+      throws IOException, ConfigInvalidException {
+    NoteMap noteMap = readNoteMap(rw, rev);
+    ObjectId noteId = key.sha1();
+    if (!noteMap.contains(noteId)) {
+      return null;
+    }
+
+    byte[] raw =
+        rw.getObjectReader().open(noteMap.get(noteId), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
+    return ExternalId.parse(noteId.name(), raw);
+  }
+
+  private void checkReadEnabled() throws IOException {
+    if (failOnLoad) {
+      throw new IOException("Reading from external IDs is disabled");
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIds.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIds.java
new file mode 100644
index 0000000..5003a35
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIds.java
@@ -0,0 +1,80 @@
+// 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.account.externalids;
+
+import static java.util.stream.Collectors.toSet;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Class to access external IDs.
+ *
+ * <p>The external IDs are either read from NoteDb or retrieved from the cache.
+ */
+@Singleton
+public class ExternalIds {
+  private final ExternalIdReader externalIdReader;
+  private final ExternalIdCache externalIdCache;
+
+  @Inject
+  public ExternalIds(ExternalIdReader externalIdReader, ExternalIdCache externalIdCache) {
+    this.externalIdReader = externalIdReader;
+    this.externalIdCache = externalIdCache;
+  }
+
+  /** Returns all external IDs. */
+  public Set<ExternalId> all() throws IOException {
+    return externalIdReader.all();
+  }
+
+  /** Returns all external IDs from the specified revision of the refs/meta/external-ids branch. */
+  public Set<ExternalId> all(ObjectId rev) throws IOException {
+    return externalIdReader.all(rev);
+  }
+
+  /** Returns the specified external ID. */
+  @Nullable
+  public ExternalId get(ExternalId.Key key) throws IOException, ConfigInvalidException {
+    return externalIdReader.get(key);
+  }
+
+  /** Returns the specified external ID from the given revision. */
+  @Nullable
+  public ExternalId get(ExternalId.Key key, ObjectId rev)
+      throws IOException, ConfigInvalidException {
+    return externalIdReader.get(key, rev);
+  }
+
+  /** Returns the external IDs of the specified account. */
+  public Set<ExternalId> byAccount(Account.Id accountId) throws IOException {
+    return externalIdCache.byAccount(accountId);
+  }
+
+  /** Returns the external IDs of the specified account that have the given scheme. */
+  public Set<ExternalId> byAccount(Account.Id accountId, String scheme) throws IOException {
+    return byAccount(accountId).stream().filter(e -> e.key().isScheme(scheme)).collect(toSet());
+  }
+
+  public Set<ExternalId> byEmail(String email) throws IOException {
+    return externalIdCache.byEmail(email);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsBatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsBatchUpdate.java
similarity index 82%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsBatchUpdate.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsBatchUpdate.java
index 531e562..e35b0c3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsBatchUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsBatchUpdate.java
@@ -12,12 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.account;
-
-import static com.google.gerrit.server.account.ExternalId.toAccountExternalIds;
+package com.google.gerrit.server.account.externalids;
 
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -39,13 +36,14 @@
  *
  * <p>For NoteDb all updates will result in a single commit to the refs/meta/external-ids branch.
  * This means callers can prepare many updates by invoking {@link #replace(ExternalId, ExternalId)}
- * multiple times and when {@link ExternalIdsBatchUpdate#commit(ReviewDb, String)} is invoked a
- * single NoteDb commit is created that contains all the prepared updates.
+ * multiple times and when {@link ExternalIdsBatchUpdate#commit(String)} is invoked a single NoteDb
+ * commit is created that contains all the prepared updates.
  */
 public class ExternalIdsBatchUpdate {
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsersName;
   private final PersonIdent serverIdent;
+  private final ExternalIdCache externalIdCache;
   private final Set<ExternalId> toAdd = new HashSet<>();
   private final Set<ExternalId> toDelete = new HashSet<>();
 
@@ -53,16 +51,18 @@
   public ExternalIdsBatchUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
-      @GerritPersonIdent PersonIdent serverIdent) {
+      @GerritPersonIdent PersonIdent serverIdent,
+      ExternalIdCache externalIdCache) {
     this.repoManager = repoManager;
     this.allUsersName = allUsersName;
     this.serverIdent = serverIdent;
+    this.externalIdCache = externalIdCache;
   }
 
   /**
    * Adds an external ID replacement to the batch.
    *
-   * <p>The actual replacement is only done when {@link #commit(ReviewDb, String)} is invoked.
+   * <p>The actual replacement is only done when {@link #commit(String)} is invoked.
    */
   public void replace(ExternalId extIdToDelete, ExternalId extIdToAdd) {
     ExternalIdsUpdate.checkSameAccount(ImmutableSet.of(extIdToDelete, extIdToAdd));
@@ -82,21 +82,18 @@
    *
    * <p>For NoteDb a single commit is created that contains all the external ID updates.
    */
-  public void commit(ReviewDb db, String commitMessage)
+  public void commit(String commitMessage)
       throws IOException, OrmException, ConfigInvalidException {
     if (toDelete.isEmpty() && toAdd.isEmpty()) {
       return;
     }
 
-    db.accountExternalIds().delete(toAccountExternalIds(toDelete));
-    db.accountExternalIds().insert(toAccountExternalIds(toAdd));
-
     try (Repository repo = repoManager.openRepository(allUsersName);
         RevWalk rw = new RevWalk(repo);
         ObjectInserter ins = repo.newObjectInserter()) {
-      ObjectId rev = ExternalIds.readRevision(repo);
+      ObjectId rev = ExternalIdReader.readRevision(repo);
 
-      NoteMap noteMap = ExternalIds.readNoteMap(rw, rev);
+      NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
 
       for (ExternalId extId : toDelete) {
         ExternalIdsUpdate.remove(rw, noteMap, extId);
@@ -106,8 +103,10 @@
         ExternalIdsUpdate.insert(rw, ins, noteMap, extId);
       }
 
-      ExternalIdsUpdate.commit(
-          repo, rw, ins, rev, noteMap, commitMessage, serverIdent, serverIdent);
+      ObjectId newRev =
+          ExternalIdsUpdate.commit(
+              repo, rw, ins, rev, noteMap, commitMessage, serverIdent, serverIdent);
+      externalIdCache.onReplace(rev, newRev, toDelete, toAdd);
     }
 
     toAdd.clear();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
new file mode 100644
index 0000000..928349d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
@@ -0,0 +1,155 @@
+// Copyright (C) 2017 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.account.externalids;
+
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+import static java.util.stream.Collectors.joining;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.HashedPassword;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.commons.codec.DecoderException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.Note;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+@Singleton
+public class ExternalIdsConsistencyChecker {
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsers;
+  private final AccountCache accountCache;
+  private final OutgoingEmailValidator validator;
+
+  @Inject
+  ExternalIdsConsistencyChecker(
+      GitRepositoryManager repoManager,
+      AllUsersName allUsers,
+      AccountCache accountCache,
+      OutgoingEmailValidator validator) {
+    this.repoManager = repoManager;
+    this.allUsers = allUsers;
+    this.accountCache = accountCache;
+    this.validator = validator;
+  }
+
+  public List<ConsistencyProblemInfo> check() throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return check(repo, ExternalIdReader.readRevision(repo));
+    }
+  }
+
+  public List<ConsistencyProblemInfo> check(ObjectId rev) throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return check(repo, rev);
+    }
+  }
+
+  private List<ConsistencyProblemInfo> check(Repository repo, ObjectId commit) throws IOException {
+    List<ConsistencyProblemInfo> problems = new ArrayList<>();
+
+    ListMultimap<String, ExternalId.Key> emails =
+        MultimapBuilder.hashKeys().arrayListValues().build();
+
+    try (RevWalk rw = new RevWalk(repo)) {
+      NoteMap noteMap = ExternalIdReader.readNoteMap(rw, commit);
+      for (Note note : noteMap) {
+        byte[] raw =
+            rw.getObjectReader()
+                .open(note.getData(), OBJ_BLOB)
+                .getCachedBytes(ExternalIdReader.MAX_NOTE_SZ);
+        try {
+          ExternalId extId = ExternalId.parse(note.getName(), raw);
+          problems.addAll(validateExternalId(extId));
+
+          if (extId.email() != null) {
+            emails.put(extId.email(), extId.key());
+          }
+        } catch (ConfigInvalidException e) {
+          addError(String.format(e.getMessage()), problems);
+        }
+      }
+    }
+
+    emails
+        .asMap()
+        .entrySet()
+        .stream()
+        .filter(e -> e.getValue().size() > 1)
+        .forEach(
+            e ->
+                addError(
+                    String.format(
+                        "Email '%s' is not unique, it's used by the following external IDs: %s",
+                        e.getKey(),
+                        e.getValue()
+                            .stream()
+                            .map(k -> "'" + k.get() + "'")
+                            .sorted()
+                            .collect(joining(", "))),
+                    problems));
+
+    return problems;
+  }
+
+  private List<ConsistencyProblemInfo> validateExternalId(ExternalId extId) {
+    List<ConsistencyProblemInfo> problems = new ArrayList<>();
+
+    if (accountCache.getIfPresent(extId.accountId()) == null) {
+      addError(
+          String.format(
+              "External ID '%s' belongs to account that doesn't exist: %s",
+              extId.key().get(), extId.accountId().get()),
+          problems);
+    }
+
+    if (extId.email() != null && !validator.isValid(extId.email())) {
+      addError(
+          String.format(
+              "External ID '%s' has an invalid email: %s", extId.key().get(), extId.email()),
+          problems);
+    }
+
+    if (extId.password() != null && extId.isScheme(SCHEME_USERNAME)) {
+      try {
+        HashedPassword.decode(extId.password());
+      } catch (DecoderException e) {
+        addError(
+            String.format(
+                "External ID '%s' has an invalid password: %s", extId.key().get(), e.getMessage()),
+            problems);
+      }
+    }
+
+    return problems;
+  }
+
+  private static void addError(String error, List<ConsistencyProblemInfo> problems) {
+    problems.add(new ConsistencyProblemInfo(ConsistencyProblemInfo.Status.ERROR, error));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsUpdate.java
similarity index 61%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsUpdate.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsUpdate.java
index a596a8e..c21ff95 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsUpdate.java
@@ -12,15 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.account;
+package com.google.gerrit.server.account.externalids;
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.account.ExternalId.Key.toAccountExternalIdKeys;
-import static com.google.gerrit.server.account.ExternalId.toAccountExternalIds;
-import static com.google.gerrit.server.account.ExternalIds.MAX_NOTE_SZ;
-import static com.google.gerrit.server.account.ExternalIds.readNoteMap;
-import static com.google.gerrit.server.account.ExternalIds.readRevision;
+import static com.google.gerrit.server.account.externalids.ExternalIdReader.MAX_NOTE_SZ;
+import static com.google.gerrit.server.account.externalids.ExternalIdReader.readNoteMap;
+import static com.google.gerrit.server.account.externalids.ExternalIdReader.readRevision;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toSet;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
@@ -37,9 +35,11 @@
 import com.google.common.collect.Iterables;
 import com.google.common.util.concurrent.Runnables;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllUsersName;
@@ -53,7 +53,6 @@
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -98,21 +97,31 @@
   public static class Server {
     private final GitRepositoryManager repoManager;
     private final AllUsersName allUsersName;
+    private final MetricMaker metricMaker;
+    private final ExternalIds externalIds;
+    private final ExternalIdCache externalIdCache;
     private final Provider<PersonIdent> serverIdent;
 
     @Inject
     public Server(
         GitRepositoryManager repoManager,
         AllUsersName allUsersName,
+        MetricMaker metricMaker,
+        ExternalIds externalIds,
+        ExternalIdCache externalIdCache,
         @GerritPersonIdent Provider<PersonIdent> serverIdent) {
       this.repoManager = repoManager;
       this.allUsersName = allUsersName;
+      this.metricMaker = metricMaker;
+      this.externalIds = externalIds;
+      this.externalIdCache = externalIdCache;
       this.serverIdent = serverIdent;
     }
 
     public ExternalIdsUpdate create() {
       PersonIdent i = serverIdent.get();
-      return new ExternalIdsUpdate(repoManager, allUsersName, i, i);
+      return new ExternalIdsUpdate(
+          repoManager, allUsersName, metricMaker, externalIds, externalIdCache, i, i);
     }
   }
 
@@ -126,6 +135,9 @@
   public static class User {
     private final GitRepositoryManager repoManager;
     private final AllUsersName allUsersName;
+    private final MetricMaker metricMaker;
+    private final ExternalIds externalIds;
+    private final ExternalIdCache externalIdCache;
     private final Provider<PersonIdent> serverIdent;
     private final Provider<IdentifiedUser> identifiedUser;
 
@@ -133,10 +145,16 @@
     public User(
         GitRepositoryManager repoManager,
         AllUsersName allUsersName,
+        MetricMaker metricMaker,
+        ExternalIds externalIds,
+        ExternalIdCache externalIdCache,
         @GerritPersonIdent Provider<PersonIdent> serverIdent,
         Provider<IdentifiedUser> identifiedUser) {
       this.repoManager = repoManager;
       this.allUsersName = allUsersName;
+      this.metricMaker = metricMaker;
+      this.externalIds = externalIds;
+      this.externalIdCache = externalIdCache;
       this.serverIdent = serverIdent;
       this.identifiedUser = identifiedUser;
     }
@@ -144,7 +162,13 @@
     public ExternalIdsUpdate create() {
       PersonIdent i = serverIdent.get();
       return new ExternalIdsUpdate(
-          repoManager, allUsersName, createPersonIdent(i, identifiedUser.get()), i);
+          repoManager,
+          allUsersName,
+          metricMaker,
+          externalIds,
+          externalIdCache,
+          createPersonIdent(i, identifiedUser.get()),
+          i);
     }
 
     private PersonIdent createPersonIdent(PersonIdent ident, IdentifiedUser user) {
@@ -153,8 +177,8 @@
   }
 
   @VisibleForTesting
-  public static RetryerBuilder<Void> retryerBuilder() {
-    return RetryerBuilder.<Void>newBuilder()
+  public static RetryerBuilder<RefsMetaExternalIdsUpdate> retryerBuilder() {
+    return RetryerBuilder.<RefsMetaExternalIdsUpdate>newBuilder()
         .retryIfException(e -> e instanceof LockFailureException)
         .withWaitStrategy(
             WaitStrategies.join(
@@ -163,37 +187,61 @@
         .withStopStrategy(StopStrategies.stopAfterDelay(10, TimeUnit.SECONDS));
   }
 
-  private static final Retryer<Void> RETRYER = retryerBuilder().build();
+  private static final Retryer<RefsMetaExternalIdsUpdate> RETRYER = retryerBuilder().build();
 
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsersName;
+  private final ExternalIds externalIds;
+  private final ExternalIdCache externalIdCache;
   private final PersonIdent committerIdent;
   private final PersonIdent authorIdent;
   private final Runnable afterReadRevision;
-  private final Retryer<Void> retryer;
+  private final Retryer<RefsMetaExternalIdsUpdate> retryer;
+  private final Counter0 updateCount;
 
   private ExternalIdsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
+      MetricMaker metricMaker,
+      ExternalIds externalIds,
+      ExternalIdCache externalIdCache,
       PersonIdent committerIdent,
       PersonIdent authorIdent) {
-    this(repoManager, allUsersName, committerIdent, authorIdent, Runnables.doNothing(), RETRYER);
+    this(
+        repoManager,
+        allUsersName,
+        metricMaker,
+        externalIds,
+        externalIdCache,
+        committerIdent,
+        authorIdent,
+        Runnables.doNothing(),
+        RETRYER);
   }
 
   @VisibleForTesting
   public ExternalIdsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
+      MetricMaker metricMaker,
+      ExternalIds externalIds,
+      ExternalIdCache externalIdCache,
       PersonIdent committerIdent,
       PersonIdent authorIdent,
       Runnable afterReadRevision,
-      Retryer<Void> retryer) {
+      Retryer<RefsMetaExternalIdsUpdate> retryer) {
     this.repoManager = checkNotNull(repoManager, "repoManager");
     this.allUsersName = checkNotNull(allUsersName, "allUsersName");
     this.committerIdent = checkNotNull(committerIdent, "committerIdent");
+    this.externalIds = checkNotNull(externalIds, "externalIds");
+    this.externalIdCache = checkNotNull(externalIdCache, "externalIdCache");
     this.authorIdent = checkNotNull(authorIdent, "authorIdent");
     this.afterReadRevision = checkNotNull(afterReadRevision, "afterReadRevision");
     this.retryer = checkNotNull(retryer, "retryer");
+    this.updateCount =
+        metricMaker.newCounter(
+            "notedb/external_id_update_count",
+            new Description("Total number of external ID updates.").setRate().setUnit("updates"));
   }
 
   /**
@@ -201,9 +249,8 @@
    *
    * <p>If the external ID already exists, the insert fails with {@link OrmDuplicateKeyException}.
    */
-  public void insert(ReviewDb db, ExternalId extId)
-      throws IOException, ConfigInvalidException, OrmException {
-    insert(db, Collections.singleton(extId));
+  public void insert(ExternalId extId) throws IOException, ConfigInvalidException, OrmException {
+    insert(Collections.singleton(extId));
   }
 
   /**
@@ -212,16 +259,16 @@
    * <p>If any of the external ID already exists, the insert fails with {@link
    * OrmDuplicateKeyException}.
    */
-  public void insert(ReviewDb db, Collection<ExternalId> extIds)
+  public void insert(Collection<ExternalId> extIds)
       throws IOException, ConfigInvalidException, OrmException {
-    db.accountExternalIds().insert(toAccountExternalIds(extIds));
-
-    updateNoteMap(
-        o -> {
-          for (ExternalId extId : extIds) {
-            insert(o.rw(), o.ins(), o.noteMap(), extId);
-          }
-        });
+    RefsMetaExternalIdsUpdate u =
+        updateNoteMap(
+            o -> {
+              for (ExternalId extId : extIds) {
+                insert(o.rw(), o.ins(), o.noteMap(), extId);
+              }
+            });
+    externalIdCache.onCreate(u.oldRev(), u.newRev(), extIds);
   }
 
   /**
@@ -229,9 +276,8 @@
    *
    * <p>If the external ID already exists, it is overwritten, otherwise it is inserted.
    */
-  public void upsert(ReviewDb db, ExternalId extId)
-      throws IOException, ConfigInvalidException, OrmException {
-    upsert(db, Collections.singleton(extId));
+  public void upsert(ExternalId extId) throws IOException, ConfigInvalidException, OrmException {
+    upsert(Collections.singleton(extId));
   }
 
   /**
@@ -239,81 +285,97 @@
    *
    * <p>If any of the external IDs already exists, it is overwritten. New external IDs are inserted.
    */
-  public void upsert(ReviewDb db, Collection<ExternalId> extIds)
+  public void upsert(Collection<ExternalId> extIds)
       throws IOException, ConfigInvalidException, OrmException {
-    db.accountExternalIds().upsert(toAccountExternalIds(extIds));
-
-    updateNoteMap(
-        o -> {
-          for (ExternalId extId : extIds) {
-            upsert(o.rw(), o.ins(), o.noteMap(), extId);
-          }
-        });
+    RefsMetaExternalIdsUpdate u =
+        updateNoteMap(
+            o -> {
+              for (ExternalId extId : extIds) {
+                upsert(o.rw(), o.ins(), o.noteMap(), extId);
+              }
+            });
+    externalIdCache.onUpdate(u.oldRev(), u.newRev(), extIds);
   }
 
   /**
    * Deletes an external ID.
    *
-   * <p>The deletion fails with {@link IllegalStateException} if there is an existing external ID
-   * that has the same key, but otherwise doesn't match the specified external ID.
+   * @throws IllegalStateException is thrown if there is an existing external ID that has the same
+   *     key, but otherwise doesn't match the specified external ID.
    */
-  public void delete(ReviewDb db, ExternalId extId)
-      throws IOException, ConfigInvalidException, OrmException {
-    delete(db, Collections.singleton(extId));
+  public void delete(ExternalId extId) throws IOException, ConfigInvalidException, OrmException {
+    delete(Collections.singleton(extId));
   }
 
   /**
    * Deletes external IDs.
    *
-   * <p>The deletion fails with {@link IllegalStateException} if there is an existing external ID
-   * that has the same key as any of the external IDs that should be deleted, but otherwise doesn't
-   * match the that external ID.
+   * @throws IllegalStateException is thrown if there is an existing external ID that has the same
+   *     key as any of the external IDs that should be deleted, but otherwise doesn't match the that
+   *     external ID.
    */
-  public void delete(ReviewDb db, Collection<ExternalId> extIds)
+  public void delete(Collection<ExternalId> extIds)
       throws IOException, ConfigInvalidException, OrmException {
-    db.accountExternalIds().delete(toAccountExternalIds(extIds));
-
-    updateNoteMap(
-        o -> {
-          for (ExternalId extId : extIds) {
-            remove(o.rw(), o.noteMap(), extId);
-          }
-        });
+    RefsMetaExternalIdsUpdate u =
+        updateNoteMap(
+            o -> {
+              for (ExternalId extId : extIds) {
+                remove(o.rw(), o.noteMap(), extId);
+              }
+            });
+    externalIdCache.onRemove(u.oldRev(), u.newRev(), extIds);
   }
 
   /**
    * Delete an external ID by key.
    *
-   * <p>The external ID is only deleted if it belongs to the specified account. If it belongs to
-   * another account the deletion fails with {@link IllegalStateException}.
+   * @throws IllegalStateException is thrown if the external ID does not belong to the specified
+   *     account.
    */
-  public void delete(ReviewDb db, Account.Id accountId, ExternalId.Key extIdKey)
+  public void delete(Account.Id accountId, ExternalId.Key extIdKey)
       throws IOException, ConfigInvalidException, OrmException {
-    delete(db, accountId, Collections.singleton(extIdKey));
+    delete(accountId, Collections.singleton(extIdKey));
   }
 
   /**
    * Delete external IDs by external ID key.
    *
-   * <p>The external IDs are only deleted if they belongs to the specified account. If any of the
-   * external IDs belongs to another account the deletion fails with {@link IllegalStateException}.
+   * @throws IllegalStateException is thrown if any of the external IDs does not belong to the
+   *     specified account.
    */
-  public void delete(ReviewDb db, Account.Id accountId, Collection<ExternalId.Key> extIdKeys)
+  public void delete(Account.Id accountId, Collection<ExternalId.Key> extIdKeys)
       throws IOException, ConfigInvalidException, OrmException {
-    db.accountExternalIds().deleteKeys(toAccountExternalIdKeys(extIdKeys));
+    RefsMetaExternalIdsUpdate u =
+        updateNoteMap(
+            o -> {
+              for (ExternalId.Key extIdKey : extIdKeys) {
+                remove(o.rw(), o.noteMap(), extIdKey, accountId);
+              }
+            });
+    externalIdCache.onRemoveByKeys(u.oldRev(), u.newRev(), accountId, extIdKeys);
+  }
 
-    updateNoteMap(
-        o -> {
-          for (ExternalId.Key extIdKey : extIdKeys) {
-            remove(o.rw(), o.noteMap(), accountId, extIdKey);
-          }
-        });
+  /**
+   * Delete external IDs by external ID key.
+   *
+   * <p>The external IDs are deleted regardless of which account they belong to.
+   */
+  public void deleteByKeys(Collection<ExternalId.Key> extIdKeys)
+      throws IOException, ConfigInvalidException, OrmException {
+    RefsMetaExternalIdsUpdate u =
+        updateNoteMap(
+            o -> {
+              for (ExternalId.Key extIdKey : extIdKeys) {
+                remove(o.rw(), o.noteMap(), extIdKey, null);
+              }
+            });
+    externalIdCache.onRemoveByKeys(u.oldRev(), u.newRev(), extIdKeys);
   }
 
   /** Deletes all external IDs of the specified account. */
-  public void deleteAll(ReviewDb db, Account.Id accountId)
+  public void deleteAll(Account.Id accountId)
       throws IOException, ConfigInvalidException, OrmException {
-    delete(db, ExternalId.from(db.accountExternalIds().byAccount(accountId).toList()));
+    delete(externalIds.byAccount(accountId));
   }
 
   /**
@@ -324,41 +386,63 @@
    * be added, the old external ID with that key is deleted first and then the new external ID is
    * added (so the external ID for that key is replaced).
    *
-   * <p>If any of the specified external IDs belongs to another account the replacement fails with
-   * {@link IllegalStateException}.
+   * @throws IllegalStateException is thrown if any of the specified external IDs does not belong to
+   *     the specified account.
    */
   public void replace(
-      ReviewDb db,
-      Account.Id accountId,
-      Collection<ExternalId.Key> toDelete,
-      Collection<ExternalId> toAdd)
+      Account.Id accountId, Collection<ExternalId.Key> toDelete, Collection<ExternalId> toAdd)
       throws IOException, ConfigInvalidException, OrmException {
     checkSameAccount(toAdd, accountId);
 
-    db.accountExternalIds().deleteKeys(toAccountExternalIdKeys(toDelete));
-    db.accountExternalIds().insert(toAccountExternalIds(toAdd));
+    RefsMetaExternalIdsUpdate u =
+        updateNoteMap(
+            o -> {
+              for (ExternalId.Key extIdKey : toDelete) {
+                remove(o.rw(), o.noteMap(), extIdKey, accountId);
+              }
 
-    updateNoteMap(
-        o -> {
-          for (ExternalId.Key extIdKey : toDelete) {
-            remove(o.rw(), o.noteMap(), accountId, extIdKey);
-          }
+              for (ExternalId extId : toAdd) {
+                insert(o.rw(), o.ins(), o.noteMap(), extId);
+              }
+            });
+    externalIdCache.onReplaceByKeys(u.oldRev(), u.newRev(), accountId, toDelete, toAdd);
+  }
 
-          for (ExternalId extId : toAdd) {
-            insert(o.rw(), o.ins(), o.noteMap(), extId);
-          }
-        });
+  /**
+   * Replaces external IDs for an account by external ID keys.
+   *
+   * <p>Deletion of external IDs is done before adding the new external IDs. This means if an
+   * external ID key is specified for deletion and an external ID with the same key is specified to
+   * be added, the old external ID with that key is deleted first and then the new external ID is
+   * added (so the external ID for that key is replaced).
+   *
+   * <p>The external IDs are replaced regardless of which account they belong to.
+   */
+  public void replaceByKeys(Collection<ExternalId.Key> toDelete, Collection<ExternalId> toAdd)
+      throws IOException, ConfigInvalidException, OrmException {
+    RefsMetaExternalIdsUpdate u =
+        updateNoteMap(
+            o -> {
+              for (ExternalId.Key extIdKey : toDelete) {
+                remove(o.rw(), o.noteMap(), extIdKey, null);
+              }
+
+              for (ExternalId extId : toAdd) {
+                insert(o.rw(), o.ins(), o.noteMap(), extId);
+              }
+            });
+    externalIdCache.onReplaceByKeys(u.oldRev(), u.newRev(), toDelete, toAdd);
   }
 
   /**
    * Replaces an external ID.
    *
-   * <p>If the specified external IDs belongs to different accounts the replacement fails with
-   * {@link IllegalStateException}.
+   * @throws IllegalStateException is thrown if the specified external IDs belong to different
+   *     accounts.
    */
-  public void replace(ReviewDb db, ExternalId toDelete, ExternalId toAdd)
+  public void replace(ExternalId toDelete, ExternalId toAdd)
       throws IOException, ConfigInvalidException, OrmException {
-    replace(db, Collections.singleton(toDelete), Collections.singleton(toAdd));
+    replace(Collections.singleton(toDelete), Collections.singleton(toAdd));
   }
 
   /**
@@ -369,10 +453,10 @@
    * added, the old external ID with that key is deleted first and then the new external ID is added
    * (so the external ID for that key is replaced).
    *
-   * <p>If the specified external IDs belong to different accounts the replacement fails with {@link
-   * IllegalStateException}.
+   * @throws IllegalStateException is thrown if the specified external IDs belong to different
+   *     accounts.
    */
-  public void replace(ReviewDb db, Collection<ExternalId> toDelete, Collection<ExternalId> toAdd)
+  public void replace(Collection<ExternalId> toDelete, Collection<ExternalId> toAdd)
       throws IOException, ConfigInvalidException, OrmException {
     Account.Id accountId = checkSameAccount(Iterables.concat(toDelete, toAdd));
     if (accountId == null) {
@@ -380,7 +464,7 @@
       return;
     }
 
-    replace(db, accountId, toDelete.stream().map(e -> e.key()).collect(toSet()), toAdd);
+    replace(accountId, toDelete.stream().map(e -> e.key()).collect(toSet()), toAdd);
   }
 
   /**
@@ -457,8 +541,8 @@
   /**
    * Removes an external ID from the note map.
    *
-   * <p>The removal fails with {@link IllegalStateException} if there is an existing external ID
-   * that has the same key, but otherwise doesn't match the specified external ID.
+   * @throws IllegalStateException is thrown if there is an existing external ID that has the same
+   *     key, but otherwise doesn't match the specified external ID.
    */
   public static void remove(RevWalk rw, NoteMap noteMap, ExternalId extId)
       throws IOException, ConfigInvalidException {
@@ -481,11 +565,11 @@
   /**
    * Removes an external ID from the note map by external ID key.
    *
-   * <p>The external ID is only deleted if it belongs to the specified account. If the external IDs
-   * belongs to another account the deletion fails with {@link IllegalStateException}.
+   * @throws IllegalStateException is thrown if an expected account ID is provided and an external
+   *     ID with the specified key exists, but belongs to another account.
    */
   private static void remove(
-      RevWalk rw, NoteMap noteMap, Account.Id accountId, ExternalId.Key extIdKey)
+      RevWalk rw, NoteMap noteMap, ExternalId.Key extIdKey, Account.Id expectedAccountId)
       throws IOException, ConfigInvalidException {
     ObjectId noteId = extIdKey.sha1();
     if (!noteMap.contains(noteId)) {
@@ -495,22 +579,37 @@
     byte[] raw =
         rw.getObjectReader().open(noteMap.get(noteId), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
     ExternalId extId = ExternalId.parse(noteId.name(), raw);
-    checkState(
-        accountId.equals(extId.accountId()),
-        "external id %s should be removed for account %s,"
-            + " but external id belongs to account %s",
-        extIdKey.get(),
-        accountId.get(),
-        extId.accountId().get());
+    if (expectedAccountId != null) {
+      checkState(
+          expectedAccountId.equals(extId.accountId()),
+          "external id %s should be removed for account %s,"
+              + " but external id belongs to account %s",
+          extIdKey.get(),
+          expectedAccountId.get(),
+          extId.accountId().get());
+    }
     noteMap.remove(noteId);
   }
 
-  private void updateNoteMap(MyConsumer<OpenRepo> update)
+  private RefsMetaExternalIdsUpdate updateNoteMap(MyConsumer<OpenRepo> update)
       throws IOException, ConfigInvalidException, OrmException {
-    try (Repository repo = repoManager.openRepository(allUsersName);
-        RevWalk rw = new RevWalk(repo);
-        ObjectInserter ins = repo.newObjectInserter()) {
-      retryer.call(new TryNoteMapUpdate(repo, rw, ins, update));
+    try {
+      return retryer.call(
+          () -> {
+            try (Repository repo = repoManager.openRepository(allUsersName);
+                ObjectInserter ins = repo.newObjectInserter()) {
+              ObjectId rev = readRevision(repo);
+
+              afterReadRevision.run();
+
+              try (RevWalk rw = new RevWalk(repo)) {
+                NoteMap noteMap = readNoteMap(rw, rev);
+                update.accept(OpenRepo.create(repo, rw, ins, noteMap));
+
+                return commit(repo, rw, ins, rev, noteMap);
+              }
+            }
+          });
     } catch (ExecutionException | RetryException e) {
       if (e.getCause() != null) {
         Throwables.throwIfInstanceOf(e.getCause(), IOException.class);
@@ -521,14 +620,16 @@
     }
   }
 
-  private void commit(
+  private RefsMetaExternalIdsUpdate commit(
       Repository repo, RevWalk rw, ObjectInserter ins, ObjectId rev, NoteMap noteMap)
       throws IOException {
-    commit(repo, rw, ins, rev, noteMap, COMMIT_MSG, committerIdent, authorIdent);
+    ObjectId newRev = commit(repo, rw, ins, rev, noteMap, COMMIT_MSG, committerIdent, authorIdent);
+    updateCount.increment();
+    return RefsMetaExternalIdsUpdate.create(rev, newRev);
   }
 
   /** Commits updates to the external IDs. */
-  public static void commit(
+  public static ObjectId commit(
       Repository repo,
       RevWalk rw,
       ObjectInserter ins,
@@ -581,12 +682,14 @@
       default:
         throw new IOException("Updating external IDs failed with " + res);
     }
+    return rw.parseCommit(commitId);
   }
 
   private static ObjectId emptyTree(ObjectInserter ins) throws IOException {
     return ins.insert(OBJ_TREE, new byte[] {});
   }
 
+  @FunctionalInterface
   private static interface MyConsumer<T> {
     void accept(T t) throws IOException, ConfigInvalidException, OrmException;
   }
@@ -606,31 +709,15 @@
     abstract NoteMap noteMap();
   }
 
-  private class TryNoteMapUpdate implements Callable<Void> {
-    private final Repository repo;
-    private final RevWalk rw;
-    private final ObjectInserter ins;
-    private final MyConsumer<OpenRepo> update;
-
-    private TryNoteMapUpdate(
-        Repository repo, RevWalk rw, ObjectInserter ins, MyConsumer<OpenRepo> update) {
-      this.repo = repo;
-      this.rw = rw;
-      this.ins = ins;
-      this.update = update;
+  @VisibleForTesting
+  @AutoValue
+  public abstract static class RefsMetaExternalIdsUpdate {
+    static RefsMetaExternalIdsUpdate create(ObjectId oldRev, ObjectId newRev) {
+      return new AutoValue_ExternalIdsUpdate_RefsMetaExternalIdsUpdate(oldRev, newRev);
     }
 
-    @Override
-    public Void call() throws Exception {
-      ObjectId rev = readRevision(repo);
+    abstract ObjectId oldRev();
 
-      afterReadRevision.run();
-
-      NoteMap noteMap = readNoteMap(rw, rev);
-      update.accept(OpenRepo.create(repo, rw, ins, noteMap));
-
-      commit(repo, rw, ins, rev, noteMap);
-      return null;
-    }
+    abstract ObjectId newRev();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/ApiUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/ApiUtil.java
new file mode 100644
index 0000000..c5b8b12
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/ApiUtil.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api;
+
+import com.google.common.base.Throwables;
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+/** Static utilities for API implementations. */
+public class ApiUtil {
+  /**
+   * Convert an exception encountered during API execution to a {@link RestApiException}.
+   *
+   * @param msg message to be used in the case where a new {@code RestApiException} is wrapped
+   *     around {@code e}.
+   * @param e exception being handled.
+   * @return {@code e} if it is already a {@code RestApiException}, otherwise a new {@code
+   *     RestApiException} wrapped around {@code e}.
+   * @throws RuntimeException if {@code e} is a runtime exception, it is rethrown as-is.
+   */
+  public static RestApiException asRestApiException(String msg, Exception e)
+      throws RuntimeException {
+    Throwables.throwIfUnchecked(e);
+    return e instanceof RestApiException ? (RestApiException) e : new RestApiException(msg, e);
+  }
+
+  private ApiUtil() {}
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index 430b6b7..64760a65 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.api.accounts;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
 
 import com.google.gerrit.common.RawInputUtil;
-import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.accounts.AccountApi;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
@@ -38,7 +38,6 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AddSshKey;
@@ -71,14 +70,11 @@
 import com.google.gerrit.server.account.Stars;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ChangesCollection;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
 import java.util.List;
 import java.util.Map;
 import java.util.SortedSet;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 
 public class AccountApiImpl implements AccountApi {
   interface Factory {
@@ -202,8 +198,8 @@
       AccountInfo ai = accountLoader.get(account.getUser().getAccountId());
       accountLoader.fill();
       return ai;
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot parse change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse change", e);
     }
   }
 
@@ -221,8 +217,8 @@
       } else {
         deleteActive.apply(account, new DeleteActive.Input());
       }
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot set active", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set active", e);
     }
   }
 
@@ -234,15 +230,19 @@
 
   @Override
   public GeneralPreferencesInfo getPreferences() throws RestApiException {
-    return getPreferences.apply(account);
+    try {
+      return getPreferences.apply(account);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get preferences", e);
+    }
   }
 
   @Override
   public GeneralPreferencesInfo setPreferences(GeneralPreferencesInfo in) throws RestApiException {
     try {
       return setPreferences.apply(account, in);
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot set preferences", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set preferences", e);
     }
   }
 
@@ -250,8 +250,8 @@
   public DiffPreferencesInfo getDiffPreferences() throws RestApiException {
     try {
       return getDiffPreferences.apply(account);
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot query diff preferences", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot query diff preferences", e);
     }
   }
 
@@ -259,8 +259,8 @@
   public DiffPreferencesInfo setDiffPreferences(DiffPreferencesInfo in) throws RestApiException {
     try {
       return setDiffPreferences.apply(account, in);
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot set diff preferences", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set diff preferences", e);
     }
   }
 
@@ -268,8 +268,8 @@
   public EditPreferencesInfo getEditPreferences() throws RestApiException {
     try {
       return getEditPreferences.apply(account);
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot query edit preferences", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot query edit preferences", e);
     }
   }
 
@@ -277,8 +277,8 @@
   public EditPreferencesInfo setEditPreferences(EditPreferencesInfo in) throws RestApiException {
     try {
       return setEditPreferences.apply(account, in);
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot set edit preferences", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set edit preferences", e);
     }
   }
 
@@ -286,8 +286,8 @@
   public List<ProjectWatchInfo> getWatchedProjects() throws RestApiException {
     try {
       return getWatchedProjects.apply(account);
-    } catch (OrmException | IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot get watched projects", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get watched projects", e);
     }
   }
 
@@ -296,8 +296,8 @@
       throws RestApiException {
     try {
       return postWatchedProjects.apply(account, in);
-    } catch (OrmException | IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot update watched projects", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot update watched projects", e);
     }
   }
 
@@ -305,8 +305,8 @@
   public void deleteWatchedProjects(List<ProjectWatchInfo> in) throws RestApiException {
     try {
       deleteWatchedProjects.apply(account, in);
-    } catch (OrmException | IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot delete watched projects", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete watched projects", e);
     }
   }
 
@@ -316,8 +316,8 @@
       ChangeResource rsrc = changes.parse(TopLevelResource.INSTANCE, IdString.fromUrl(changeId));
       starredChangesCreate.setChange(rsrc);
       starredChangesCreate.apply(account, new StarredChanges.EmptyInput());
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot star change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot star change", e);
     }
   }
 
@@ -328,8 +328,8 @@
       AccountResource.StarredChange starredChange =
           new AccountResource.StarredChange(account.getUser(), rsrc);
       starredChangesDelete.apply(starredChange, new StarredChanges.EmptyInput());
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot unstar change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot unstar change", e);
     }
   }
 
@@ -338,8 +338,8 @@
     try {
       AccountResource.Star rsrc = stars.parse(account, IdString.fromUrl(changeId));
       starsPost.apply(rsrc, input);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot post stars", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot post stars", e);
     }
   }
 
@@ -348,8 +348,8 @@
     try {
       AccountResource.Star rsrc = stars.parse(account, IdString.fromUrl(changeId));
       return starsGet.apply(rsrc);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot get stars", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get stars", e);
     }
   }
 
@@ -357,8 +357,8 @@
   public List<ChangeInfo> getStarredChanges() throws RestApiException {
     try {
       return stars.list().apply(account);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot get starred changes", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get starred changes", e);
     }
   }
 
@@ -372,8 +372,8 @@
     AccountResource.Email rsrc = new AccountResource.Email(account.getUser(), input.email);
     try {
       createEmailFactory.create(input.email).apply(rsrc, input);
-    } catch (EmailException | OrmException | IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot add email", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot add email", e);
     }
   }
 
@@ -382,8 +382,8 @@
     AccountResource.Email rsrc = new AccountResource.Email(account.getUser(), email);
     try {
       deleteEmail.apply(rsrc, null);
-    } catch (OrmException | IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot delete email", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete email", e);
     }
   }
 
@@ -392,8 +392,8 @@
     PutStatus.Input in = new PutStatus.Input(status);
     try {
       putStatus.apply(account, in);
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot set status", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set status", e);
     }
   }
 
@@ -401,8 +401,8 @@
   public List<SshKeyInfo> listSshKeys() throws RestApiException {
     try {
       return getSshKeys.apply(account);
-    } catch (OrmException | IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot list SSH keys", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list SSH keys", e);
     }
   }
 
@@ -412,8 +412,8 @@
     in.raw = RawInputUtil.create(key);
     try {
       return addSshKey.apply(account, in).value();
-    } catch (OrmException | IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot add SSH key", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot add SSH key", e);
     }
   }
 
@@ -423,8 +423,8 @@
       AccountResource.SshKey sshKeyRes =
           sshKeys.parse(account, IdString.fromDecoded(Integer.toString(seq)));
       deleteSshKey.apply(sshKeyRes, null);
-    } catch (OrmException | IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot delete SSH key", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete SSH key", e);
     }
   }
 
@@ -432,8 +432,8 @@
   public Map<String, GpgKeyInfo> listGpgKeys() throws RestApiException {
     try {
       return gpgApiAdapter.listGpgKeys(account);
-    } catch (GpgException e) {
-      throw new RestApiException("Cannot list GPG keys", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list GPG keys", e);
     }
   }
 
@@ -442,8 +442,8 @@
       throws RestApiException {
     try {
       return gpgApiAdapter.putGpgKeys(account, add, delete);
-    } catch (GpgException e) {
-      throw new RestApiException("Cannot add GPG key", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot add GPG key", e);
     }
   }
 
@@ -451,8 +451,8 @@
   public GpgKeyApi gpgKey(String id) throws RestApiException {
     try {
       return gpgApiAdapter.gpgKey(account, IdString.fromDecoded(id));
-    } catch (GpgException e) {
-      throw new RestApiException("Cannot get PGP key", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get PGP key", e);
     }
   }
 
@@ -467,8 +467,8 @@
       AgreementInput input = new AgreementInput();
       input.name = agreementName;
       putAgreement.apply(account, input);
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot sign agreement", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot sign agreement", e);
     }
   }
 
@@ -476,8 +476,8 @@
   public void index() throws RestApiException {
     try {
       index.apply(account, new Index.Input());
-    } catch (IOException e) {
-      throw new RestApiException("Cannot index account", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot index account", e);
     }
   }
 
@@ -485,8 +485,8 @@
   public List<AccountExternalIdInfo> getExternalIds() throws RestApiException {
     try {
       return getExternalIds.apply(account);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot get external IDs", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get external IDs", e);
     }
   }
 
@@ -494,8 +494,8 @@
   public void deleteExternalIds(List<String> externalIds) throws RestApiException {
     try {
       deleteExternalIds.apply(account, externalIds);
-    } catch (IOException | OrmException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot delete external IDs", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete external IDs", e);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java
index 2d90853..2f8dee6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.api.accounts;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import java.util.List;
 
 public interface AccountExternalIdCreator {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountsImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
index 498b720..5257aec 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.api.accounts;
 
 import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.gerrit.server.account.CapabilityUtils.checkRequiresCapability;
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.gerrit.extensions.api.accounts.AccountApi;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
@@ -32,18 +32,18 @@
 import com.google.gerrit.server.account.AccountsCollection;
 import com.google.gerrit.server.account.CreateAccount;
 import com.google.gerrit.server.account.QueryAccounts;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import java.util.List;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class AccountsImpl implements Accounts {
   private final AccountsCollection accounts;
   private final AccountApiImpl.Factory api;
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> self;
   private final CreateAccount.Factory createAccount;
   private final Provider<QueryAccounts> queryAccountsProvider;
@@ -52,11 +52,13 @@
   AccountsImpl(
       AccountsCollection accounts,
       AccountApiImpl.Factory api,
+      PermissionBackend permissionBackend,
       Provider<CurrentUser> self,
       CreateAccount.Factory createAccount,
       Provider<QueryAccounts> queryAccountsProvider) {
     this.accounts = accounts;
     this.api = api;
+    this.permissionBackend = permissionBackend;
     this.self = self;
     this.createAccount = createAccount;
     this.queryAccountsProvider = queryAccountsProvider;
@@ -66,8 +68,8 @@
   public AccountApi id(String id) throws RestApiException {
     try {
       return api.create(accounts.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(id)));
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot parse change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse change", e);
     }
   }
 
@@ -96,13 +98,13 @@
     if (checkNotNull(in, "AccountInput").username == null) {
       throw new BadRequestException("AccountInput must specify username");
     }
-    checkRequiresCapability(self, null, CreateAccount.class);
     try {
-      AccountInfo info =
-          createAccount.create(in.username).apply(TopLevelResource.INSTANCE, in).value();
+      CreateAccount impl = createAccount.create(in.username);
+      permissionBackend.user(self).checkAny(GlobalPermission.fromAnnotation(impl.getClass()));
+      AccountInfo info = impl.apply(TopLevelResource.INSTANCE, in).value();
       return id(info._accountId);
-    } catch (OrmException | IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot create account " + in.username, e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create account " + in.username, e);
     }
   }
 
@@ -128,8 +130,8 @@
       myQueryAccounts.setQuery(r.getQuery());
       myQueryAccounts.setLimit(r.getLimit());
       return myQueryAccounts.apply(TopLevelResource.INSTANCE);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve suggested accounts", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve suggested accounts", e);
     }
   }
 
@@ -158,8 +160,8 @@
         myQueryAccounts.addOption(option);
       }
       return myQueryAccounts.apply(TopLevelResource.INSTANCE);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve suggested accounts", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve suggested accounts", e);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index a0babe1..f4ea3b0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -14,9 +14,12 @@
 
 package com.google.gerrit.server.api.changes;
 
-import com.google.gerrit.common.errors.EmailException;
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.ChangeEditApi;
@@ -51,16 +54,20 @@
 import com.google.gerrit.server.change.CreateMergePatchSet;
 import com.google.gerrit.server.change.DeleteAssignee;
 import com.google.gerrit.server.change.DeleteChange;
+import com.google.gerrit.server.change.DeletePrivate;
 import com.google.gerrit.server.change.GetAssignee;
 import com.google.gerrit.server.change.GetHashtags;
 import com.google.gerrit.server.change.GetPastAssignees;
 import com.google.gerrit.server.change.GetTopic;
+import com.google.gerrit.server.change.Ignore;
 import com.google.gerrit.server.change.Index;
 import com.google.gerrit.server.change.ListChangeComments;
 import com.google.gerrit.server.change.ListChangeDrafts;
 import com.google.gerrit.server.change.ListChangeRobotComments;
 import com.google.gerrit.server.change.Move;
+import com.google.gerrit.server.change.Mute;
 import com.google.gerrit.server.change.PostHashtags;
+import com.google.gerrit.server.change.PostPrivate;
 import com.google.gerrit.server.change.PostReviewers;
 import com.google.gerrit.server.change.PublishDraftPatchSet;
 import com.google.gerrit.server.change.PutAssignee;
@@ -70,15 +77,17 @@
 import com.google.gerrit.server.change.Revert;
 import com.google.gerrit.server.change.Reviewers;
 import com.google.gerrit.server.change.Revisions;
+import com.google.gerrit.server.change.SetPrivateOp;
+import com.google.gerrit.server.change.SetReadyForReview;
+import com.google.gerrit.server.change.SetWorkInProgress;
 import com.google.gerrit.server.change.SubmittedTogether;
 import com.google.gerrit.server.change.SuggestChangeReviewers;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.change.Unignore;
+import com.google.gerrit.server.change.Unmute;
+import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
 import java.util.EnumSet;
 import java.util.List;
 import java.util.Map;
@@ -122,6 +131,14 @@
   private final Check check;
   private final Index index;
   private final Move move;
+  private final PostPrivate postPrivate;
+  private final DeletePrivate deletePrivate;
+  private final Ignore ignore;
+  private final Unignore unignore;
+  private final Mute mute;
+  private final Unmute unmute;
+  private final SetWorkInProgress setWip;
+  private final SetReadyForReview setReady;
 
   @Inject
   ChangeApiImpl(
@@ -157,6 +174,14 @@
       Check check,
       Index index,
       Move move,
+      PostPrivate postPrivate,
+      DeletePrivate deletePrivate,
+      Ignore ignore,
+      Unignore unignore,
+      Mute mute,
+      Unmute unmute,
+      SetWorkInProgress setWip,
+      SetReadyForReview setReady,
       @Assisted ChangeResource change) {
     this.changeApi = changeApi;
     this.revert = revert;
@@ -190,6 +215,14 @@
     this.check = check;
     this.index = index;
     this.move = move;
+    this.postPrivate = postPrivate;
+    this.deletePrivate = deletePrivate;
+    this.ignore = ignore;
+    this.unignore = unignore;
+    this.mute = mute;
+    this.unmute = unmute;
+    this.setWip = setWip;
+    this.setReady = setReady;
     this.change = change;
   }
 
@@ -212,8 +245,8 @@
   public RevisionApi revision(String id) throws RestApiException {
     try {
       return revisionApi.create(revisions.parse(change, IdString.fromDecoded(id)));
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot parse revision", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse revision", e);
     }
   }
 
@@ -221,8 +254,8 @@
   public ReviewerApi reviewer(String id) throws RestApiException {
     try {
       return reviewerApi.create(reviewers.parse(change, IdString.fromDecoded(id)));
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot parse reviewer", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse reviewer", e);
     }
   }
 
@@ -235,8 +268,8 @@
   public void abandon(AbandonInput in) throws RestApiException {
     try {
       abandon.apply(change, in);
-    } catch (OrmException | UpdateException e) {
-      throw new RestApiException("Cannot abandon change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot abandon change", e);
     }
   }
 
@@ -249,8 +282,8 @@
   public void restore(RestoreInput in) throws RestApiException {
     try {
       restore.apply(change, in);
-    } catch (OrmException | UpdateException e) {
-      throw new RestApiException("Cannot restore change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot restore change", e);
     }
   }
 
@@ -265,8 +298,40 @@
   public void move(MoveInput in) throws RestApiException {
     try {
       move.apply(change, in);
-    } catch (OrmException | UpdateException e) {
-      throw new RestApiException("Cannot move change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot move change", e);
+    }
+  }
+
+  @Override
+  public void setPrivate(boolean value, @Nullable String message) throws RestApiException {
+    try {
+      SetPrivateOp.Input input = new SetPrivateOp.Input(message);
+      if (value) {
+        postPrivate.apply(change, input);
+      } else {
+        deletePrivate.apply(change, input);
+      }
+    } catch (Exception e) {
+      throw asRestApiException("Cannot change private status", e);
+    }
+  }
+
+  @Override
+  public void setWorkInProgress(String message) throws RestApiException {
+    try {
+      setWip.apply(change, new WorkInProgressOp.Input(message));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set work in progress state", e);
+    }
+  }
+
+  @Override
+  public void setReadyForReview(String message) throws RestApiException {
+    try {
+      setReady.apply(change, new WorkInProgressOp.Input(message));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set ready for review state", e);
     }
   }
 
@@ -279,8 +344,8 @@
   public ChangeApi revert(RevertInput in) throws RestApiException {
     try {
       return changeApi.id(revert.apply(change, in)._number);
-    } catch (OrmException | IOException | UpdateException e) {
-      throw new RestApiException("Cannot revert change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot revert change", e);
     }
   }
 
@@ -288,8 +353,8 @@
   public ChangeInfo createMergePatchSet(MergePatchSetInput in) throws RestApiException {
     try {
       return updateByMerge.apply(change, in).value();
-    } catch (IOException | UpdateException | InvalidChangeOperationException | OrmException e) {
-      throw new RestApiException("Cannot update change by merge", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot update change by merge", e);
     }
   }
 
@@ -317,8 +382,8 @@
           .addListChangesOption(listOptions)
           .addSubmittedTogetherOption(submitOptions)
           .applyInfo(change);
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot query submittedTogether", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot query submittedTogether", e);
     }
   }
 
@@ -326,8 +391,8 @@
   public void publish() throws RestApiException {
     try {
       publishDraftChange.apply(change, null);
-    } catch (UpdateException e) {
-      throw new RestApiException("Cannot publish change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot publish change", e);
     }
   }
 
@@ -340,8 +405,8 @@
   public void rebase(RebaseInput in) throws RestApiException {
     try {
       rebase.apply(change, in);
-    } catch (EmailException | OrmException | UpdateException | IOException e) {
-      throw new RestApiException("Cannot rebase change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot rebase change", e);
     }
   }
 
@@ -349,8 +414,8 @@
   public void delete() throws RestApiException {
     try {
       deleteChange.apply(change, null);
-    } catch (UpdateException e) {
-      throw new RestApiException("Cannot delete change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete change", e);
     }
   }
 
@@ -365,8 +430,8 @@
     in.topic = topic;
     try {
       putTopic.apply(change, in);
-    } catch (UpdateException e) {
-      throw new RestApiException("Cannot set topic", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set topic", e);
     }
   }
 
@@ -374,24 +439,24 @@
   public IncludedInInfo includedIn() throws RestApiException {
     try {
       return includedIn.apply(change);
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Could not extract IncludedIn data", e);
+    } catch (Exception e) {
+      throw asRestApiException("Could not extract IncludedIn data", e);
     }
   }
 
   @Override
-  public void addReviewer(String reviewer) throws RestApiException {
+  public AddReviewerResult addReviewer(String reviewer) throws RestApiException {
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = reviewer;
-    addReviewer(in);
+    return addReviewer(in);
   }
 
   @Override
-  public void addReviewer(AddReviewerInput in) throws RestApiException {
+  public AddReviewerResult addReviewer(AddReviewerInput in) throws RestApiException {
     try {
-      postReviewers.apply(change, in);
-    } catch (OrmException | IOException | UpdateException e) {
-      throw new RestApiException("Cannot add change reviewer", e);
+      return postReviewers.apply(change, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot add change reviewer", e);
     }
   }
 
@@ -416,8 +481,8 @@
       suggestReviewers.setQuery(r.getQuery());
       suggestReviewers.setLimit(r.getLimit());
       return suggestReviewers.apply(change);
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot retrieve suggested reviewers", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve suggested reviewers", e);
     }
   }
 
@@ -425,8 +490,8 @@
   public ChangeInfo get(EnumSet<ListChangesOption> s) throws RestApiException {
     try {
       return changeJson.create(s).format(change);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve change", e);
     }
   }
 
@@ -454,8 +519,8 @@
   public void setHashtags(HashtagsInput input) throws RestApiException {
     try {
       postHashtags.apply(change, input);
-    } catch (UpdateException e) {
-      throw new RestApiException("Cannot post hashtags", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot post hashtags", e);
     }
   }
 
@@ -463,17 +528,17 @@
   public Set<String> getHashtags() throws RestApiException {
     try {
       return getHashtags.apply(change).value();
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot get hashtags", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get hashtags", e);
     }
   }
 
   @Override
   public AccountInfo setAssignee(AssigneeInput input) throws RestApiException {
     try {
-      return putAssignee.apply(change, input).value();
-    } catch (UpdateException | IOException | OrmException e) {
-      throw new RestApiException("Cannot set assignee", e);
+      return putAssignee.apply(change, input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set assignee", e);
     }
   }
 
@@ -482,8 +547,8 @@
     try {
       Response<AccountInfo> r = getAssignee.apply(change);
       return r.isNone() ? null : r.value();
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot get assignee", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get assignee", e);
     }
   }
 
@@ -491,8 +556,8 @@
   public List<AccountInfo> getPastAssignees() throws RestApiException {
     try {
       return getPastAssignees.apply(change).value();
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot get past assignees", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get past assignees", e);
     }
   }
 
@@ -501,8 +566,8 @@
     try {
       Response<AccountInfo> r = deleteAssignee.apply(change, null);
       return r.isNone() ? null : r.value();
-    } catch (UpdateException | OrmException e) {
-      throw new RestApiException("Cannot delete assignee", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete assignee", e);
     }
   }
 
@@ -510,8 +575,8 @@
   public Map<String, List<CommentInfo>> comments() throws RestApiException {
     try {
       return listComments.apply(change);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot get comments", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get comments", e);
     }
   }
 
@@ -519,8 +584,8 @@
   public Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException {
     try {
       return listChangeRobotComments.apply(change);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot get robot comments", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get robot comments", e);
     }
   }
 
@@ -528,8 +593,8 @@
   public Map<String, List<CommentInfo>> drafts() throws RestApiException {
     try {
       return listDrafts.apply(change);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot get drafts", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get drafts", e);
     }
   }
 
@@ -537,17 +602,19 @@
   public ChangeInfo check() throws RestApiException {
     try {
       return check.apply(change).value();
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot check change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check change", e);
     }
   }
 
   @Override
   public ChangeInfo check(FixInput fix) throws RestApiException {
     try {
+      // TODO(dborowitz): Convert to RetryingRestModifyView. Needs to plumb BatchUpdate.Factory into
+      // ConsistencyChecker.
       return check.apply(change, fix).value();
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot check change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check change", e);
     }
   }
 
@@ -555,8 +622,30 @@
   public void index() throws RestApiException {
     try {
       index.apply(change, new Index.Input());
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot index change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot index change", e);
+    }
+  }
+
+  @Override
+  public void ignore(boolean ignore) throws RestApiException {
+    // TODO(dborowitz): Convert to RetryingRestModifyView. Needs to plumb BatchUpdate.Factory into
+    // StarredChangesUtil.
+    if (ignore) {
+      this.ignore.apply(change, new Ignore.Input());
+    } else {
+      unignore.apply(change, new Unignore.Input());
+    }
+  }
+
+  @Override
+  public void mute(boolean mute) throws RestApiException {
+    // TODO(dborowitz): Convert to RetryingRestModifyView. Needs to plumb BatchUpdate.Factory into
+    // StarredChangesUtil.
+    if (mute) {
+      this.mute.apply(change, new Mute.Input());
+    } else {
+      unmute.apply(change, new Unmute.Input());
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
index 80d5071..5184e89 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.api.changes;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.changes.ChangeEditApi;
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
 import com.google.gerrit.extensions.common.EditInfo;
@@ -30,7 +32,6 @@
 import com.google.gerrit.server.change.DeleteChangeEdit;
 import com.google.gerrit.server.change.PublishChangeEdit;
 import com.google.gerrit.server.change.RebaseChangeEdit;
-import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -88,8 +89,8 @@
     try {
       Response<EditInfo> edit = editDetail.apply(changeResource);
       return edit.isNone() ? Optional.empty() : Optional.of(edit.value());
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot retrieve change edit", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve change edit", e);
     }
   }
 
@@ -97,8 +98,8 @@
   public void create() throws RestApiException {
     try {
       changeEditsPost.apply(changeResource, null);
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot create change edit", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create change edit", e);
     }
   }
 
@@ -106,8 +107,8 @@
   public void delete() throws RestApiException {
     try {
       deleteChangeEdit.apply(changeResource, new DeleteChangeEdit.Input());
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot delete change edit", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete change edit", e);
     }
   }
 
@@ -115,8 +116,8 @@
   public void rebase() throws RestApiException {
     try {
       rebaseChangeEdit.apply(changeResource, null);
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot rebase change edit", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot rebase change edit", e);
     }
   }
 
@@ -129,8 +130,8 @@
   public void publish(PublishChangeEditInput publishChangeEditInput) throws RestApiException {
     try {
       publishChangeEdit.apply(changeResource, publishChangeEditInput);
-    } catch (IOException | OrmException | UpdateException e) {
-      throw new RestApiException("Cannot publish change edit", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot publish change edit", e);
     }
   }
 
@@ -140,8 +141,8 @@
       ChangeEditResource changeEditResource = getChangeEditResource(filePath);
       Response<BinaryResult> fileResponse = changeEditsGet.apply(changeEditResource);
       return fileResponse.isNone() ? Optional.empty() : Optional.of(fileResponse.value());
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot retrieve file of change edit", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve file of change edit", e);
     }
   }
 
@@ -152,8 +153,8 @@
       renameInput.oldPath = oldFilePath;
       renameInput.newPath = newFilePath;
       changeEditsPost.apply(changeResource, renameInput);
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot rename file of change edit", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot rename file of change edit", e);
     }
   }
 
@@ -163,8 +164,8 @@
       ChangeEdits.Post.Input restoreInput = new ChangeEdits.Post.Input();
       restoreInput.restorePath = filePath;
       changeEditsPost.apply(changeResource, restoreInput);
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot restore file of change edit", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot restore file of change edit", e);
     }
   }
 
@@ -172,8 +173,8 @@
   public void modifyFile(String filePath, RawInput newContent) throws RestApiException {
     try {
       changeEditsPut.apply(changeResource.getControl(), filePath, newContent);
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot modify file of change edit", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot modify file of change edit", e);
     }
   }
 
@@ -181,8 +182,8 @@
   public void deleteFile(String filePath) throws RestApiException {
     try {
       changeEditDeleteContent.apply(changeResource.getControl(), filePath);
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot delete file of change edit", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete file of change edit", e);
     }
   }
 
@@ -192,8 +193,8 @@
       try (BinaryResult binaryResult = getChangeEditCommitMessage.apply(changeResource)) {
         return binaryResult.asString();
       }
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot get commit message of change edit", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get commit message of change edit", e);
     }
   }
 
@@ -203,8 +204,8 @@
     input.message = newCommitMessage;
     try {
       modifyChangeEditCommitMessage.apply(changeResource, input);
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot modify commit message of change edit", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot modify commit message of change edit", e);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangesImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangesImpl.java
index c77f86f..b800655 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangesImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangesImpl.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
@@ -24,7 +25,6 @@
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
@@ -32,14 +32,10 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.change.ChangesCollection;
 import com.google.gerrit.server.change.CreateChange;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.query.change.QueryChanges;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import java.util.List;
 
 @Singleton
@@ -77,8 +73,8 @@
   public ChangeApi id(String id) throws RestApiException {
     try {
       return api.create(changes.parse(TopLevelResource.INSTANCE, IdString.fromUrl(id)));
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot parse change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse change", e);
     }
   }
 
@@ -87,8 +83,8 @@
     try {
       ChangeInfo out = createChange.apply(TopLevelResource.INSTANCE, in).value();
       return api.create(changes.parse(new Change.Id(out._number)));
-    } catch (OrmException | IOException | InvalidChangeOperationException | UpdateException e) {
-      throw new RestApiException("Cannot create change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create change", e);
     }
   }
 
@@ -132,8 +128,8 @@
       List<ChangeInfo> infos = (List<ChangeInfo>) result;
 
       return ImmutableList.copyOf(infos);
-    } catch (AuthException | OrmException e) {
-      throw new RestApiException("Cannot query changes", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot query changes", e);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/CommentApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
index 5c61e23..6a2501e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
@@ -14,12 +14,15 @@
 
 package com.google.gerrit.server.api.changes;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.changes.CommentApi;
+import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.change.CommentResource;
+import com.google.gerrit.server.change.DeleteComment;
 import com.google.gerrit.server.change.GetComment;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -29,11 +32,14 @@
   }
 
   private final GetComment getComment;
+  private final DeleteComment deleteComment;
   private final CommentResource comment;
 
   @Inject
-  CommentApiImpl(GetComment getComment, @Assisted CommentResource comment) {
+  CommentApiImpl(
+      GetComment getComment, DeleteComment deleteComment, @Assisted CommentResource comment) {
     this.getComment = getComment;
+    this.deleteComment = deleteComment;
     this.comment = comment;
   }
 
@@ -41,8 +47,17 @@
   public CommentInfo get() throws RestApiException {
     try {
       return getComment.apply(comment);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve comment", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve comment", e);
+    }
+  }
+
+  @Override
+  public CommentInfo delete(DeleteCommentInput input) throws RestApiException {
+    try {
+      return deleteComment.apply(comment, input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete comment", e);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/DraftApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
index 1bd9216..eada51b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
@@ -14,16 +14,18 @@
 
 package com.google.gerrit.server.api.changes;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
 import com.google.gerrit.extensions.api.changes.DraftApi;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.change.DeleteDraftComment;
 import com.google.gerrit.server.change.DraftCommentResource;
 import com.google.gerrit.server.change.GetDraftComment;
 import com.google.gerrit.server.change.PutDraftComment;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -53,8 +55,8 @@
   public CommentInfo get() throws RestApiException {
     try {
       return getDraft.apply(draft);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve draft", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve draft", e);
     }
   }
 
@@ -62,8 +64,8 @@
   public CommentInfo update(DraftInput in) throws RestApiException {
     try {
       return putDraft.apply(draft, in).value();
-    } catch (UpdateException | OrmException e) {
-      throw new RestApiException("Cannot update draft", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot update draft", e);
     }
   }
 
@@ -71,8 +73,13 @@
   public void delete() throws RestApiException {
     try {
       deleteDraft.apply(draft, null);
-    } catch (UpdateException e) {
-      throw new RestApiException("Cannot delete draft", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete draft", e);
     }
   }
+
+  @Override
+  public CommentInfo delete(DeleteCommentInput input) {
+    throw new NotImplementedException();
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/FileApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/FileApiImpl.java
index aa66e7b..f0a934f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/FileApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/FileApiImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.api.changes;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.changes.FileApi;
 import com.google.gerrit.extensions.common.DiffInfo;
 import com.google.gerrit.extensions.restapi.BinaryResult;
@@ -21,11 +23,8 @@
 import com.google.gerrit.server.change.FileResource;
 import com.google.gerrit.server.change.GetContent;
 import com.google.gerrit.server.change.GetDiff;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
 
 class FileApiImpl implements FileApi {
   interface Factory {
@@ -47,8 +46,8 @@
   public BinaryResult content() throws RestApiException {
     try {
       return getContent.apply(file);
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot retrieve file content", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve file content", e);
     }
   }
 
@@ -56,8 +55,8 @@
   public DiffInfo diff() throws RestApiException {
     try {
       return getDiff.apply(file).value();
-    } catch (IOException | InvalidChangeOperationException | OrmException e) {
-      throw new RestApiException("Cannot retrieve diff", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve diff", e);
     }
   }
 
@@ -65,8 +64,8 @@
   public DiffInfo diff(String base) throws RestApiException {
     try {
       return getDiff.setBase(base).apply(file).value();
-    } catch (IOException | InvalidChangeOperationException | OrmException e) {
-      throw new RestApiException("Cannot retrieve diff", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve diff", e);
     }
   }
 
@@ -74,8 +73,8 @@
   public DiffInfo diff(int parent) throws RestApiException {
     try {
       return getDiff.setParent(parent).apply(file).value();
-    } catch (OrmException | InvalidChangeOperationException | IOException e) {
-      throw new RestApiException("Cannot retrieve diff", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve diff", e);
     }
   }
 
@@ -104,8 +103,8 @@
     }
     try {
       return getDiff.apply(file).value();
-    } catch (IOException | InvalidChangeOperationException | OrmException e) {
-      throw new RestApiException("Cannot retrieve diff", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve diff", e);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
index 8ac874a..2f8b7d8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.api.changes;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.ReviewerApi;
@@ -23,8 +25,6 @@
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.VoteResource;
 import com.google.gerrit.server.change.Votes;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.Map;
@@ -55,8 +55,8 @@
   public Map<String, Short> votes() throws RestApiException {
     try {
       return listVotes.apply(reviewer);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot list votes", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list votes", e);
     }
   }
 
@@ -64,8 +64,8 @@
   public void deleteVote(String label) throws RestApiException {
     try {
       deleteVote.apply(new VoteResource(reviewer, label), null);
-    } catch (UpdateException e) {
-      throw new RestApiException("Cannot delete vote", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete vote", e);
     }
   }
 
@@ -73,8 +73,8 @@
   public void deleteVote(DeleteVoteInput input) throws RestApiException {
     try {
       deleteVote.apply(new VoteResource(reviewer, input.label), input);
-    } catch (UpdateException e) {
-      throw new RestApiException("Cannot delete vote", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete vote", e);
     }
   }
 
@@ -87,8 +87,8 @@
   public void remove(DeleteReviewerInput input) throws RestApiException {
     try {
       deleteReviewer.apply(reviewer, input);
-    } catch (UpdateException e) {
-      throw new RestApiException("Cannot remove reviewer", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot remove reviewer", e);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 43be8df..21fb578 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -14,8 +14,9 @@
 
 package com.google.gerrit.server.api.changes;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.Changes;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
@@ -33,6 +34,7 @@
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.common.MergeableInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
@@ -41,6 +43,7 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.change.ApplyFix;
 import com.google.gerrit.server.change.CherryPick;
 import com.google.gerrit.server.change.Comments;
 import com.google.gerrit.server.change.CreateDraftComment;
@@ -48,6 +51,7 @@
 import com.google.gerrit.server.change.DraftComments;
 import com.google.gerrit.server.change.FileResource;
 import com.google.gerrit.server.change.Files;
+import com.google.gerrit.server.change.Fixes;
 import com.google.gerrit.server.change.GetDescription;
 import com.google.gerrit.server.change.GetMergeList;
 import com.google.gerrit.server.change.GetPatch;
@@ -69,13 +73,9 @@
 import com.google.gerrit.server.change.Submit;
 import com.google.gerrit.server.change.TestSubmitType;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -109,6 +109,8 @@
   private final FileApiImpl.Factory fileApi;
   private final ListRevisionComments listComments;
   private final ListRobotComments listRobotComments;
+  private final ApplyFix applyFix;
+  private final Fixes fixes;
   private final ListRevisionDrafts listDrafts;
   private final CreateDraftComment createDraft;
   private final DraftComments drafts;
@@ -147,6 +149,8 @@
       FileApiImpl.Factory fileApi,
       ListRevisionComments listComments,
       ListRobotComments listRobotComments,
+      ApplyFix applyFix,
+      Fixes fixes,
       ListRevisionDrafts listDrafts,
       CreateDraftComment createDraft,
       DraftComments drafts,
@@ -184,6 +188,8 @@
     this.listComments = listComments;
     this.robotComments = robotComments;
     this.listRobotComments = listRobotComments;
+    this.applyFix = applyFix;
+    this.fixes = fixes;
     this.listDrafts = listDrafts;
     this.createDraft = createDraft;
     this.drafts = drafts;
@@ -204,8 +210,8 @@
   public void review(ReviewInput in) throws RestApiException {
     try {
       review.apply(revision, in);
-    } catch (OrmException | UpdateException | IOException e) {
-      throw new RestApiException("Cannot post review", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot post review", e);
     }
   }
 
@@ -218,9 +224,11 @@
   @Override
   public void submit(SubmitInput in) throws RestApiException {
     try {
+      // TODO(dborowitz): Convert to RetryingRestModifyHandler. Requires converting MergeOp to a
+      // Factory that takes BatchUpdate.Factory. (Enough Factories yet?)
       submit.apply(revision, in);
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot submit change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot submit change", e);
     }
   }
 
@@ -232,10 +240,12 @@
   @Override
   public BinaryResult submitPreview(String format) throws RestApiException {
     try {
+      // TODO(dborowitz): Convert to RetryingRestModifyHandler. Requires converting MergeOp to a
+      // Factory that takes BatchUpdate.Factory.
       submitPreview.setFormat(format);
       return submitPreview.apply(revision);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot get submit preview", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get submit preview", e);
     }
   }
 
@@ -243,8 +253,8 @@
   public void publish() throws RestApiException {
     try {
       publish.apply(revision, new PublishDraftPatchSet.Input());
-    } catch (UpdateException e) {
-      throw new RestApiException("Cannot publish draft patch set", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot publish draft patch set", e);
     }
   }
 
@@ -252,8 +262,8 @@
   public void delete() throws RestApiException {
     try {
       deleteDraft.apply(revision, null);
-    } catch (UpdateException e) {
-      throw new RestApiException("Cannot delete draft ps", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete draft ps", e);
     }
   }
 
@@ -267,8 +277,8 @@
   public ChangeApi rebase(RebaseInput in) throws RestApiException {
     try {
       return changes.id(rebase.apply(revision, in)._number);
-    } catch (OrmException | EmailException | UpdateException | IOException e) {
-      throw new RestApiException("Cannot rebase ps", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot rebase ps", e);
     }
   }
 
@@ -277,8 +287,8 @@
     try (Repository repo = repoManager.openRepository(revision.getProject());
         RevWalk rw = new RevWalk(repo)) {
       return rebaseUtil.canRebase(revision.getPatchSet(), revision.getChange().getDest(), repo, rw);
-    } catch (IOException e) {
-      throw new RestApiException("Cannot check if rebase is possible", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check if rebase is possible", e);
     }
   }
 
@@ -286,8 +296,8 @@
   public ChangeApi cherryPick(CherryPickInput in) throws RestApiException {
     try {
       return changes.id(cherryPick.apply(revision, in)._number);
-    } catch (OrmException | IOException | UpdateException e) {
-      throw new RestApiException("Cannot cherry pick", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot cherry pick", e);
     }
   }
 
@@ -296,8 +306,8 @@
     try {
       return revisionReviewerApi.create(
           revisionReviewers.parse(revision, IdString.fromDecoded(id)));
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot parse reviewer", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse reviewer", e);
     }
   }
 
@@ -312,7 +322,7 @@
       }
       view.apply(files.parse(revision, IdString.fromDecoded(path)), new Reviewed.Input());
     } catch (Exception e) {
-      throw new RestApiException("Cannot update reviewed flag", e);
+      throw asRestApiException("Cannot update reviewed flag", e);
     }
   }
 
@@ -322,8 +332,8 @@
     try {
       return ImmutableSet.copyOf(
           (Iterable<String>) listFiles.setReviewed(true).apply(revision).value());
-    } catch (OrmException | IOException | PatchListNotAvailableException e) {
-      throw new RestApiException("Cannot list reviewed files", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list reviewed files", e);
     }
   }
 
@@ -331,8 +341,8 @@
   public MergeableInfo mergeable() throws RestApiException {
     try {
       return mergeable.apply(revision);
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot check mergeability", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check mergeability", e);
     }
   }
 
@@ -341,8 +351,8 @@
     try {
       mergeable.setOtherBranches(true);
       return mergeable.apply(revision);
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot check mergeability", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check mergeability", e);
     }
   }
 
@@ -351,8 +361,8 @@
   public Map<String, FileInfo> files() throws RestApiException {
     try {
       return (Map<String, FileInfo>) listFiles.apply(revision).value();
-    } catch (OrmException | IOException | PatchListNotAvailableException e) {
-      throw new RestApiException("Cannot retrieve files", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve files", e);
     }
   }
 
@@ -361,8 +371,8 @@
   public Map<String, FileInfo> files(String base) throws RestApiException {
     try {
       return (Map<String, FileInfo>) listFiles.setBase(base).apply(revision).value();
-    } catch (OrmException | IOException | PatchListNotAvailableException e) {
-      throw new RestApiException("Cannot retrieve files", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve files", e);
     }
   }
 
@@ -371,8 +381,8 @@
   public Map<String, FileInfo> files(int parentNum) throws RestApiException {
     try {
       return (Map<String, FileInfo>) listFiles.setParent(parentNum).apply(revision).value();
-    } catch (OrmException | IOException | PatchListNotAvailableException e) {
-      throw new RestApiException("Cannot retrieve files", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve files", e);
     }
   }
 
@@ -385,8 +395,8 @@
   public Map<String, List<CommentInfo>> comments() throws RestApiException {
     try {
       return listComments.apply(revision);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve comments", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve comments", e);
     }
   }
 
@@ -394,8 +404,8 @@
   public Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException {
     try {
       return listRobotComments.apply(revision);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve robot comments", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve robot comments", e);
     }
   }
 
@@ -403,8 +413,8 @@
   public List<CommentInfo> commentsAsList() throws RestApiException {
     try {
       return listComments.getComments(revision);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve comments", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve comments", e);
     }
   }
 
@@ -412,8 +422,8 @@
   public Map<String, List<CommentInfo>> drafts() throws RestApiException {
     try {
       return listDrafts.apply(revision);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve drafts", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve drafts", e);
     }
   }
 
@@ -421,8 +431,17 @@
   public List<RobotCommentInfo> robotCommentsAsList() throws RestApiException {
     try {
       return listRobotComments.getComments(revision);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve robot comments", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve robot comments", e);
+    }
+  }
+
+  @Override
+  public EditInfo applyFix(String fixId) throws RestApiException {
+    try {
+      return applyFix.apply(fixes.parse(revision, IdString.fromDecoded(fixId)), null).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot apply fix", e);
     }
   }
 
@@ -430,8 +449,8 @@
   public List<CommentInfo> draftsAsList() throws RestApiException {
     try {
       return listDrafts.getComments(revision);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve drafts", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve drafts", e);
     }
   }
 
@@ -439,8 +458,8 @@
   public DraftApi draft(String id) throws RestApiException {
     try {
       return draftFactory.create(drafts.parse(revision, IdString.fromDecoded(id)));
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve draft", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve draft", e);
     }
   }
 
@@ -453,8 +472,8 @@
           .id(revision.getChange().getId().get())
           .revision(revision.getPatchSet().getId().get())
           .draft(id);
-    } catch (UpdateException | OrmException e) {
-      throw new RestApiException("Cannot create draft", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create draft", e);
     }
   }
 
@@ -462,8 +481,8 @@
   public CommentApi comment(String id) throws RestApiException {
     try {
       return commentFactory.create(comments.parse(revision, IdString.fromDecoded(id)));
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve comment", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve comment", e);
     }
   }
 
@@ -471,8 +490,8 @@
   public RobotCommentApi robotComment(String id) throws RestApiException {
     try {
       return robotCommentFactory.create(robotComments.parse(revision, IdString.fromDecoded(id)));
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve robot comment", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve robot comment", e);
     }
   }
 
@@ -480,8 +499,8 @@
   public BinaryResult patch() throws RestApiException {
     try {
       return getPatch.apply(revision);
-    } catch (IOException e) {
-      throw new RestApiException("Cannot get patch", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get patch", e);
     }
   }
 
@@ -489,8 +508,8 @@
   public BinaryResult patch(String path) throws RestApiException {
     try {
       return getPatch.setPath(path).apply(revision);
-    } catch (IOException e) {
-      throw new RestApiException("Cannot get patch", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get patch", e);
     }
   }
 
@@ -498,8 +517,8 @@
   public Map<String, ActionInfo> actions() throws RestApiException {
     try {
       return revisionActions.apply(revision).value();
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot get actions", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get actions", e);
     }
   }
 
@@ -507,8 +526,8 @@
   public SubmitType submitType() throws RestApiException {
     try {
       return getSubmitType.apply(revision);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot get submit type", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get submit type", e);
     }
   }
 
@@ -516,8 +535,8 @@
   public SubmitType testSubmitType(TestSubmitRuleInput in) throws RestApiException {
     try {
       return testSubmitType.apply(revision, in);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot test submit type", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot test submit type", e);
     }
   }
 
@@ -531,8 +550,8 @@
           gml.setUninterestingParent(getUninterestingParent());
           gml.setAddLinks(getAddLinks());
           return gml.apply(revision).value();
-        } catch (IOException e) {
-          throw new RestApiException("Cannot get merge list", e);
+        } catch (Exception e) {
+          throw asRestApiException("Cannot get merge list", e);
         }
       }
     };
@@ -544,8 +563,8 @@
     in.description = description;
     try {
       putDescription.apply(revision, in);
-    } catch (UpdateException e) {
-      throw new RestApiException("Cannot set description", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set description", e);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java
index 5c56321..60dc1d2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.api.changes;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.RevisionReviewerApi;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -21,8 +23,6 @@
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.VoteResource;
 import com.google.gerrit.server.change.Votes;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.Map;
@@ -48,8 +48,8 @@
   public Map<String, Short> votes() throws RestApiException {
     try {
       return listVotes.apply(reviewer);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot list votes", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list votes", e);
     }
   }
 
@@ -57,8 +57,8 @@
   public void deleteVote(String label) throws RestApiException {
     try {
       deleteVote.apply(new VoteResource(reviewer, label), null);
-    } catch (UpdateException e) {
-      throw new RestApiException("Cannot delete vote", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete vote", e);
     }
   }
 
@@ -66,8 +66,8 @@
   public void deleteVote(DeleteVoteInput input) throws RestApiException {
     try {
       deleteVote.apply(new VoteResource(reviewer, input.label), input);
-    } catch (UpdateException e) {
-      throw new RestApiException("Cannot delete vote", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete vote", e);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java
index ded98cb..b19939b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java
@@ -14,12 +14,13 @@
 
 package com.google.gerrit.server.api.changes;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.changes.RobotCommentApi;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.change.GetRobotComment;
 import com.google.gerrit.server.change.RobotCommentResource;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -41,8 +42,8 @@
   public RobotCommentInfo get() throws RestApiException {
     try {
       return getComment.apply(comment);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve robot comment", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve robot comment", e);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java
index 9b6ead0..87118c7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java
@@ -14,12 +14,20 @@
 
 package com.google.gerrit.server.api.config;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.common.Version;
+import com.google.gerrit.extensions.api.config.AccessCheckInfo;
+import com.google.gerrit.extensions.api.config.AccessCheckInput;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
 import com.google.gerrit.extensions.api.config.Server;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.common.ServerInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.config.CheckAccess;
+import com.google.gerrit.server.config.CheckConsistency;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.config.GetDiffPreferences;
 import com.google.gerrit.server.config.GetPreferences;
@@ -27,9 +35,8 @@
 import com.google.gerrit.server.config.SetDiffPreferences;
 import com.google.gerrit.server.config.SetPreferences;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class ServerImpl implements Server {
@@ -38,6 +45,8 @@
   private final GetDiffPreferences getDiffPreferences;
   private final SetDiffPreferences setDiffPreferences;
   private final GetServerInfo getServerInfo;
+  private final Provider<CheckConsistency> checkConsistency;
+  private final Provider<CheckAccess> checkAccess;
 
   @Inject
   ServerImpl(
@@ -45,12 +54,16 @@
       SetPreferences setPreferences,
       GetDiffPreferences getDiffPreferences,
       SetDiffPreferences setDiffPreferences,
-      GetServerInfo getServerInfo) {
+      GetServerInfo getServerInfo,
+      Provider<CheckConsistency> checkConsistency,
+      Provider<CheckAccess> checkAccess) {
     this.getPreferences = getPreferences;
     this.setPreferences = setPreferences;
     this.getDiffPreferences = getDiffPreferences;
     this.setDiffPreferences = setDiffPreferences;
     this.getServerInfo = getServerInfo;
+    this.checkConsistency = checkConsistency;
+    this.checkAccess = checkAccess;
   }
 
   @Override
@@ -62,8 +75,8 @@
   public ServerInfo getInfo() throws RestApiException {
     try {
       return getServerInfo.apply(new ConfigResource());
-    } catch (IOException e) {
-      throw new RestApiException("Cannot get server info", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get server info", e);
     }
   }
 
@@ -71,8 +84,8 @@
   public GeneralPreferencesInfo getDefaultPreferences() throws RestApiException {
     try {
       return getPreferences.apply(new ConfigResource());
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot get default general preferences", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get default general preferences", e);
     }
   }
 
@@ -81,8 +94,8 @@
       throws RestApiException {
     try {
       return setPreferences.apply(new ConfigResource(), in);
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot set default general preferences", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set default general preferences", e);
     }
   }
 
@@ -90,8 +103,8 @@
   public DiffPreferencesInfo getDefaultDiffPreferences() throws RestApiException {
     try {
       return getDiffPreferences.apply(new ConfigResource());
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot get default diff preferences", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get default diff preferences", e);
     }
   }
 
@@ -100,8 +113,26 @@
       throws RestApiException {
     try {
       return setDiffPreferences.apply(new ConfigResource(), in);
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot set default diff preferences", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set default diff preferences", e);
+    }
+  }
+
+  @Override
+  public ConsistencyCheckInfo checkConsistency(ConsistencyCheckInput in) throws RestApiException {
+    try {
+      return checkConsistency.get().apply(new ConfigResource(), in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check consistency", e);
+    }
+  }
+
+  @Override
+  public AccessCheckInfo checkAccess(AccessCheckInput in) throws RestApiException {
+    try {
+      return checkAccess.get().apply(new ConfigResource(), in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check access", e);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
index 15120d2..d7f868c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.api.groups;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.api.groups.GroupApi;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -41,10 +43,8 @@
 import com.google.gerrit.server.group.PutName;
 import com.google.gerrit.server.group.PutOptions;
 import com.google.gerrit.server.group.PutOwner;
-import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-import java.io.IOException;
 import java.util.Arrays;
 import java.util.List;
 
@@ -73,7 +73,7 @@
   private final GroupResource rsrc;
   private final Index index;
 
-  @AssistedInject
+  @Inject
   GroupApiImpl(
       GetGroup getGroup,
       GetDetail getDetail,
@@ -119,8 +119,8 @@
   public GroupInfo get() throws RestApiException {
     try {
       return getGroup.apply(rsrc);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve group", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve group", e);
     }
   }
 
@@ -128,8 +128,8 @@
   public GroupInfo detail() throws RestApiException {
     try {
       return getDetail.apply(rsrc);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve group", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve group", e);
     }
   }
 
@@ -146,8 +146,8 @@
       putName.apply(rsrc, in);
     } catch (NoSuchGroupException e) {
       throw new ResourceNotFoundException(name, e);
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot put group name", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot put group name", e);
     }
   }
 
@@ -155,8 +155,8 @@
   public GroupInfo owner() throws RestApiException {
     try {
       return getOwner.apply(rsrc);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot get group owner", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get group owner", e);
     }
   }
 
@@ -166,8 +166,8 @@
     in.owner = owner;
     try {
       putOwner.apply(rsrc, in);
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot put group owner", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot put group owner", e);
     }
   }
 
@@ -182,8 +182,8 @@
     in.description = description;
     try {
       putDescription.apply(rsrc, in);
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot put group description", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot put group description", e);
     }
   }
 
@@ -196,8 +196,8 @@
   public void options(GroupOptionsInfo options) throws RestApiException {
     try {
       putOptions.apply(rsrc, options);
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot put group options", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot put group options", e);
     }
   }
 
@@ -211,8 +211,8 @@
     listMembers.setRecursive(recursive);
     try {
       return listMembers.apply(rsrc);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot list group members", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list group members", e);
     }
   }
 
@@ -220,8 +220,8 @@
   public void addMembers(String... members) throws RestApiException {
     try {
       addMembers.apply(rsrc, AddMembers.Input.fromMembers(Arrays.asList(members)));
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot add group members", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot add group members", e);
     }
   }
 
@@ -229,8 +229,8 @@
   public void removeMembers(String... members) throws RestApiException {
     try {
       deleteMembers.apply(rsrc, AddMembers.Input.fromMembers(Arrays.asList(members)));
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot remove group members", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot remove group members", e);
     }
   }
 
@@ -238,8 +238,8 @@
   public List<GroupInfo> includedGroups() throws RestApiException {
     try {
       return listGroups.apply(rsrc);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot list included groups", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list included groups", e);
     }
   }
 
@@ -247,8 +247,8 @@
   public void addGroups(String... groups) throws RestApiException {
     try {
       addGroups.apply(rsrc, AddIncludedGroups.Input.fromGroups(Arrays.asList(groups)));
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot add group members", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot add group members", e);
     }
   }
 
@@ -256,8 +256,8 @@
   public void removeGroups(String... groups) throws RestApiException {
     try {
       deleteGroups.apply(rsrc, AddIncludedGroups.Input.fromGroups(Arrays.asList(groups)));
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot remove group members", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot remove group members", e);
     }
   }
 
@@ -265,8 +265,8 @@
   public List<? extends GroupAuditEventInfo> auditLog() throws RestApiException {
     try {
       return getAuditLog.apply(rsrc);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot get audit log", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get audit log", e);
     }
   }
 
@@ -274,8 +274,8 @@
   public void index() throws RestApiException {
     try {
       index.apply(rsrc, new Index.Input());
-    } catch (IOException e) {
-      throw new RestApiException("Cannot index group", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot index group", e);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupsImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupsImpl.java
index 1d725a8..5f816bb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupsImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupsImpl.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.api.groups;
 
 import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.gerrit.server.account.CapabilityUtils.checkRequiresCapability;
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.gerrit.extensions.api.groups.GroupApi;
 import com.google.gerrit.extensions.api.groups.GroupInput;
@@ -32,12 +32,12 @@
 import com.google.gerrit.server.group.GroupsCollection;
 import com.google.gerrit.server.group.ListGroups;
 import com.google.gerrit.server.group.QueryGroups;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectsCollection;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import java.util.List;
 import java.util.SortedMap;
 
@@ -49,6 +49,7 @@
   private final Provider<ListGroups> listGroups;
   private final Provider<QueryGroups> queryGroups;
   private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
   private final CreateGroup.Factory createGroup;
   private final GroupApiImpl.Factory api;
 
@@ -60,6 +61,7 @@
       Provider<ListGroups> listGroups,
       Provider<QueryGroups> queryGroups,
       Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
       CreateGroup.Factory createGroup,
       GroupApiImpl.Factory api) {
     this.accounts = accounts;
@@ -68,6 +70,7 @@
     this.listGroups = listGroups;
     this.queryGroups = queryGroups;
     this.user = user;
+    this.permissionBackend = permissionBackend;
     this.createGroup = createGroup;
     this.api = api;
   }
@@ -89,12 +92,13 @@
     if (checkNotNull(in, "GroupInput").name == null) {
       throw new BadRequestException("GroupInput must specify name");
     }
-    checkRequiresCapability(user, null, CreateGroup.class);
     try {
-      GroupInfo info = createGroup.create(in.name).apply(TopLevelResource.INSTANCE, in);
+      CreateGroup impl = createGroup.create(in.name);
+      permissionBackend.user(user).checkAny(GlobalPermission.fromAnnotation(impl.getClass()));
+      GroupInfo info = impl.apply(TopLevelResource.INSTANCE, in);
       return id(info.id);
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot create group " + in.name, e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create group " + in.name, e);
     }
   }
 
@@ -116,8 +120,8 @@
     for (String project : req.getProjects()) {
       try {
         list.addProject(projects.parse(tlr, IdString.fromDecoded(project)).getControl());
-      } catch (IOException e) {
-        throw new RestApiException("Error looking up project " + project, e);
+      } catch (Exception e) {
+        throw asRestApiException("Error looking up project " + project, e);
       }
     }
 
@@ -130,8 +134,8 @@
     if (req.getUser() != null) {
       try {
         list.setUser(accounts.parse(req.getUser()).getAccountId());
-      } catch (OrmException e) {
-        throw new RestApiException("Error looking up user " + req.getUser(), e);
+      } catch (Exception e) {
+        throw asRestApiException("Error looking up user " + req.getUser(), e);
       }
     }
 
@@ -142,8 +146,8 @@
     list.setSuggest(req.getSuggest());
     try {
       return list.apply(tlr);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot list groups", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list groups", e);
     }
   }
 
@@ -172,8 +176,8 @@
         myQueryGroups.addOption(option);
       }
       return myQueryGroups.apply(TopLevelResource.INSTANCE);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot query groups", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot query groups", e);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/BranchApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
index 2fc7833..4a587a8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.api.projects;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
@@ -28,7 +30,6 @@
 import com.google.gerrit.server.project.FilesCollection;
 import com.google.gerrit.server.project.GetContent;
 import com.google.gerrit.server.project.ProjectResource;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -69,8 +70,8 @@
     try {
       createBranchFactory.create(ref).apply(project, input);
       return this;
-    } catch (IOException e) {
-      throw new RestApiException("Cannot create branch", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create branch", e);
     }
   }
 
@@ -78,8 +79,8 @@
   public BranchInfo get() throws RestApiException {
     try {
       return resource().getBranchInfo();
-    } catch (IOException e) {
-      throw new RestApiException("Cannot read branch", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot read branch", e);
     }
   }
 
@@ -87,8 +88,8 @@
   public void delete() throws RestApiException {
     try {
       deleteBranch.apply(resource(), new DeleteBranch.Input());
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot delete branch", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete branch", e);
     }
   }
 
@@ -97,8 +98,8 @@
     try {
       FileResource resource = filesCollection.parse(resource(), IdString.fromDecoded(path));
       return getContent.apply(resource);
-    } catch (IOException e) {
-      throw new RestApiException("Cannot retrieve file", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve file", e);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
index 925b647..1595682 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
@@ -19,8 +19,8 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.project.ChildProjectResource;
 import com.google.gerrit.server.project.GetChildProject;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 
 public class ChildProjectApiImpl implements ChildProjectApi {
   interface Factory {
@@ -30,7 +30,7 @@
   private final GetChildProject getChildProject;
   private final ChildProjectResource rsrc;
 
-  @AssistedInject
+  @Inject
   ChildProjectApiImpl(GetChildProject getChildProject, @Assisted ChildProjectResource rsrc) {
     this.getChildProject = getChildProject;
     this.rsrc = rsrc;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/CommitApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
new file mode 100644
index 0000000..cbdd03d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.projects;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.Changes;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.api.projects.CommitApi;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.change.CherryPickCommit;
+import com.google.gerrit.server.project.CommitResource;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class CommitApiImpl implements CommitApi {
+  public interface Factory {
+    CommitApiImpl create(CommitResource r);
+  }
+
+  private final Changes changes;
+  private final CherryPickCommit cherryPickCommit;
+  private final CommitResource commitResource;
+
+  @Inject
+  CommitApiImpl(
+      Changes changes, CherryPickCommit cherryPickCommit, @Assisted CommitResource commitResource) {
+    this.changes = changes;
+    this.cherryPickCommit = cherryPickCommit;
+    this.commitResource = commitResource;
+  }
+
+  @Override
+  public ChangeApi cherryPick(CherryPickInput input) throws RestApiException {
+    try {
+      return changes.id(cherryPickCommit.apply(commitResource, input)._number);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot cherry pick", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/Module.java
index 975e6c1..a4fe39b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/Module.java
@@ -26,5 +26,6 @@
     factory(TagApiImpl.Factory.class);
     factory(ProjectApiImpl.Factory.class);
     factory(ChildProjectApiImpl.Factory.class);
+    factory(CommitApiImpl.Factory.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index e29d633..104fb94 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -14,13 +14,14 @@
 
 package com.google.gerrit.server.api.projects;
 
-import static com.google.gerrit.server.account.CapabilityUtils.checkRequiresCapability;
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.ChildProjectApi;
+import com.google.gerrit.extensions.api.projects.CommitApi;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
@@ -38,7 +39,10 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ChildProjectsCollection;
+import com.google.gerrit.server.project.CommitsCollection;
 import com.google.gerrit.server.project.CreateProject;
 import com.google.gerrit.server.project.DeleteBranches;
 import com.google.gerrit.server.project.DeleteTags;
@@ -54,12 +58,9 @@
 import com.google.gerrit.server.project.PutConfig;
 import com.google.gerrit.server.project.PutDescription;
 import com.google.gerrit.server.project.SetAccess;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-import java.io.IOException;
 import java.util.List;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 
 public class ProjectApiImpl implements ProjectApi {
   interface Factory {
@@ -69,6 +70,7 @@
   }
 
   private final CurrentUser user;
+  private final PermissionBackend permissionBackend;
   private final CreateProject.Factory createProjectFactory;
   private final ProjectApiImpl.Factory projectApi;
   private final ProjectsCollection projects;
@@ -89,10 +91,13 @@
   private final ListTags listTags;
   private final DeleteBranches deleteBranches;
   private final DeleteTags deleteTags;
+  private final CommitsCollection commitsCollection;
+  private final CommitApiImpl.Factory commitApi;
 
   @AssistedInject
   ProjectApiImpl(
       CurrentUser user,
+      PermissionBackend permissionBackend,
       CreateProject.Factory createProjectFactory,
       ProjectApiImpl.Factory projectApi,
       ProjectsCollection projects,
@@ -111,9 +116,12 @@
       ListTags listTags,
       DeleteBranches deleteBranches,
       DeleteTags deleteTags,
+      CommitsCollection commitsCollection,
+      CommitApiImpl.Factory commitApi,
       @Assisted ProjectResource project) {
     this(
         user,
+        permissionBackend,
         createProjectFactory,
         projectApi,
         projects,
@@ -133,12 +141,15 @@
         deleteBranches,
         deleteTags,
         project,
+        commitsCollection,
+        commitApi,
         null);
   }
 
   @AssistedInject
   ProjectApiImpl(
       CurrentUser user,
+      PermissionBackend permissionBackend,
       CreateProject.Factory createProjectFactory,
       ProjectApiImpl.Factory projectApi,
       ProjectsCollection projects,
@@ -157,9 +168,12 @@
       ListTags listTags,
       DeleteBranches deleteBranches,
       DeleteTags deleteTags,
+      CommitsCollection commitsCollection,
+      CommitApiImpl.Factory commitApi,
       @Assisted String name) {
     this(
         user,
+        permissionBackend,
         createProjectFactory,
         projectApi,
         projects,
@@ -179,11 +193,14 @@
         deleteBranches,
         deleteTags,
         null,
+        commitsCollection,
+        commitApi,
         name);
   }
 
   private ProjectApiImpl(
       CurrentUser user,
+      PermissionBackend permissionBackend,
       CreateProject.Factory createProjectFactory,
       ProjectApiImpl.Factory projectApi,
       ProjectsCollection projects,
@@ -203,8 +220,11 @@
       DeleteBranches deleteBranches,
       DeleteTags deleteTags,
       ProjectResource project,
+      CommitsCollection commitsCollection,
+      CommitApiImpl.Factory commitApi,
       String name) {
     this.user = user;
+    this.permissionBackend = permissionBackend;
     this.createProjectFactory = createProjectFactory;
     this.projectApi = projectApi;
     this.projects = projects;
@@ -225,6 +245,8 @@
     this.listTags = listTags;
     this.deleteBranches = deleteBranches;
     this.deleteTags = deleteTags;
+    this.commitsCollection = commitsCollection;
+    this.commitApi = commitApi;
   }
 
   @Override
@@ -241,11 +263,12 @@
       if (in.name != null && !name.equals(in.name)) {
         throw new BadRequestException("name must match input.name");
       }
-      checkRequiresCapability(user, null, CreateProject.class);
-      createProjectFactory.create(name).apply(TopLevelResource.INSTANCE, in);
+      CreateProject impl = createProjectFactory.create(name);
+      permissionBackend.user(user).checkAny(GlobalPermission.fromAnnotation(impl.getClass()));
+      impl.apply(TopLevelResource.INSTANCE, in);
       return projectApi.create(projects.parse(name));
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot create project: " + e.getMessage(), e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create project: " + e.getMessage(), e);
     }
   }
 
@@ -266,8 +289,8 @@
   public ProjectAccessInfo access() throws RestApiException {
     try {
       return getAccess.apply(checkExists());
-    } catch (IOException e) {
-      throw new RestApiException("Cannot get access rights", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get access rights", e);
     }
   }
 
@@ -275,8 +298,8 @@
   public ProjectAccessInfo access(ProjectAccessInput p) throws RestApiException {
     try {
       return setAccess.apply(checkExists(), p);
-    } catch (IOException e) {
-      throw new RestApiException("Cannot put access rights", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot put access rights", e);
     }
   }
 
@@ -284,8 +307,8 @@
   public void description(DescriptionInput in) throws RestApiException {
     try {
       putDescription.apply(checkExists(), in);
-    } catch (IOException e) {
-      throw new RestApiException("Cannot put project description", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot put project description", e);
     }
   }
 
@@ -317,8 +340,8 @@
     listBranches.setMatchRegex(request.getRegex());
     try {
       return listBranches.apply(checkExists());
-    } catch (IOException e) {
-      throw new RestApiException("Cannot list branches", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list branches", e);
     }
   }
 
@@ -339,8 +362,8 @@
     listTags.setMatchRegex(request.getRegex());
     try {
       return listTags.apply(checkExists());
-    } catch (IOException e) {
-      throw new RestApiException("Cannot list tags", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list tags", e);
     }
   }
 
@@ -353,15 +376,19 @@
   public List<ProjectInfo> children(boolean recursive) throws RestApiException {
     ListChildProjects list = children.list();
     list.setRecursive(recursive);
-    return list.apply(checkExists());
+    try {
+      return list.apply(checkExists());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list children", e);
+    }
   }
 
   @Override
   public ChildProjectApi child(String name) throws RestApiException {
     try {
       return childApi.create(children.parse(checkExists(), IdString.fromDecoded(name)));
-    } catch (IOException e) {
-      throw new RestApiException("Cannot parse child project", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse child project", e);
     }
   }
 
@@ -379,8 +406,8 @@
   public void deleteBranches(DeleteBranchesInput in) throws RestApiException {
     try {
       deleteBranches.apply(checkExists(), in);
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot delete branches", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete branches", e);
     }
   }
 
@@ -388,8 +415,17 @@
   public void deleteTags(DeleteTagsInput in) throws RestApiException {
     try {
       deleteTags.apply(checkExists(), in);
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot delete tags", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete tags", e);
+    }
+  }
+
+  @Override
+  public CommitApi commit(String commit) throws RestApiException {
+    try {
+      return commitApi.create(commitsCollection.parse(checkExists(), IdString.fromDecoded(commit)));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse commit", e);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectsImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
index 9483508..702a7e9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.api.projects;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.api.projects.Projects;
@@ -21,13 +23,13 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ListProjects;
 import com.google.gerrit.server.project.ListProjects.FilterType;
 import com.google.gerrit.server.project.ProjectsCollection;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import java.util.SortedMap;
 
 @Singleton
@@ -52,8 +54,8 @@
       return api.create(projects.parse(name));
     } catch (UnprocessableEntityException e) {
       return api.create(name);
-    } catch (IOException e) {
-      throw new RestApiException("Cannot retrieve project");
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve project", e);
     }
   }
 
@@ -77,12 +79,17 @@
     return new ListRequest() {
       @Override
       public SortedMap<String, ProjectInfo> getAsMap() throws RestApiException {
-        return list(this);
+        try {
+          return list(this);
+        } catch (Exception e) {
+          throw asRestApiException("project list unavailable", e);
+        }
       }
     };
   }
 
-  private SortedMap<String, ProjectInfo> list(ListRequest request) throws RestApiException {
+  private SortedMap<String, ProjectInfo> list(ListRequest request)
+      throws RestApiException, PermissionBackendException {
     ListProjects lp = listProvider.get();
     lp.setShowDescription(request.getDescription());
     lp.setLimit(request.getLimit());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/TagApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/TagApiImpl.java
index 4e81407..283d117 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/TagApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/TagApiImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.api.projects;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.api.projects.TagInput;
@@ -25,7 +27,6 @@
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.TagResource;
 import com.google.gerrit.server.project.TagsCollection;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -63,8 +64,8 @@
     try {
       createTagFactory.create(ref).apply(project, input);
       return this;
-    } catch (IOException e) {
-      throw new RestApiException("Cannot create tag", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create tag", e);
     }
   }
 
@@ -72,8 +73,8 @@
   public TagInfo get() throws RestApiException {
     try {
       return listTags.get(project, IdString.fromDecoded(ref));
-    } catch (IOException e) {
-      throw new RestApiException(e.getMessage());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get tag", e);
     }
   }
 
@@ -81,8 +82,8 @@
   public void delete() throws RestApiException {
     try {
       deleteTag.apply(resource(), new DeleteTag.Input());
-    } catch (OrmException | IOException e) {
-      throw new RestApiException(e.getMessage());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete tag", e);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ProjectControlHandler.java b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ProjectControlHandler.java
index 02e907f..bd0cdcd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ProjectControlHandler.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ProjectControlHandler.java
@@ -15,8 +15,12 @@
 package com.google.gerrit.server.args4j;
 
 import com.google.gerrit.common.ProjectUtil;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.inject.Inject;
@@ -34,18 +38,22 @@
 
 public class ProjectControlHandler extends OptionHandler<ProjectControl> {
   private static final Logger log = LoggerFactory.getLogger(ProjectControlHandler.class);
+
   private final ProjectControl.GenericFactory projectControlFactory;
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> user;
 
   @Inject
   public ProjectControlHandler(
-      final ProjectControl.GenericFactory projectControlFactory,
+      ProjectControl.GenericFactory projectControlFactory,
+      PermissionBackend permissionBackend,
       Provider<CurrentUser> user,
       @Assisted final CmdLineParser parser,
       @Assisted final OptionDef option,
       @Assisted final Setter<ProjectControl> setter) {
     super(parser, option, setter);
     this.projectControlFactory = projectControlFactory;
+    this.permissionBackend = permissionBackend;
     this.user = user;
   }
 
@@ -69,14 +77,15 @@
     String nameWithoutSuffix = ProjectUtil.stripGitSuffix(projectName);
     Project.NameKey nameKey = new Project.NameKey(nameWithoutSuffix);
 
-    final ProjectControl control;
+    ProjectControl control;
     try {
-      control =
-          projectControlFactory.validateFor(
-              nameKey, ProjectControl.OWNER | ProjectControl.VISIBLE, user.get());
+      control = projectControlFactory.controlFor(nameKey, user.get());
+      permissionBackend.user(user).project(nameKey).check(ProjectPermission.ACCESS);
+    } catch (AuthException e) {
+      throw new CmdLineException(owner, new NoSuchProjectException(nameKey).getMessage());
     } catch (NoSuchProjectException e) {
       throw new CmdLineException(owner, e.getMessage());
-    } catch (IOException e) {
+    } catch (PermissionBackendException | IOException e) {
       log.warn("Cannot load project " + nameWithoutSuffix, e);
       throw new CmdLineException(owner, new NoSuchProjectException(nameKey).getMessage());
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
index 7feb745..1a8d916 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.auth.ldap;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
 import static com.google.gerrit.server.account.GroupBackends.GROUP_REF_NAME_COMPARATOR;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
 import static com.google.gerrit.server.auth.ldap.Helper.LDAP_UUID;
 import static com.google.gerrit.server.auth.ldap.LdapModule.GROUP_CACHE;
 import static com.google.gerrit.server.auth.ldap.LdapModule.GROUP_EXIST_CACHE;
@@ -29,9 +29,9 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.auth.ldap.Helper.LdapSchema;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectControl;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
index 66b279f..4685dc0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.auth.ldap;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
 
 import com.google.common.base.Strings;
 import com.google.common.cache.CacheLoader;
@@ -25,17 +25,16 @@
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AbstractRealm;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.EmailExpander;
-import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.account.GroupBackends;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.auth.AuthenticationUnavailableException;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
@@ -318,24 +317,17 @@
   }
 
   static class UserLoader extends CacheLoader<String, Optional<Account.Id>> {
-    private final SchemaFactory<ReviewDb> schema;
+    private final ExternalIds externalIds;
 
     @Inject
-    UserLoader(SchemaFactory<ReviewDb> schema) {
-      this.schema = schema;
+    UserLoader(ExternalIds externalIds) {
+      this.externalIds = externalIds;
     }
 
     @Override
     public Optional<Account.Id> load(String username) throws Exception {
-      try (ReviewDb db = schema.open()) {
-        return Optional.ofNullable(
-                ExternalId.from(
-                    db.accountExternalIds()
-                        .get(
-                            ExternalId.Key.create(SCHEME_GERRIT, username)
-                                .asAccountExternalIdKey())))
-            .map(ExternalId::accountId);
-      }
+      return Optional.ofNullable(externalIds.get(ExternalId.Key.create(SCHEME_GERRIT, username)))
+          .map(ExternalId::accountId);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java
index d30e667..75f4213 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.auth.openid;
 
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 
 public class OpenIdProviderPattern {
   public static OpenIdProviderPattern create(String pattern) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java
index 862f4e8..11f2034 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java
@@ -30,65 +30,60 @@
 @Singleton
 public class CacheMetrics {
   @Inject
-  public CacheMetrics(MetricMaker metrics, final DynamicMap<Cache<?, ?>> cacheMap) {
+  public CacheMetrics(MetricMaker metrics, DynamicMap<Cache<?, ?>> cacheMap) {
     Field<String> F_NAME = Field.ofString("cache_name");
 
-    final CallbackMetric1<String, Long> memEnt =
+    CallbackMetric1<String, Long> memEnt =
         metrics.newCallbackMetric(
             "caches/memory_cached",
             Long.class,
             new Description("Memory entries").setGauge().setUnit("entries"),
             F_NAME);
-    final CallbackMetric1<String, Double> memHit =
+    CallbackMetric1<String, Double> memHit =
         metrics.newCallbackMetric(
             "caches/memory_hit_ratio",
             Double.class,
             new Description("Memory hit ratio").setGauge().setUnit("percent"),
             F_NAME);
-    final CallbackMetric1<String, Long> memEvict =
+    CallbackMetric1<String, Long> memEvict =
         metrics.newCallbackMetric(
             "caches/memory_eviction_count",
             Long.class,
             new Description("Memory eviction count").setGauge().setUnit("evicted entries"),
             F_NAME);
-    final CallbackMetric1<String, Long> perDiskEnt =
+    CallbackMetric1<String, Long> perDiskEnt =
         metrics.newCallbackMetric(
             "caches/disk_cached",
             Long.class,
             new Description("Disk entries used by persistent cache").setGauge().setUnit("entries"),
             F_NAME);
-    final CallbackMetric1<String, Double> perDiskHit =
+    CallbackMetric1<String, Double> perDiskHit =
         metrics.newCallbackMetric(
             "caches/disk_hit_ratio",
             Double.class,
             new Description("Disk hit ratio for persistent cache").setGauge().setUnit("percent"),
             F_NAME);
 
-    final Set<CallbackMetric<?>> cacheMetrics =
+    Set<CallbackMetric<?>> cacheMetrics =
         ImmutableSet.<CallbackMetric<?>>of(memEnt, memHit, memEvict, perDiskEnt, perDiskHit);
 
     metrics.newTrigger(
         cacheMetrics,
-        new Runnable() {
-          @Override
-          public void run() {
-            for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
-              Cache<?, ?> c = e.getProvider().get();
-              String name = metricNameOf(e);
-              CacheStats cstats = c.stats();
-              memEnt.set(name, c.size());
-              memHit.set(name, cstats.hitRate() * 100);
-              memEvict.set(name, cstats.evictionCount());
-              if (c instanceof PersistentCache) {
-                PersistentCache.DiskStats d = ((PersistentCache) c).diskStats();
-                perDiskEnt.set(name, d.size());
-                perDiskHit.set(name, hitRatio(d));
-              }
-            }
-            for (CallbackMetric<?> cbm : cacheMetrics) {
-              cbm.prune();
+        () -> {
+          for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
+            Cache<?, ?> c = e.getProvider().get();
+            String name = metricNameOf(e);
+            CacheStats cstats = c.stats();
+            memEnt.set(name, c.size());
+            memHit.set(name, cstats.hitRate() * 100);
+            memEvict.set(name, cstats.evictionCount());
+            if (c instanceof PersistentCache) {
+              PersistentCache.DiskStats d = ((PersistentCache) c).diskStats();
+              perDiskEnt.set(name, d.size());
+              perDiskHit.set(name, hitRatio(d));
             }
           }
+          cacheMetrics.forEach(CallbackMetric::prune);
         });
   }
 
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 0cafe6d..df22a3a 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
@@ -21,10 +21,8 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
@@ -32,25 +30,24 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.AbandonOp;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.Collection;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 @Singleton
-public class Abandon
-    implements RestModifyView<ChangeResource, AbandonInput>, UiAction<ChangeResource> {
-  private static final Logger log = LoggerFactory.getLogger(Abandon.class);
-
+public class Abandon extends RetryingRestModifyView<ChangeResource, AbandonInput, ChangeInfo>
+    implements UiAction<ChangeResource> {
   private final Provider<ReviewDb> dbProvider;
   private final ChangeJson.Factory json;
-  private final BatchUpdate.Factory batchUpdateFactory;
   private final AbandonOp.Factory abandonOpFactory;
   private final NotifyUtil notifyUtil;
 
@@ -58,39 +55,44 @@
   Abandon(
       Provider<ReviewDb> dbProvider,
       ChangeJson.Factory json,
-      BatchUpdate.Factory batchUpdateFactory,
+      RetryHelper retryHelper,
       AbandonOp.Factory abandonOpFactory,
       NotifyUtil notifyUtil) {
+    super(retryHelper);
     this.dbProvider = dbProvider;
     this.json = json;
-    this.batchUpdateFactory = batchUpdateFactory;
     this.abandonOpFactory = abandonOpFactory;
     this.notifyUtil = notifyUtil;
   }
 
   @Override
-  public ChangeInfo apply(ChangeResource req, AbandonInput input)
-      throws RestApiException, UpdateException, OrmException {
-    ChangeControl control = req.getControl();
-    if (!control.canAbandon(dbProvider.get())) {
-      throw new AuthException("abandon not permitted");
-    }
+  protected ChangeInfo applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource req, AbandonInput input)
+      throws RestApiException, UpdateException, OrmException, PermissionBackendException {
+    req.permissions().database(dbProvider).check(ChangePermission.ABANDON);
+
     Change change =
         abandon(
-            control, input.message, input.notify, notifyUtil.resolveAccounts(input.notifyDetails));
+            updateFactory,
+            req.getControl(),
+            input.message,
+            input.notify,
+            notifyUtil.resolveAccounts(input.notifyDetails));
     return json.noOptions().format(change);
   }
 
-  public Change abandon(ChangeControl control) throws RestApiException, UpdateException {
-    return abandon(control, "", NotifyHandling.ALL, ImmutableListMultimap.of());
+  public Change abandon(BatchUpdate.Factory updateFactory, ChangeControl control)
+      throws RestApiException, UpdateException {
+    return abandon(updateFactory, control, "", NotifyHandling.ALL, ImmutableListMultimap.of());
   }
 
-  public Change abandon(ChangeControl control, String msgTxt)
+  public Change abandon(BatchUpdate.Factory updateFactory, ChangeControl control, String msgTxt)
       throws RestApiException, UpdateException {
-    return abandon(control, msgTxt, NotifyHandling.ALL, ImmutableListMultimap.of());
+    return abandon(updateFactory, control, msgTxt, NotifyHandling.ALL, ImmutableListMultimap.of());
   }
 
   public Change abandon(
+      BatchUpdate.Factory updateFactory,
       ChangeControl control,
       String msgTxt,
       NotifyHandling notifyHandling,
@@ -100,7 +102,7 @@
     Account account = user.isIdentifiedUser() ? user.asIdentifiedUser().getAccount() : null;
     AbandonOp op = abandonOpFactory.create(account, msgTxt, notifyHandling, accountsToNotify);
     try (BatchUpdate u =
-        batchUpdateFactory.create(
+        updateFactory.create(
             dbProvider.get(),
             control.getProject().getNameKey(),
             control.getUser(),
@@ -118,6 +120,7 @@
    * matching project from its ChangeControl. Violations will result in a ResourceConflictException.
    */
   public void batchAbandon(
+      BatchUpdate.Factory updateFactory,
       Project.NameKey project,
       CurrentUser user,
       Collection<ChangeControl> controls,
@@ -129,8 +132,7 @@
       return;
     }
     Account account = user.isIdentifiedUser() ? user.asIdentifiedUser().getAccount() : null;
-    try (BatchUpdate u =
-        batchUpdateFactory.create(dbProvider.get(), project, user, TimeUtil.nowTs())) {
+    try (BatchUpdate u = updateFactory.create(dbProvider.get(), project, user, TimeUtil.nowTs())) {
       for (ChangeControl control : controls) {
         if (!project.equals(control.getProject().getNameKey())) {
           throw new ResourceConflictException(
@@ -147,31 +149,41 @@
   }
 
   public void batchAbandon(
-      Project.NameKey project, CurrentUser user, Collection<ChangeControl> controls, String msgTxt)
+      BatchUpdate.Factory updateFactory,
+      Project.NameKey project,
+      CurrentUser user,
+      Collection<ChangeControl> controls,
+      String msgTxt)
       throws RestApiException, UpdateException {
-    batchAbandon(project, user, controls, msgTxt, NotifyHandling.ALL, ImmutableListMultimap.of());
+    batchAbandon(
+        updateFactory,
+        project,
+        user,
+        controls,
+        msgTxt,
+        NotifyHandling.ALL,
+        ImmutableListMultimap.of());
   }
 
   public void batchAbandon(
-      Project.NameKey project, CurrentUser user, Collection<ChangeControl> controls)
+      BatchUpdate.Factory updateFactory,
+      Project.NameKey project,
+      CurrentUser user,
+      Collection<ChangeControl> controls)
       throws RestApiException, UpdateException {
-    batchAbandon(project, user, controls, "", NotifyHandling.ALL, ImmutableListMultimap.of());
+    batchAbandon(
+        updateFactory, project, user, controls, "", NotifyHandling.ALL, ImmutableListMultimap.of());
   }
 
   @Override
-  public UiAction.Description getDescription(ChangeResource resource) {
-    boolean canAbandon = false;
-    try {
-      canAbandon = resource.getControl().canAbandon(dbProvider.get());
-    } catch (OrmException e) {
-      log.error("Cannot check canAbandon status. Assuming false.", e);
-    }
+  public UiAction.Description getDescription(ChangeResource rsrc) {
+    Change change = rsrc.getChange();
     return new UiAction.Description()
         .setLabel("Abandon")
         .setTitle("Abandon the change")
         .setVisible(
-            resource.getChange().getStatus().isOpen()
-                && resource.getChange().getStatus() != Change.Status.DRAFT
-                && canAbandon);
+            change.getStatus().isOpen()
+                && change.getStatus() != Change.Status.DRAFT
+                && rsrc.permissions().database(dbProvider).testOrFalse(ChangePermission.ABANDON));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/AbandonUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/AbandonUtil.java
index 7c408c8..02ad66e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/AbandonUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/AbandonUtil.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeQueryProcessor;
+import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -58,7 +59,7 @@
     internalUser = internalUserFactory.create();
   }
 
-  public void abandonInactiveOpenChanges() {
+  public void abandonInactiveOpenChanges(BatchUpdate.Factory updateFactory) {
     if (cfg.getAbandonAfter() <= 0) {
       return;
     }
@@ -85,7 +86,7 @@
       for (Project.NameKey project : abandons.keySet()) {
         Collection<ChangeControl> changes = getValidChanges(abandons.get(project), query);
         try {
-          abandon.batchAbandon(project, internalUser, changes, message);
+          abandon.batchAbandon(updateFactory, project, internalUser, changes, message);
           count += changes.size();
         } catch (Throwable e) {
           StringBuilder msg = new StringBuilder("Failed to auto-abandon inactive change(s):");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java
index 519a4bc..19fdcfb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java
@@ -30,14 +30,11 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.webui.PrivateInternals_UiActionDescription;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.extensions.webui.UiActions;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import com.google.inject.util.Providers;
 import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -48,6 +45,7 @@
   private final Revisions revisions;
   private final ChangeJson.Factory changeJsonFactory;
   private final ChangeResource.Factory changeResourceFactory;
+  private final UiActions uiActions;
   private final DynamicMap<RestView<ChangeResource>> changeViews;
   private final DynamicSet<ActionVisitor> visitorSet;
 
@@ -56,11 +54,13 @@
       Revisions revisions,
       ChangeJson.Factory changeJsonFactory,
       ChangeResource.Factory changeResourceFactory,
+      UiActions uiActions,
       DynamicMap<RestView<ChangeResource>> changeViews,
       DynamicSet<ActionVisitor> visitorSet) {
     this.revisions = revisions;
     this.changeJsonFactory = changeJsonFactory;
     this.changeResourceFactory = changeResourceFactory;
+    this.uiActions = uiActions;
     this.changeViews = changeViews;
     this.visitorSet = visitorSet;
   }
@@ -122,6 +122,7 @@
     copy.mergeable = changeInfo.mergeable;
     copy.insertions = changeInfo.insertions;
     copy.deletions = changeInfo.deletions;
+    copy.isPrivate = changeInfo.isPrivate;
     copy.subject = changeInfo.subject;
     copy.status = changeInfo.status;
     copy.owner = changeInfo.owner;
@@ -161,9 +162,9 @@
       return out;
     }
 
-    Provider<CurrentUser> userProvider = Providers.of(ctl.getUser());
     FluentIterable<UiAction.Description> descs =
-        UiActions.from(changeViews, changeResourceFactory.create(ctl), userProvider);
+        uiActions.from(changeViews, changeResourceFactory.create(ctl));
+
     // The followup action is a client-side only operation that does not
     // have a server side handler. It must be manually registered into the
     // resulting action map.
@@ -197,10 +198,10 @@
     if (!rsrc.getControl().getUser().isIdentifiedUser()) {
       return ImmutableMap.of();
     }
+
     Map<String, ActionInfo> out = new LinkedHashMap<>();
-    Provider<CurrentUser> userProvider = Providers.of(rsrc.getControl().getUser());
     ACTION:
-    for (UiAction.Description d : UiActions.from(revisions, rsrc, userProvider)) {
+    for (UiAction.Description d : uiActions.from(revisions, rsrc)) {
       ActionInfo actionInfo = new ActionInfo(d);
       for (ActionVisitor visitor : visitors) {
         if (!visitor.visit(d.getId(), actionInfo, changeInfo, revisionInfo)) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ApplyFix.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ApplyFix.java
new file mode 100644
index 0000000..aee97fc
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ApplyFix.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.edit.ChangeEdit;
+import com.google.gerrit.server.edit.ChangeEditJson;
+import com.google.gerrit.server.edit.ChangeEditModifier;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import com.google.gerrit.server.fixes.FixReplacementInterpreter;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+
+@Singleton
+public class ApplyFix implements RestModifyView<FixResource, Void> {
+
+  private final GitRepositoryManager gitRepositoryManager;
+  private final FixReplacementInterpreter fixReplacementInterpreter;
+  private final ChangeEditModifier changeEditModifier;
+  private final ChangeEditJson changeEditJson;
+
+  @Inject
+  public ApplyFix(
+      GitRepositoryManager gitRepositoryManager,
+      FixReplacementInterpreter fixReplacementInterpreter,
+      ChangeEditModifier changeEditModifier,
+      ChangeEditJson changeEditJson) {
+    this.gitRepositoryManager = gitRepositoryManager;
+    this.fixReplacementInterpreter = fixReplacementInterpreter;
+    this.changeEditModifier = changeEditModifier;
+    this.changeEditJson = changeEditJson;
+  }
+
+  @Override
+  public Response<EditInfo> apply(FixResource fixResource, Void nothing)
+      throws AuthException, OrmException, ResourceConflictException, IOException,
+          ResourceNotFoundException, PermissionBackendException {
+    RevisionResource revisionResource = fixResource.getRevisionResource();
+    Project.NameKey project = revisionResource.getProject();
+    ProjectState projectState = revisionResource.getControl().getProjectControl().getProjectState();
+    PatchSet patchSet = revisionResource.getPatchSet();
+    ObjectId patchSetCommitId = ObjectId.fromString(patchSet.getRevision().get());
+
+    try (Repository repository = gitRepositoryManager.openRepository(project)) {
+      List<TreeModification> treeModifications =
+          fixReplacementInterpreter.toTreeModifications(
+              repository, projectState, patchSetCommitId, fixResource.getFixReplacements());
+      ChangeEdit changeEdit =
+          changeEditModifier.combineWithModifiedPatchSetTree(
+              repository, revisionResource.getControl(), patchSet, treeModifications);
+      return Response.ok(changeEditJson.toEditInfo(changeEdit, false));
+    } catch (InvalidChangeOperationException e) {
+      throw new ResourceConflictException(e.getMessage());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeCleanupRunner.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
index 4299188..eb7d099 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
@@ -17,10 +17,13 @@
 import static com.google.gerrit.server.config.ScheduleConfig.MISSING_CONFIG;
 
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.ChangeCleanupConfig;
 import com.google.gerrit.server.config.ScheduleConfig;
 import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.gwtorm.server.OrmException;
@@ -81,19 +84,29 @@
 
   private final OneOffRequestContext oneOffRequestContext;
   private final AbandonUtil abandonUtil;
+  private final RetryHelper retryHelper;
 
   @Inject
-  ChangeCleanupRunner(OneOffRequestContext oneOffRequestContext, AbandonUtil abandonUtil) {
+  ChangeCleanupRunner(
+      OneOffRequestContext oneOffRequestContext, AbandonUtil abandonUtil, RetryHelper retryHelper) {
     this.oneOffRequestContext = oneOffRequestContext;
     this.abandonUtil = abandonUtil;
+    this.retryHelper = retryHelper;
   }
 
   @Override
   public void run() {
     log.info("Running change cleanups.");
     try (ManualRequestContext ctx = oneOffRequestContext.open()) {
-      abandonUtil.abandonInactiveOpenChanges();
-    } catch (OrmException e) {
+      // abandonInactiveOpenChanges skips failures instead of throwing, so retrying will never
+      // actually happen. For the purposes of this class that is fine: they'll get tried again the
+      // next time the scheduled task is run.
+      retryHelper.execute(
+          updateFactory -> {
+            abandonUtil.abandonInactiveOpenChanges(updateFactory);
+            return null;
+          });
+    } catch (RestApiException | UpdateException | OrmException e) {
       log.error("Failed to cleanup changes.", e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEditResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEditResource.java
index 108e180..1695d0c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEditResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEditResource.java
@@ -16,8 +16,6 @@
 
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.inject.TypeLiteral;
@@ -67,12 +65,4 @@
   public String getPath() {
     return path;
   }
-
-  Account.Id getAccountId() {
-    return getUser().getAccountId();
-  }
-
-  IdentifiedUser getUser() {
-    return edit.getUser();
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java
index fbb2115..929268b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.server.edit.UnchangedCommitMessageException;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gwtorm.server.OrmException;
@@ -154,7 +155,8 @@
 
     @Override
     public Response<?> apply(ChangeResource resource, Put.Input input)
-        throws AuthException, ResourceConflictException, IOException, OrmException {
+        throws AuthException, ResourceConflictException, IOException, OrmException,
+            PermissionBackendException {
       putEdit.apply(resource.getControl(), path, input.content);
       return Response.none();
     }
@@ -178,7 +180,8 @@
 
     @Override
     public Response<?> apply(ChangeResource rsrc, DeleteFile.Input in)
-        throws IOException, AuthException, ResourceConflictException, OrmException {
+        throws IOException, AuthException, ResourceConflictException, OrmException,
+            PermissionBackendException {
       return deleteContent.apply(rsrc.getControl(), path);
     }
   }
@@ -229,7 +232,8 @@
         }
         try {
           editInfo.files =
-              fileInfoJson.toFileInfoMap(rsrc.getChange(), edit.get().getRevision(), basePatchSet);
+              fileInfoJson.toFileInfoMap(
+                  rsrc.getChange(), edit.get().getEditCommit(), basePatchSet);
         } catch (PatchListNotAvailableException e) {
           throw new ResourceNotFoundException(e.getMessage());
         }
@@ -267,7 +271,8 @@
 
     @Override
     public Response<?> apply(ChangeResource resource, Post.Input input)
-        throws AuthException, IOException, ResourceConflictException, OrmException {
+        throws AuthException, IOException, ResourceConflictException, OrmException,
+            PermissionBackendException {
       Project.NameKey project = resource.getProject();
       try (Repository repository = repositoryManager.openRepository(project)) {
         ChangeControl changeControl = resource.getControl();
@@ -313,12 +318,14 @@
 
     @Override
     public Response<?> apply(ChangeEditResource rsrc, Input input)
-        throws AuthException, ResourceConflictException, IOException, OrmException {
+        throws AuthException, ResourceConflictException, IOException, OrmException,
+            PermissionBackendException {
       return apply(rsrc.getControl(), rsrc.getPath(), input.content);
     }
 
     public Response<?> apply(ChangeControl changeControl, String path, RawInput newContent)
-        throws ResourceConflictException, AuthException, IOException, OrmException {
+        throws ResourceConflictException, AuthException, IOException, OrmException,
+            PermissionBackendException {
       if (Strings.isNullOrEmpty(path) || path.charAt(0) == '/') {
         throw new ResourceConflictException("Invalid path: " + path);
       }
@@ -355,12 +362,14 @@
 
     @Override
     public Response<?> apply(ChangeEditResource rsrc, DeleteContent.Input input)
-        throws AuthException, ResourceConflictException, OrmException, IOException {
+        throws AuthException, ResourceConflictException, OrmException, IOException,
+            PermissionBackendException {
       return apply(rsrc.getControl(), rsrc.getPath());
     }
 
     public Response<?> apply(ChangeControl changeControl, String filePath)
-        throws AuthException, IOException, OrmException, ResourceConflictException {
+        throws AuthException, IOException, OrmException, ResourceConflictException,
+            PermissionBackendException {
       Project.NameKey project = changeControl.getChange().getProject();
       try (Repository repository = repositoryManager.openRepository(project)) {
         editModifier.deleteFile(repository, changeControl, filePath);
@@ -395,9 +404,10 @@
                 rsrc.getControl().getProjectControl().getProjectState(),
                 base
                     ? ObjectId.fromString(edit.getBasePatchSet().getRevision().get())
-                    : ObjectId.fromString(edit.getRevision().get()),
-                rsrc.getPath()));
-      } catch (ResourceNotFoundException rnfe) {
+                    : edit.getEditCommit(),
+                rsrc.getPath(),
+                null));
+      } catch (ResourceNotFoundException | BadRequestException e) {
         return Response.none();
       }
     }
@@ -454,7 +464,7 @@
     @Override
     public Object apply(ChangeResource rsrc, Input input)
         throws AuthException, IOException, BadRequestException, ResourceConflictException,
-            OrmException {
+            OrmException, PermissionBackendException {
       if (input == null || Strings.isNullOrEmpty(input.message)) {
         throw new BadRequestException("commit message must be provided");
       }
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 da34064..5391635 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
@@ -75,6 +75,7 @@
 import java.util.concurrent.Future;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.eclipse.jgit.util.ChangeIdUtil;
 import org.slf4j.Logger;
@@ -82,7 +83,7 @@
 
 public class ChangeInserter implements InsertChangeOp {
   public interface Factory {
-    ChangeInserter create(Change.Id cid, RevCommit rc, String refName);
+    ChangeInserter create(Change.Id cid, ObjectId commitId, String refName);
   }
 
   private static final Logger log = LoggerFactory.getLogger(ChangeInserter.class);
@@ -102,7 +103,7 @@
 
   private final Change.Id changeId;
   private final PatchSet.Id psId;
-  private final RevCommit commit;
+  private final ObjectId commitId;
   private final String refName;
 
   // Fields exposed as setters.
@@ -110,20 +111,22 @@
   private String topic;
   private String message;
   private String patchSetDescription;
+  private boolean isPrivate;
+  private boolean workInProgress;
   private List<String> groups = Collections.emptyList();
-  private CommitValidators.Policy validatePolicy = CommitValidators.Policy.GERRIT;
+  private boolean validate = true;
   private NotifyHandling notify = NotifyHandling.ALL;
   private ListMultimap<RecipientType, Account.Id> accountsToNotify = ImmutableListMultimap.of();
   private Set<Account.Id> reviewers;
   private Set<Account.Id> extraCC;
   private Map<String, Short> approvals;
   private RequestScopePropagator requestScopePropagator;
-  private ReceiveCommand updateRefCommand;
   private boolean fireRevisionCreated;
   private boolean sendMail;
   private boolean updateRef;
 
   // Fields set during the insertion process.
+  private ReceiveCommand cmd;
   private Change change;
   private ChangeMessage changeMessage;
   private PatchSetInfo patchSetInfo;
@@ -145,7 +148,7 @@
       CommentAdded commentAdded,
       RevisionCreated revisionCreated,
       @Assisted Change.Id changeId,
-      @Assisted RevCommit commit,
+      @Assisted ObjectId commitId,
       @Assisted String refName) {
     this.projectControlFactory = projectControlFactory;
     this.userFactory = userFactory;
@@ -162,54 +165,57 @@
 
     this.changeId = changeId;
     this.psId = new PatchSet.Id(changeId, INITIAL_PATCH_SET_ID);
-    this.commit = commit;
+    this.commitId = commitId.copy();
     this.refName = refName;
     this.reviewers = Collections.emptySet();
     this.extraCC = Collections.emptySet();
     this.approvals = Collections.emptyMap();
-    this.updateRefCommand = null;
     this.fireRevisionCreated = true;
     this.sendMail = true;
     this.updateRef = true;
   }
 
   @Override
-  public Change createChange(Context ctx) {
+  public Change createChange(Context ctx) throws IOException {
     change =
         new Change(
-            getChangeKey(commit),
+            getChangeKey(ctx.getRevWalk(), commitId),
             changeId,
             ctx.getAccountId(),
             new Branch.NameKey(ctx.getProject(), refName),
             ctx.getWhen());
     change.setStatus(MoreObjects.firstNonNull(status, Change.Status.NEW));
     change.setTopic(topic);
+    change.setPrivate(isPrivate);
+    change.setWorkInProgress(workInProgress);
     return change;
   }
 
-  private static Change.Key getChangeKey(RevCommit commit) {
+  private static Change.Key getChangeKey(RevWalk rw, ObjectId id) throws IOException {
+    RevCommit commit = rw.parseCommit(id);
+    rw.parseBody(commit);
     List<String> idList = commit.getFooterLines(FooterConstants.CHANGE_ID);
     if (!idList.isEmpty()) {
       return new Change.Key(idList.get(idList.size() - 1).trim());
     }
-    ObjectId id =
+    ObjectId changeId =
         ChangeIdUtil.computeChangeId(
             commit.getTree(),
             commit,
             commit.getAuthorIdent(),
             commit.getCommitterIdent(),
             commit.getShortMessage());
-    StringBuilder changeId = new StringBuilder();
-    changeId.append("I").append(ObjectId.toString(id));
-    return new Change.Key(changeId.toString());
+    StringBuilder changeIdStr = new StringBuilder();
+    changeIdStr.append("I").append(ObjectId.toString(changeId));
+    return new Change.Key(changeIdStr.toString());
   }
 
   public PatchSet.Id getPatchSetId() {
     return psId;
   }
 
-  public RevCommit getCommit() {
-    return commit;
+  public ObjectId getCommitId() {
+    return commitId;
   }
 
   public Change getChange() {
@@ -233,8 +239,8 @@
     return this;
   }
 
-  public ChangeInserter setValidatePolicy(CommitValidators.Policy validate) {
-    this.validatePolicy = checkNotNull(validate);
+  public ChangeInserter setValidate(boolean validate) {
+    this.validate = validate;
     return this;
   }
 
@@ -259,11 +265,22 @@
     return this;
   }
 
+  public ChangeInserter setPrivate(boolean isPrivate) {
+    checkState(change == null, "setPrivate(boolean) only valid before creating change");
+    this.isPrivate = isPrivate;
+    return this;
+  }
+
   public ChangeInserter setDraft(boolean draft) {
     checkState(change == null, "setDraft(boolean) only valid before creating change");
     return setStatus(draft ? Change.Status.DRAFT : Change.Status.NEW);
   }
 
+  public ChangeInserter setWorkInProgress(boolean workInProgress) {
+    this.workInProgress = workInProgress;
+    return this;
+  }
+
   public ChangeInserter setStatus(Change.Status status) {
     checkState(change == null, "setStatus(Change.Status) only valid before creating change");
     this.status = status;
@@ -292,10 +309,6 @@
     return this;
   }
 
-  public void setUpdateRefCommand(ReceiveCommand cmd) {
-    updateRefCommand = cmd;
-  }
-
   public void setPushCertificate(String cert) {
     pushCert = cert;
   }
@@ -310,6 +323,18 @@
     return this;
   }
 
+  /**
+   * Set whether to include the new patch set ref update in this update.
+   *
+   * <p>If false, the caller is responsible for creating the patch set ref <strong>before</strong>
+   * executing the containing {@code BatchUpdate}.
+   *
+   * <p>Should not be used in new code, as it doesn't result in a single atomic batch ref update for
+   * code and NoteDb meta refs.
+   *
+   * @param updateRef whether to update the ref during {@code updateRepo}.
+   */
+  @Deprecated
   public ChangeInserter setUpdateRef(boolean updateRef) {
     this.updateRef = updateRef;
     return this;
@@ -323,17 +348,18 @@
     return changeMessage;
   }
 
+  public ReceiveCommand getCommand() {
+    return cmd;
+  }
+
   @Override
   public void updateRepo(RepoContext ctx) throws ResourceConflictException, IOException {
+    cmd = new ReceiveCommand(ObjectId.zeroId(), commitId, psId.toRefName());
     validate(ctx);
     if (!updateRef) {
       return;
     }
-    if (updateRefCommand == null) {
-      ctx.addRefUpdate(new ReceiveCommand(ObjectId.zeroId(), commit, psId.toRefName()));
-    } else {
-      ctx.addRefUpdate(updateRefCommand);
-    }
+    ctx.addRefUpdate(cmd);
   }
 
   @Override
@@ -342,7 +368,8 @@
     change = ctx.getChange(); // Use defensive copy created by ChangeControl.
     ReviewDb db = ctx.getDb();
     ChangeControl ctl = ctx.getControl();
-    patchSetInfo = patchSetInfoFactory.get(ctx.getRevWalk(), commit, psId);
+    patchSetInfo =
+        patchSetInfoFactory.get(ctx.getRevWalk(), ctx.getRevWalk().parseCommit(commitId), psId);
     ctx.getChange().setCurrentPatchSet(patchSetInfo);
 
     ChangeUpdate update = ctx.getUpdate(psId);
@@ -351,11 +378,13 @@
     update.setBranch(change.getDest().get());
     update.setTopic(change.getTopic());
     update.setPsDescription(patchSetDescription);
+    update.setPrivate(isPrivate);
+    update.setWorkInProgress(workInProgress);
 
     boolean draft = status == Change.Status.DRAFT;
     List<String> newGroups = groups;
     if (newGroups.isEmpty()) {
-      newGroups = GroupCollector.getDefaultGroups(commit);
+      newGroups = GroupCollector.getDefaultGroups(commitId);
     }
     patchSet =
         psUtil.insert(
@@ -363,7 +392,7 @@
             ctx.getRevWalk(),
             update,
             psId,
-            commit,
+            commitId,
             draft,
             newGroups,
             pushCert,
@@ -404,7 +433,7 @@
               ctx.getUser(),
               patchSet.getCreatedOn(),
               message,
-              ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
+              ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
       cmUtil.addChangeMessage(db, update, changeMessage);
     }
     return true;
@@ -497,24 +526,25 @@
   }
 
   private void validate(RepoContext ctx) throws IOException, ResourceConflictException {
-    if (validatePolicy == CommitValidators.Policy.NONE) {
+    if (!validate) {
       return;
     }
 
     try {
       RefControl refControl =
           projectControlFactory.controlFor(ctx.getProject(), ctx.getUser()).controlForRef(refName);
-      String refName = psId.toRefName();
-      CommitReceivedEvent event =
+      try (CommitReceivedEvent event =
           new CommitReceivedEvent(
-              new ReceiveCommand(ObjectId.zeroId(), commit.getId(), refName),
+              cmd,
               refControl.getProjectControl().getProject(),
               change.getDest().get(),
-              commit,
-              ctx.getIdentifiedUser());
-      commitValidatorsFactory
-          .create(validatePolicy, refControl, new NoSshInfo(), ctx.getRepository())
-          .validate(event);
+              ctx.getRevWalk().getObjectReader(),
+              commitId,
+              ctx.getIdentifiedUser())) {
+        commitValidatorsFactory
+            .forGerritCommits(refControl, new NoSshInfo(), ctx.getRevWalk())
+            .validate(event);
+      }
     } catch (CommitValidationException e) {
       throw new ResourceConflictException(e.getFullMessage());
     } catch (NoSuchProjectException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
index 41d101b..d690984 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
@@ -59,8 +59,6 @@
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.LabelValue;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.extensions.api.changes.FixInput;
@@ -104,25 +102,28 @@
 import com.google.gerrit.server.api.accounts.AccountInfoComparator;
 import com.google.gerrit.server.api.accounts.GpgApiAdapter;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.LabelNormalizer;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.permissions.LabelPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.QueryResult;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeData.ChangedLines;
+import com.google.gerrit.server.query.change.PluginDefinedAttributesFactory;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
@@ -186,9 +187,9 @@
   }
 
   private final Provider<ReviewDb> db;
-  private final LabelNormalizer labelNormalizer;
   private final Provider<CurrentUser> userProvider;
   private final AnonymousUser anonymous;
+  private final PermissionBackend permissionBackend;
   private final GitRepositoryManager repoManager;
   private final ProjectCache projectCache;
   private final MergeUtil.Factory mergeUtilFactory;
@@ -213,13 +214,14 @@
   private boolean lazyLoad = true;
   private AccountLoader accountLoader;
   private FixInput fix;
+  private PluginDefinedAttributesFactory pluginDefinedAttributesFactory;
 
-  @AssistedInject
+  @Inject
   ChangeJson(
       Provider<ReviewDb> db,
-      LabelNormalizer ln,
       Provider<CurrentUser> user,
       AnonymousUser au,
+      PermissionBackend permissionBackend,
       GitRepositoryManager repoManager,
       ProjectCache projectCache,
       MergeUtil.Factory mergeUtilFactory,
@@ -241,10 +243,10 @@
       ApprovalsUtil approvalsUtil,
       @Assisted Iterable<ListChangesOption> options) {
     this.db = db;
-    this.labelNormalizer = ln;
     this.userProvider = user;
     this.anonymous = au;
     this.changeDataFactory = cdf;
+    this.permissionBackend = permissionBackend;
     this.repoManager = repoManager;
     this.userFactory = uf;
     this.projectCache = projectCache;
@@ -276,6 +278,10 @@
     return this;
   }
 
+  public void setPluginDefinedAttributesFactory(PluginDefinedAttributesFactory pluginsFactory) {
+    this.pluginDefinedAttributesFactory = pluginsFactory;
+  }
+
   public ChangeInfo format(ChangeResource rsrc) throws OrmException {
     return format(changeDataFactory.create(db.get(), rsrc.getControl()));
   }
@@ -316,6 +322,7 @@
         | GpgException
         | OrmException
         | IOException
+        | PermissionBackendException
         | RuntimeException e) {
       if (!has(CHECK)) {
         Throwables.throwIfInstanceOf(e, OrmException.class);
@@ -393,6 +400,7 @@
             | GpgException
             | OrmException
             | IOException
+            | PermissionBackendException
             | RuntimeException e) {
           if (has(CHECK)) {
             i = checkOnly(cd);
@@ -439,6 +447,8 @@
       info.updated = c.getLastUpdatedOn();
       info._number = c.getId().get();
       info.problems = result.problems();
+      info.isPrivate = c.isPrivate() ? true : null;
+      info.workInProgress = c.isWorkInProgress() ? true : null;
       finish(info);
     } else {
       info = new ChangeInfo();
@@ -449,7 +459,8 @@
   }
 
   private ChangeInfo toChangeInfo(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
-      throws PatchListNotAvailableException, GpgException, OrmException, IOException {
+      throws PatchListNotAvailableException, GpgException, OrmException, IOException,
+          PermissionBackendException {
     ChangeInfo out = new ChangeInfo();
     CurrentUser user = userProvider.get();
     ChangeControl ctl = cd.changeControl().forUser(user);
@@ -465,6 +476,7 @@
       }
     }
 
+    PermissionBackend.ForChange perm = permissionBackend.user(user).database(db).change(cd);
     Change in = cd.change();
     out.project = in.getProject().get();
     out.branch = in.getDest().getShortName();
@@ -491,6 +503,8 @@
       out.insertions = changedLines.get().insertions;
       out.deletions = changedLines.get().deletions;
     }
+    out.isPrivate = in.isPrivate() ? true : null;
+    out.workInProgress = in.isWorkInProgress() ? true : null;
     out.subject = in.getSubject();
     out.status = in.getStatus().asChangeStatus();
     out.owner = accountLoader.get(in.getOwner());
@@ -502,6 +516,10 @@
     if (user.isIdentifiedUser()) {
       Collection<String> stars = cd.stars(user.getAccountId());
       out.starred = stars.contains(StarredChangesUtil.DEFAULT_LABEL) ? true : null;
+      out.muted =
+          stars.contains(StarredChangesUtil.MUTE_LABEL + "/" + cd.currentPatchSet().getPatchSetId())
+              ? true
+              : null;
       if (!stars.isEmpty()) {
         out.stars = stars;
       }
@@ -509,26 +527,39 @@
 
     if (in.getStatus().isOpen() && has(REVIEWED) && user.isIdentifiedUser()) {
       Account.Id accountId = user.getAccountId();
-      out.reviewed = cd.reviewedBy().contains(accountId) ? true : null;
+      if (out.muted != null) {
+        out.reviewed = true;
+      } else {
+        out.reviewed = cd.reviewedBy().contains(accountId) ? true : null;
+      }
     }
 
-    out.labels = labelsFor(ctl, cd, has(LABELS), has(DETAILED_LABELS));
+    out.labels = labelsFor(perm, ctl, cd, has(LABELS), has(DETAILED_LABELS));
     out.submitted = getSubmittedOn(cd);
+    out.plugins =
+        pluginDefinedAttributesFactory != null ? pluginDefinedAttributesFactory.create(cd) : null;
 
     if (out.labels != null && has(DETAILED_LABELS)) {
       // If limited to specific patch sets but not the current patch set, don't
       // list permitted labels, since users can't vote on those patch sets.
-      if (!limitToPsId.isPresent() || limitToPsId.get().equals(in.currentPatchSetId())) {
+      if (user.isIdentifiedUser()
+          && (!limitToPsId.isPresent() || limitToPsId.get().equals(in.currentPatchSetId()))) {
         out.permittedLabels =
             cd.change().getStatus() != Change.Status.ABANDONED
-                ? permittedLabels(ctl, cd)
+                ? permittedLabels(perm, cd)
                 : ImmutableMap.of();
       }
 
       out.reviewers = new HashMap<>();
-      for (Map.Entry<ReviewerStateInternal, Map<Account.Id, Timestamp>> e :
-          cd.reviewers().asTable().rowMap().entrySet()) {
-        out.reviewers.put(e.getKey().asReviewerState(), toAccountInfo(e.getValue().keySet()));
+      for (ReviewerStateInternal state : ReviewerStateInternal.values()) {
+        if (state == ReviewerStateInternal.REMOVED) {
+          continue;
+        }
+        Collection<AccountInfo> reviewers = toAccountInfo(cd.reviewers().byState(state));
+        reviewers.addAll(toAccountInfoByEmail(cd.reviewersByEmail().byState(state)));
+        if (!reviewers.isEmpty()) {
+          out.reviewers.put(state.asReviewerState(), reviewers);
+        }
       }
 
       out.removableReviewers = removableReviewers(ctl, out);
@@ -595,7 +626,12 @@
   }
 
   private Map<String, LabelInfo> labelsFor(
-      ChangeControl ctl, ChangeData cd, boolean standard, boolean detailed) throws OrmException {
+      PermissionBackend.ForChange perm,
+      ChangeControl ctl,
+      ChangeData cd,
+      boolean standard,
+      boolean detailed)
+      throws OrmException, PermissionBackendException {
     if (!standard && !detailed) {
       return null;
     }
@@ -604,20 +640,24 @@
       return null;
     }
 
-    LabelTypes labelTypes = ctl.getLabelTypes();
+    LabelTypes labelTypes = cd.getLabelTypes();
     Map<String, LabelWithStatus> withStatus =
         cd.change().getStatus().isOpen()
-            ? labelsForOpenChange(ctl, cd, labelTypes, standard, detailed)
-            : labelsForClosedChange(ctl, cd, labelTypes, standard, detailed);
+            ? labelsForOpenChange(perm, cd, labelTypes, standard, detailed)
+            : labelsForClosedChange(perm, cd, labelTypes, standard, detailed);
     return ImmutableMap.copyOf(Maps.transformValues(withStatus, LabelWithStatus::label));
   }
 
   private Map<String, LabelWithStatus> labelsForOpenChange(
-      ChangeControl ctl, ChangeData cd, LabelTypes labelTypes, boolean standard, boolean detailed)
-      throws OrmException {
+      PermissionBackend.ForChange perm,
+      ChangeData cd,
+      LabelTypes labelTypes,
+      boolean standard,
+      boolean detailed)
+      throws OrmException, PermissionBackendException {
     Map<String, LabelWithStatus> labels = initLabels(cd, labelTypes, standard);
     if (detailed) {
-      setAllApprovals(ctl, cd, labels);
+      setAllApprovals(perm, cd, labels);
     }
     for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
       LabelType type = labelTypes.byLabel(e.getKey());
@@ -704,8 +744,8 @@
   }
 
   private void setAllApprovals(
-      ChangeControl baseCtrl, ChangeData cd, Map<String, LabelWithStatus> labels)
-      throws OrmException {
+      PermissionBackend.ForChange basePerm, ChangeData cd, Map<String, LabelWithStatus> labels)
+      throws OrmException, PermissionBackendException {
     Change.Status status = cd.change().getStatus();
     checkState(status.isOpen(), "should not call setAllApprovals on %s change", status);
 
@@ -719,17 +759,17 @@
     }
 
     Table<Account.Id, String, PatchSetApproval> current =
-        HashBasedTable.create(allUsers.size(), baseCtrl.getLabelTypes().getLabelTypes().size());
+        HashBasedTable.create(allUsers.size(), cd.getLabelTypes().getLabelTypes().size());
     for (PatchSetApproval psa : cd.currentApprovals()) {
       current.put(psa.getAccountId(), psa.getLabel(), psa);
     }
 
+    LabelTypes labelTypes = cd.getLabelTypes();
     for (Account.Id accountId : allUsers) {
-      IdentifiedUser user = userFactory.create(accountId);
-      ChangeControl ctl = baseCtrl.forUser(user);
-      Map<String, VotingRangeInfo> pvr = getPermittedVotingRanges(permittedLabels(ctl, cd));
+      PermissionBackend.ForChange perm = basePerm.user(userFactory.create(accountId));
+      Map<String, VotingRangeInfo> pvr = getPermittedVotingRanges(permittedLabels(perm, cd));
       for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
-        LabelType lt = ctl.getLabelTypes().byLabel(e.getKey());
+        LabelType lt = labelTypes.byLabel(e.getKey());
         if (lt == null) {
           // Ignore submit record for undefined label; likely the submit rule
           // author didn't intend for the label to show up in the table.
@@ -746,7 +786,7 @@
             // This may be a dummy approval that was inserted when the reviewer
             // was added. Explicitly check whether the user can vote on this
             // label.
-            value = labelNormalizer.canVote(ctl, lt, accountId) ? 0 : null;
+            value = perm.test(new LabelPermission(lt)) ? 0 : null;
           }
           tag = psa.getTag();
           date = psa.getGranted();
@@ -757,7 +797,7 @@
           // Either the user cannot vote on this label, or they were added as a
           // reviewer but have not responded yet. Explicitly check whether the
           // user can vote on this label.
-          value = labelNormalizer.canVote(ctl, lt, accountId) ? 0 : null;
+          value = perm.test(new LabelPermission(lt)) ? 0 : null;
         }
         addApproval(
             e.getValue().label(), approvalInfo(accountId, value, permittedVotingRange, tag, date));
@@ -805,12 +845,12 @@
   }
 
   private Map<String, LabelWithStatus> labelsForClosedChange(
-      ChangeControl baseCtrl,
+      PermissionBackend.ForChange basePerm,
       ChangeData cd,
       LabelTypes labelTypes,
       boolean standard,
       boolean detailed)
-      throws OrmException {
+      throws OrmException, PermissionBackendException {
     Set<Account.Id> allUsers = new HashSet<>();
     if (detailed) {
       // Users expect to see all reviewers on closed changes, even if they
@@ -878,8 +918,8 @@
       Map<String, ApprovalInfo> byLabel = Maps.newHashMapWithExpectedSize(labels.size());
       Map<String, VotingRangeInfo> pvr = Collections.emptyMap();
       if (detailed) {
-        ChangeControl ctl = baseCtrl.forUser(userFactory.create(accountId));
-        pvr = getPermittedVotingRanges(permittedLabels(ctl, cd));
+        PermissionBackend.ForChange perm = basePerm.user(userFactory.create(accountId));
+        pvr = getPermittedVotingRanges(permittedLabels(perm, cd));
         for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) {
           ApprovalInfo ai = approvalInfo(accountId, 0, null, null, null);
           byLabel.put(entry.getKey(), ai);
@@ -953,15 +993,25 @@
     }
   }
 
-  private Map<String, Collection<String>> permittedLabels(ChangeControl ctl, ChangeData cd)
-      throws OrmException {
-    if (ctl == null || !ctl.getUser().isIdentifiedUser()) {
-      return null;
+  private Map<String, Collection<String>> permittedLabels(
+      PermissionBackend.ForChange perm, ChangeData cd)
+      throws OrmException, PermissionBackendException {
+    boolean isMerged = cd.change().getStatus() == Change.Status.MERGED;
+    LabelTypes labelTypes = cd.getLabelTypes();
+    Map<String, LabelType> toCheck = new HashMap<>();
+    for (SubmitRecord rec : submitRecords(cd)) {
+      if (rec.labels != null) {
+        for (SubmitRecord.Label r : rec.labels) {
+          LabelType type = labelTypes.byLabel(r.label);
+          if (type != null && (!isMerged || type.allowPostSubmit())) {
+            toCheck.put(type.getName(), type);
+          }
+        }
+      }
     }
 
     Map<String, Short> labels = null;
-    boolean isMerged = ctl.getChange().getStatus() == Change.Status.MERGED;
-    LabelTypes labelTypes = ctl.getLabelTypes();
+    Set<LabelPermission.WithValue> can = perm.testLabels(toCheck.values());
     SetMultimap<String, String> permitted = LinkedHashMultimap.create();
     for (SubmitRecord rec : submitRecords(cd)) {
       if (rec.labels == null) {
@@ -972,12 +1022,12 @@
         if (type == null || (isMerged && !type.allowPostSubmit())) {
           continue;
         }
-        PermissionRange range = ctl.getRange(Permission.forLabel(r.label));
+
         for (LabelValue v : type.getValues()) {
-          boolean ok = range.contains(v.getValue());
+          boolean ok = can.contains(new LabelPermission.WithValue(type, v));
           if (isMerged) {
             if (labels == null) {
-              labels = currentLabels(ctl);
+              labels = currentLabels(perm, cd);
             }
             short prev = labels.getOrDefault(type.getName(), (short) 0);
             ok &= v.getValue() >= prev;
@@ -988,6 +1038,7 @@
         }
       }
     }
+
     List<String> toClear = Lists.newArrayListWithCapacity(permitted.keySet().size());
     for (Map.Entry<String, Collection<String>> e : permitted.asMap().entrySet()) {
       if (isOnlyZero(e.getValue())) {
@@ -1000,11 +1051,14 @@
     return permitted.asMap();
   }
 
-  private Map<String, Short> currentLabels(ChangeControl ctl) throws OrmException {
+  private Map<String, Short> currentLabels(PermissionBackend.ForChange perm, ChangeData cd)
+      throws OrmException {
+    IdentifiedUser user = perm.user().asIdentifiedUser();
+    ChangeControl ctl = cd.changeControl().forUser(user);
     Map<String, Short> result = new HashMap<>();
     for (PatchSetApproval psa :
         approvalsUtil.byPatchSetUser(
-            db.get(), ctl, ctl.getChange().currentPatchSetId(), ctl.getUser().getAccountId())) {
+            db.get(), ctl, cd.change().currentPatchSetId(), user.getAccountId())) {
       result.put(psa.getLabel(), psa.getValue());
     }
     return result;
@@ -1029,6 +1083,10 @@
         cmi.message = message.getMessage();
         cmi.tag = message.getTag();
         cmi._revisionNumber = patchNum != null ? patchNum.get() : null;
+        Account.Id realAuthor = message.getRealAuthor();
+        if (realAuthor != null) {
+          cmi.realAuthor = accountLoader.get(realAuthor);
+        }
         result.add(cmi);
       }
     }
@@ -1070,9 +1128,11 @@
     Collection<AccountInfo> ccs = out.reviewers.get(ReviewerState.CC);
     if (ccs != null) {
       for (AccountInfo ai : ccs) {
-        Account.Id id = new Account.Id(ai._accountId);
-        if (ctl.canRemoveReviewer(id, 0)) {
-          removable.add(id);
+        if (ai._accountId != null) {
+          Account.Id id = new Account.Id(ai._accountId);
+          if (ctl.canRemoveReviewer(id, 0)) {
+            removable.add(id);
+          }
         }
       }
     }
@@ -1086,6 +1146,14 @@
     for (Account.Id id : removable) {
       result.add(accountLoader.get(id));
     }
+    // Reviewers added by email are always removable
+    for (Collection<AccountInfo> infos : out.reviewers.values()) {
+      for (AccountInfo info : infos) {
+        if (info._accountId == null) {
+          result.add(info);
+        }
+      }
+    }
     return result;
   }
 
@@ -1097,6 +1165,14 @@
         .collect(toList());
   }
 
+  private Collection<AccountInfo> toAccountInfoByEmail(Collection<Address> addresses) {
+    return addresses
+        .stream()
+        .map(a -> new AccountInfo(a.getName(), a.getEmail()))
+        .sorted(AccountInfoComparator.ORDER_NULLS_FIRST)
+        .collect(toList());
+  }
+
   @Nullable
   private Repository openRepoIfNecessary(ChangeControl ctl) throws IOException {
     if (has(ALL_COMMITS) || has(CURRENT_COMMIT) || has(COMMIT_FOOTERS)) {
@@ -1105,15 +1181,21 @@
     return null;
   }
 
+  @Nullable
+  private RevWalk newRevWalk(@Nullable Repository repo) {
+    return repo != null ? new RevWalk(repo) : null;
+  }
+
   private Map<String, RevisionInfo> revisions(
       ChangeControl ctl, ChangeData cd, Map<PatchSet.Id, PatchSet> map, ChangeInfo changeInfo)
       throws PatchListNotAvailableException, GpgException, OrmException, IOException {
     Map<String, RevisionInfo> res = new LinkedHashMap<>();
-    try (Repository repo = openRepoIfNecessary(ctl)) {
+    try (Repository repo = openRepoIfNecessary(ctl);
+        RevWalk rw = newRevWalk(repo)) {
       for (PatchSet in : map.values()) {
         if ((has(ALL_REVISIONS) || in.getId().equals(ctl.getChange().currentPatchSetId()))
             && ctl.isPatchVisible(in, db.get())) {
-          res.put(in.getRevision().get(), toRevisionInfo(ctl, cd, in, repo, false, changeInfo));
+          res.put(in.getRevision().get(), toRevisionInfo(ctl, cd, in, repo, rw, false, changeInfo));
         }
       }
       return res;
@@ -1150,9 +1232,10 @@
   public RevisionInfo getRevisionInfo(ChangeControl ctl, PatchSet in)
       throws PatchListNotAvailableException, GpgException, OrmException, IOException {
     accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
-    try (Repository repo = openRepoIfNecessary(ctl)) {
+    try (Repository repo = openRepoIfNecessary(ctl);
+        RevWalk rw = newRevWalk(repo)) {
       RevisionInfo rev =
-          toRevisionInfo(ctl, changeDataFactory.create(db.get(), ctl), in, repo, true, null);
+          toRevisionInfo(ctl, changeDataFactory.create(db.get(), ctl), in, repo, rw, true, null);
       accountLoader.fill();
       return rev;
     }
@@ -1163,6 +1246,7 @@
       ChangeData cd,
       PatchSet in,
       @Nullable Repository repo,
+      @Nullable RevWalk rw,
       boolean fillCommit,
       @Nullable ChangeInfo changeInfo)
       throws PatchListNotAvailableException, GpgException, OrmException, IOException {
@@ -1175,32 +1259,32 @@
     out.uploader = accountLoader.get(in.getUploader());
     out.draft = in.isDraft() ? true : null;
     out.fetch = makeFetchMap(ctl, in);
-    out.kind = changeKindCache.getChangeKind(repo, cd, in);
+    out.kind = changeKindCache.getChangeKind(rw, repo != null ? repo.getConfig() : null, cd, in);
     out.description = in.getDescription();
 
     boolean setCommit = has(ALL_COMMITS) || (out.isCurrent && has(CURRENT_COMMIT));
     boolean addFooters = out.isCurrent && has(COMMIT_FOOTERS);
     if (setCommit || addFooters) {
+      checkState(rw != null);
+      checkState(repo != null);
       Project.NameKey project = c.getProject();
-      try (RevWalk rw = new RevWalk(repo)) {
-        String rev = in.getRevision().get();
-        RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
-        rw.parseBody(commit);
-        if (setCommit) {
-          out.commit = toCommit(ctl, rw, commit, has(WEB_LINKS), fillCommit);
+      String rev = in.getRevision().get();
+      RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
+      rw.parseBody(commit);
+      if (setCommit) {
+        out.commit = toCommit(ctl, rw, commit, has(WEB_LINKS), fillCommit);
+      }
+      if (addFooters) {
+        Ref ref = repo.exactRef(ctl.getChange().getDest().get());
+        RevCommit mergeTip = null;
+        if (ref != null) {
+          mergeTip = rw.parseCommit(ref.getObjectId());
+          rw.parseBody(mergeTip);
         }
-        if (addFooters) {
-          Ref ref = repo.exactRef(ctl.getChange().getDest().get());
-          RevCommit mergeTip = null;
-          if (ref != null) {
-            mergeTip = rw.parseCommit(ref.getObjectId());
-            rw.parseBody(mergeTip);
-          }
-          out.commitWithFooters =
-              mergeUtilFactory
-                  .create(projectCache.get(project))
-                  .createCommitMessageOnSubmit(commit, mergeTip, ctl, in.getId());
-        }
+        out.commitWithFooters =
+            mergeUtilFactory
+                .create(projectCache.get(project))
+                .createCommitMessageOnSubmit(commit, mergeTip, ctl, in.getId());
       }
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java
index aa47827..6baeefc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java
@@ -21,8 +21,9 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.query.change.ChangeData;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
  * Cache of {@link ChangeKind} per commit.
@@ -32,9 +33,14 @@
  */
 public interface ChangeKindCache {
   ChangeKind getChangeKind(
-      Project.NameKey project, @Nullable Repository repo, ObjectId prior, ObjectId next);
+      Project.NameKey project,
+      @Nullable RevWalk rw,
+      @Nullable Config repoConfig,
+      ObjectId prior,
+      ObjectId next);
 
   ChangeKind getChangeKind(ReviewDb db, Change change, PatchSet patch);
 
-  ChangeKind getChangeKind(@Nullable Repository repo, ChangeData cd, PatchSet patch);
+  ChangeKind getChangeKind(
+      @Nullable RevWalk rw, @Nullable Config repoConfig, ChangeData cd, PatchSet patch);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
index 030ddd2..7a6c209 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static org.eclipse.jgit.lib.ObjectIdSerialization.readNotNull;
 import static org.eclipse.jgit.lib.ObjectIdSerialization.writeNotNull;
@@ -94,10 +95,14 @@
 
     @Override
     public ChangeKind getChangeKind(
-        Project.NameKey project, @Nullable Repository repo, ObjectId prior, ObjectId next) {
+        Project.NameKey project,
+        @Nullable RevWalk rw,
+        @Nullable Config repoConfig,
+        ObjectId prior,
+        ObjectId next) {
       try {
         Key key = new Key(prior, next, useRecursiveMerge);
-        return new Loader(key, repoManager, project, repo).call();
+        return new Loader(key, repoManager, project, rw, repoConfig).call();
       } catch (IOException e) {
         log.warn(
             "Cannot check trivial rebase of new patch set " + next.name() + " in " + project, e);
@@ -111,8 +116,9 @@
     }
 
     @Override
-    public ChangeKind getChangeKind(@Nullable Repository repo, ChangeData cd, PatchSet patch) {
-      return getChangeKindInternal(this, repo, cd, patch);
+    public ChangeKind getChangeKind(
+        @Nullable RevWalk rw, @Nullable Config repoConfig, ChangeData cd, PatchSet patch) {
+      return getChangeKindInternal(this, rw, repoConfig, cd, patch);
     }
   }
 
@@ -182,36 +188,47 @@
     private final Key key;
     private final GitRepositoryManager repoManager;
     private final Project.NameKey projectName;
-    private final Repository alreadyOpenRepo;
+    private final RevWalk alreadyOpenRw;
+    private final Config repoConfig;
 
     private Loader(
         Key key,
         GitRepositoryManager repoManager,
         Project.NameKey projectName,
-        @Nullable Repository alreadyOpenRepo) {
+        @Nullable RevWalk rw,
+        @Nullable Config repoConfig) {
+      checkArgument(
+          (rw == null && repoConfig == null) || (rw != null && repoConfig != null),
+          "must either provide both revwalk/config, or neither; got %s/%s",
+          rw,
+          repoConfig);
       this.key = key;
       this.repoManager = repoManager;
       this.projectName = projectName;
-      this.alreadyOpenRepo = alreadyOpenRepo;
+      this.alreadyOpenRw = rw;
+      this.repoConfig = repoConfig;
     }
 
+    @SuppressWarnings("resource") // Resources are manually managed.
     @Override
     public ChangeKind call() throws IOException {
       if (Objects.equals(key.prior, key.next)) {
         return ChangeKind.NO_CODE_CHANGE;
       }
 
-      Repository repo = alreadyOpenRepo;
-      boolean close = false;
-      if (repo == null) {
+      RevWalk rw = alreadyOpenRw;
+      Config config = repoConfig;
+      Repository repo = null;
+      if (alreadyOpenRw == null) {
         repo = repoManager.openRepository(projectName);
-        close = true;
+        rw = new RevWalk(repo);
+        config = repo.getConfig();
       }
-      try (RevWalk walk = new RevWalk(repo)) {
-        RevCommit prior = walk.parseCommit(key.prior);
-        walk.parseBody(prior);
-        RevCommit next = walk.parseCommit(key.next);
-        walk.parseBody(next);
+      try {
+        RevCommit prior = rw.parseCommit(key.prior);
+        rw.parseBody(prior);
+        RevCommit next = rw.parseCommit(key.next);
+        rw.parseBody(next);
 
         if (!next.getFullMessage().equals(prior.getFullMessage())) {
           if (isSameDeltaAndTree(prior, next)) {
@@ -233,8 +250,8 @@
         // A trivial rebase can be detected by looking for the next commit
         // having the same tree as would exist when the prior commit is
         // cherry-picked onto the next commit's new first parent.
-        try (ObjectInserter ins = new InMemoryInserter(repo)) {
-          ThreeWayMerger merger = MergeUtil.newThreeWayMerger(repo, ins, key.strategyName);
+        try (ObjectInserter ins = new InMemoryInserter(rw.getObjectReader())) {
+          ThreeWayMerger merger = MergeUtil.newThreeWayMerger(ins, config, key.strategyName);
           merger.setBase(prior.getParent(0));
           if (merger.merge(next.getParent(0), prior)
               && merger.getResultTreeId().equals(next.getTree())) {
@@ -249,7 +266,8 @@
         }
         return ChangeKind.REWORK;
       } finally {
-        if (close) {
+        if (repo != null) {
+          rw.close();
           repo.close();
         }
       }
@@ -327,10 +345,14 @@
 
   @Override
   public ChangeKind getChangeKind(
-      Project.NameKey project, @Nullable Repository repo, ObjectId prior, ObjectId next) {
+      Project.NameKey project,
+      @Nullable RevWalk rw,
+      @Nullable Config repoConfig,
+      ObjectId prior,
+      ObjectId next) {
     try {
       Key key = new Key(prior, next, useRecursiveMerge);
-      return cache.get(key, new Loader(key, repoManager, project, repo));
+      return cache.get(key, new Loader(key, repoManager, project, rw, repoConfig));
     } catch (ExecutionException e) {
       log.warn("Cannot check trivial rebase of new patch set " + next.name() + " in " + project, e);
       return ChangeKind.REWORK;
@@ -343,12 +365,17 @@
   }
 
   @Override
-  public ChangeKind getChangeKind(@Nullable Repository repo, ChangeData cd, PatchSet patch) {
-    return getChangeKindInternal(this, repo, cd, patch);
+  public ChangeKind getChangeKind(
+      @Nullable RevWalk rw, @Nullable Config repoConfig, ChangeData cd, PatchSet patch) {
+    return getChangeKindInternal(this, rw, repoConfig, cd, patch);
   }
 
   private static ChangeKind getChangeKindInternal(
-      ChangeKindCache cache, @Nullable Repository repo, ChangeData change, PatchSet patch) {
+      ChangeKindCache cache,
+      @Nullable RevWalk rw,
+      @Nullable Config repoConfig,
+      ChangeData change,
+      PatchSet patch) {
     ChangeKind kind = ChangeKind.REWORK;
     // Trivial case: if we're on the first patch, we don't need to use
     // the repository.
@@ -372,7 +399,8 @@
           kind =
               cache.getChangeKind(
                   change.project(),
-                  repo,
+                  rw,
+                  repoConfig,
                   ObjectId.fromString(priorPs.getRevision().get()),
                   ObjectId.fromString(patch.getRevision().get()));
         }
@@ -401,8 +429,11 @@
     // Trivial case: if we're on the first patch, we don't need to open
     // the repository.
     if (patch.getId().get() > 1) {
-      try (Repository repo = repoManager.openRepository(change.getProject())) {
-        kind = getChangeKindInternal(cache, repo, changeDataFactory.create(db, change), patch);
+      try (Repository repo = repoManager.openRepository(change.getProject());
+          RevWalk rw = new RevWalk(repo)) {
+        kind =
+            getChangeKindInternal(
+                cache, rw, repo.getConfig(), changeDataFactory.create(db, change), patch);
       } catch (IOException e) {
         // Do nothing; assume we have a complex change
         log.warn(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeMessages.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeMessages.java
index 92b4150..41b6855 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeMessages.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeMessages.java
@@ -23,6 +23,10 @@
   }
 
   public String revertChangeDefaultMessage;
+
+  public String reviewerCantSeeChange;
+  public String reviewerInactive;
+  public String reviewerInvalid;
   public String reviewerNotFoundUser;
   public String reviewerNotFoundUserOrGroup;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java
index b06f05f..1bec3d1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestResource.HasETag;
 import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
@@ -29,12 +30,13 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.TypeLiteral;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import org.eclipse.jgit.lib.ObjectId;
 
 public class ChangeResource implements RestResource, HasETag {
@@ -53,15 +55,24 @@
     ChangeResource create(ChangeControl ctl);
   }
 
+  private final PermissionBackend permissionBackend;
   private final StarredChangesUtil starredChangesUtil;
   private final ChangeControl control;
 
-  @AssistedInject
-  ChangeResource(StarredChangesUtil starredChangesUtil, @Assisted ChangeControl control) {
+  @Inject
+  ChangeResource(
+      PermissionBackend permissionBackend,
+      StarredChangesUtil starredChangesUtil,
+      @Assisted ChangeControl control) {
+    this.permissionBackend = permissionBackend;
     this.starredChangesUtil = starredChangesUtil;
     this.control = control;
   }
 
+  public PermissionBackend.ForChange permissions() {
+    return permissionBackend.user(getControl().getUser()).change(getNotes());
+  }
+
   public ChangeControl getControl() {
     return control;
   }
@@ -74,6 +85,13 @@
     return getControl().getId();
   }
 
+  /** @return true if {@link #getUser()} is the change's owner. */
+  public boolean isUserOwner() {
+    CurrentUser user = getControl().getUser();
+    Account.Id owner = getChange().getOwner();
+    return user.isIdentifiedUser() && user.asIdentifiedUser().getAccountId().equals(owner);
+  }
+
   public Change getChange() {
     return getControl().getChange();
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Check.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Check.java
index 3b67930..a36a1d3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Check.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Check.java
@@ -17,21 +17,28 @@
 import com.google.gerrit.extensions.api.changes.FixInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 
 public class Check
     implements RestReadView<ChangeResource>, RestModifyView<ChangeResource, FixInput> {
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
   private final ChangeJson.Factory jsonFactory;
 
   @Inject
-  Check(ChangeJson.Factory json) {
+  Check(PermissionBackend permissionBackend, Provider<CurrentUser> user, ChangeJson.Factory json) {
+    this.permissionBackend = permissionBackend;
+    this.user = user;
     this.jsonFactory = json;
   }
 
@@ -42,12 +49,9 @@
 
   @Override
   public Response<ChangeInfo> apply(ChangeResource rsrc, FixInput input)
-      throws RestApiException, OrmException {
-    ChangeControl ctl = rsrc.getControl();
-    if (!ctl.isOwner()
-        && !ctl.getProjectControl().isOwner()
-        && !ctl.getUser().getCapabilities().canMaintainServer()) {
-      throw new AuthException("Cannot fix change");
+      throws RestApiException, OrmException, PermissionBackendException {
+    if (!rsrc.isUserOwner() && !rsrc.getControl().getProjectControl().isOwner()) {
+      permissionBackend.user(user).check(GlobalPermission.MAINTAIN_SERVER);
     }
     return Response.withMustRevalidate(newChangeJson().fix(input).format(rsrc));
   }
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 e5a4d0f..35aa4ad 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
@@ -21,7 +21,6 @@
 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.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -32,6 +31,9 @@
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.RefControl;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -41,24 +43,30 @@
 
 @Singleton
 public class CherryPick
-    implements RestModifyView<RevisionResource, CherryPickInput>, UiAction<RevisionResource> {
+    extends RetryingRestModifyView<RevisionResource, CherryPickInput, ChangeInfo>
+    implements UiAction<RevisionResource> {
   private final Provider<ReviewDb> dbProvider;
   private final CherryPickChange cherryPickChange;
   private final ChangeJson.Factory json;
 
   @Inject
   CherryPick(
-      Provider<ReviewDb> dbProvider, CherryPickChange cherryPickChange, ChangeJson.Factory json) {
+      RetryHelper retryHelper,
+      Provider<ReviewDb> dbProvider,
+      CherryPickChange cherryPickChange,
+      ChangeJson.Factory json) {
+    super(retryHelper);
     this.dbProvider = dbProvider;
     this.cherryPickChange = cherryPickChange;
     this.json = json;
   }
 
   @Override
-  public ChangeInfo apply(RevisionResource revision, CherryPickInput input)
+  protected ChangeInfo applyImpl(
+      BatchUpdate.Factory updateFactory, RevisionResource revision, CherryPickInput input)
       throws OrmException, IOException, UpdateException, RestApiException {
     final ChangeControl control = revision.getControl();
-    int parent = input.parent == null ? 1 : input.parent;
+    input.parent = input.parent == null ? 1 : input.parent;
 
     if (input.message == null || input.message.trim().isEmpty()) {
       throw new BadRequestException("message must be non-empty");
@@ -66,9 +74,7 @@
       throw new BadRequestException("destination must be non-empty");
     }
 
-    @SuppressWarnings("resource")
-    ReviewDb db = dbProvider.get();
-    if (!control.isVisible(db)) {
+    if (!control.isVisible(dbProvider.get())) {
       throw new AuthException("Cherry pick not permitted");
     }
 
@@ -91,12 +97,12 @@
     try {
       Change.Id cherryPickedChangeId =
           cherryPickChange.cherryPick(
+              updateFactory,
               revision.getChange(),
               revision.getPatchSet(),
-              input.message,
+              input,
               refName,
-              refControl,
-              parent);
+              refControl);
       return json.noOptions().format(revision.getProject(), cherryPickedChangeId);
     } catch (InvalidChangeOperationException e) {
       throw new BadRequestException(e.getMessage());
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 b2455f1..7c0a7be 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
@@ -16,8 +16,10 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -39,10 +41,8 @@
 import com.google.gerrit.server.git.IntegrationException;
 import com.google.gerrit.server.git.MergeIdenticalTreeException;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.RefControl;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -59,9 +59,6 @@
 import java.sql.Timestamp;
 import java.util.List;
 import java.util.TimeZone;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -84,7 +81,7 @@
   private final MergeUtil.Factory mergeUtilFactory;
   private final ChangeMessagesUtil changeMessagesUtil;
   private final PatchSetUtil psUtil;
-  private final BatchUpdate.Factory batchUpdateFactory;
+  private final NotifyUtil notifyUtil;
 
   @Inject
   CherryPickChange(
@@ -99,7 +96,7 @@
       MergeUtil.Factory mergeUtilFactory,
       ChangeMessagesUtil changeMessagesUtil,
       PatchSetUtil psUtil,
-      BatchUpdate.Factory batchUpdateFactory) {
+      NotifyUtil notifyUtil) {
     this.db = db;
     this.seq = seq;
     this.queryProvider = queryProvider;
@@ -111,27 +108,51 @@
     this.mergeUtilFactory = mergeUtilFactory;
     this.changeMessagesUtil = changeMessagesUtil;
     this.psUtil = psUtil;
-    this.batchUpdateFactory = batchUpdateFactory;
+    this.notifyUtil = notifyUtil;
   }
 
   public Change.Id cherryPick(
+      BatchUpdate.Factory batchUpdateFactory,
       Change change,
       PatchSet patch,
-      final String message,
-      final String ref,
-      final RefControl refControl,
-      int parent)
-      throws NoSuchChangeException, OrmException, MissingObjectException,
-          IncorrectObjectTypeException, IOException, InvalidChangeOperationException,
-          IntegrationException, UpdateException, RestApiException {
+      CherryPickInput input,
+      String ref,
+      RefControl refControl)
+      throws OrmException, IOException, InvalidChangeOperationException, IntegrationException,
+          UpdateException, RestApiException {
+    return cherryPick(
+        batchUpdateFactory,
+        change.getId(),
+        patch.getId(),
+        change.getDest(),
+        change.getTopic(),
+        change.getProject(),
+        ObjectId.fromString(patch.getRevision().get()),
+        input,
+        ref,
+        refControl);
+  }
 
-    if (Strings.isNullOrEmpty(ref)) {
+  public Change.Id cherryPick(
+      BatchUpdate.Factory batchUpdateFactory,
+      @Nullable Change.Id sourceChangeId,
+      @Nullable PatchSet.Id sourcePatchId,
+      @Nullable Branch.NameKey sourceBranch,
+      @Nullable String sourceChangeTopic,
+      Project.NameKey project,
+      ObjectId sourceCommit,
+      CherryPickInput input,
+      String targetRef,
+      RefControl targetRefControl)
+      throws OrmException, IOException, InvalidChangeOperationException, IntegrationException,
+          UpdateException, RestApiException {
+
+    if (Strings.isNullOrEmpty(targetRef)) {
       throw new InvalidChangeOperationException(
           "Cherry Pick: Destination branch cannot be null or empty");
     }
 
-    Project.NameKey project = change.getProject();
-    String destinationBranch = RefNames.shortName(ref);
+    String destinationBranch = RefNames.shortName(targetRef);
     IdentifiedUser identifiedUser = user.get();
     try (Repository git = gitManager.openRepository(project);
         // This inserter and revwalk *must* be passed to any BatchUpdates
@@ -140,7 +161,7 @@
         ObjectInserter oi = git.newObjectInserter();
         ObjectReader reader = oi.newReader();
         CodeReviewRevWalk revWalk = CodeReviewCommit.newRevWalk(reader)) {
-      Ref destRef = git.getRefDatabase().exactRef(ref);
+      Ref destRef = git.getRefDatabase().exactRef(targetRef);
       if (destRef == null) {
         throw new InvalidChangeOperationException(
             String.format("Branch %s does not exist.", destinationBranch));
@@ -148,15 +169,14 @@
 
       CodeReviewCommit mergeTip = revWalk.parseCommit(destRef.getObjectId());
 
-      CodeReviewCommit commitToCherryPick =
-          revWalk.parseCommit(ObjectId.fromString(patch.getRevision().get()));
+      CodeReviewCommit commitToCherryPick = revWalk.parseCommit(sourceCommit);
 
-      if (parent <= 0 || parent > commitToCherryPick.getParentCount()) {
+      if (input.parent <= 0 || input.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()));
+                input.parent, commitToCherryPick.getParentCount()));
       }
 
       Timestamp now = TimeUtil.nowTs();
@@ -168,24 +188,24 @@
               mergeTip,
               commitToCherryPick.getAuthorIdent(),
               committerIdent,
-              message);
-      String commitMessage = ChangeIdUtil.insertId(message, computedChangeId).trim() + '\n';
+              input.message);
+      String commitMessage = ChangeIdUtil.insertId(input.message, computedChangeId).trim() + '\n';
 
       CodeReviewCommit cherryPickCommit;
       try {
-        ProjectState projectState = refControl.getProjectControl().getProjectState();
+        ProjectState projectState = targetRefControl.getProjectControl().getProjectState();
         cherryPickCommit =
             mergeUtilFactory
                 .create(projectState)
                 .createCherryPickFromCommit(
-                    git,
                     oi,
+                    git.getConfig(),
                     mergeTip,
                     commitToCherryPick,
                     committerIdent,
                     commitMessage,
                     revWalk,
-                    parent - 1,
+                    input.parent - 1,
                     false);
 
         Change.Key changeKey;
@@ -197,7 +217,7 @@
           changeKey = new Change.Key("I" + computedChangeId.name());
         }
 
-        Branch.NameKey newDest = new Branch.NameKey(change.getProject(), destRef.getName());
+        Branch.NameKey newDest = new Branch.NameKey(project, destRef.getName());
         List<ChangeData> destChanges =
             queryProvider.get().setLimit(2).byBranchKey(newDest, changeKey);
         if (destChanges.size() > 1) {
@@ -207,32 +227,38 @@
                   + " reside on the same branch. "
                   + "Cannot create a new patch set.");
         }
-        try (BatchUpdate bu =
-            batchUpdateFactory.create(
-                db.get(), change.getDest().getParentKey(), identifiedUser, now)) {
+        try (BatchUpdate bu = batchUpdateFactory.create(db.get(), project, identifiedUser, now)) {
           bu.setRepository(git, revWalk, oi);
           Change.Id result;
           if (destChanges.size() == 1) {
             // The change key exists on the destination branch. The cherry pick
             // will be added as a new patch set.
             ChangeControl destCtl =
-                refControl.getProjectControl().controlFor(destChanges.get(0).notes());
-            result = insertPatchSet(bu, git, destCtl, cherryPickCommit);
+                targetRefControl.getProjectControl().controlFor(destChanges.get(0).notes());
+            result = insertPatchSet(bu, git, destCtl, cherryPickCommit, input);
           } else {
             // Change key not found on destination branch. We can create a new
             // change.
             String newTopic = null;
-            if (!Strings.isNullOrEmpty(change.getTopic())) {
-              newTopic = change.getTopic() + "-" + newDest.getShortName();
+            if (!Strings.isNullOrEmpty(sourceChangeTopic)) {
+              newTopic = sourceChangeTopic + "-" + newDest.getShortName();
             }
             result =
                 createNewChange(
-                    bu, cherryPickCommit, refControl.getRefName(), newTopic, change.getDest());
+                    bu,
+                    cherryPickCommit,
+                    targetRefControl.getRefName(),
+                    newTopic,
+                    sourceBranch,
+                    sourceCommit,
+                    input);
 
-            bu.addOp(
-                change.getId(),
-                new AddMessageToSourceChangeOp(
-                    changeMessagesUtil, patch.getId(), destinationBranch, cherryPickCommit));
+            if (sourceChangeId != null && sourcePatchId != null) {
+              bu.addOp(
+                  sourceChangeId,
+                  new AddMessageToSourceChangeOp(
+                      changeMessagesUtil, sourcePatchId, destinationBranch, cherryPickCommit));
+            }
           }
           bu.execute();
           return result;
@@ -240,26 +266,27 @@
       } catch (MergeIdenticalTreeException | MergeConflictException e) {
         throw new IntegrationException("Cherry pick failed: " + e.getMessage());
       }
-    } catch (RepositoryNotFoundException e) {
-      throw new NoSuchChangeException(change.getId(), e);
     }
   }
 
   private Change.Id insertPatchSet(
-      BatchUpdate bu, Repository git, ChangeControl destCtl, CodeReviewCommit cherryPickCommit)
-      throws IOException, OrmException {
+      BatchUpdate bu,
+      Repository git,
+      ChangeControl destCtl,
+      CodeReviewCommit cherryPickCommit,
+      CherryPickInput input)
+      throws IOException, OrmException, BadRequestException {
     Change destChange = destCtl.getChange();
     PatchSet.Id psId = ChangeUtil.nextPatchSetId(git, destChange.currentPatchSetId());
-    PatchSetInserter inserter = patchSetInserterFactory.create(destCtl, psId, cherryPickCommit);
-    PatchSet.Id newPatchSetId = inserter.getPatchSetId();
     PatchSet current = psUtil.current(db.get(), destCtl.getNotes());
 
-    bu.addOp(
-        destChange.getId(),
-        inserter
-            .setMessage("Uploaded patch set " + newPatchSetId.get() + ".")
-            .setDraft(current.isDraft())
-            .setNotify(NotifyHandling.NONE));
+    PatchSetInserter inserter = patchSetInserterFactory.create(destCtl, psId, cherryPickCommit);
+    inserter
+        .setMessage("Uploaded patch set " + inserter.getPatchSetId().get() + ".")
+        .setDraft(current.isDraft())
+        .setNotify(input.notify)
+        .setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
+    bu.addOp(destChange.getId(), inserter);
     return destChange.getId();
   }
 
@@ -268,16 +295,16 @@
       CodeReviewCommit cherryPickCommit,
       String refName,
       String topic,
-      Branch.NameKey sourceBranch)
-      throws OrmException {
+      Branch.NameKey sourceBranch,
+      ObjectId sourceCommit,
+      CherryPickInput input)
+      throws OrmException, IOException, BadRequestException {
     Change.Id changeId = new Change.Id(seq.nextChangeId());
     ChangeInserter ins =
-        changeInserterFactory
-            .create(changeId, cherryPickCommit, refName)
-            .setValidatePolicy(CommitValidators.Policy.GERRIT)
-            .setTopic(topic);
-
-    ins.setMessage(messageForDestinationChange(ins.getPatchSetId(), sourceBranch));
+        changeInserterFactory.create(changeId, cherryPickCommit, refName).setTopic(topic);
+    ins.setMessage(messageForDestinationChange(ins.getPatchSetId(), sourceBranch, sourceCommit))
+        .setNotify(input.notify)
+        .setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
     bu.insertChange(ins);
     return changeId;
   }
@@ -319,12 +346,16 @@
     }
   }
 
-  private String messageForDestinationChange(PatchSet.Id patchSetId, Branch.NameKey sourceBranch) {
-    return new StringBuilder("Patch Set ")
-        .append(patchSetId.get())
-        .append(": Cherry Picked from branch ")
-        .append(sourceBranch.getShortName())
-        .append(".")
-        .toString();
+  private String messageForDestinationChange(
+      PatchSet.Id patchSetId, Branch.NameKey sourceBranch, ObjectId sourceCommit) {
+    StringBuilder stringBuilder = new StringBuilder("Patch Set ").append(patchSetId.get());
+
+    if (sourceBranch != null) {
+      stringBuilder.append(": Cherry Picked from branch ").append(sourceBranch.getShortName());
+    } else {
+      stringBuilder.append(": Cherry Picked from commit ").append(sourceCommit.getName());
+    }
+
+    return stringBuilder.append(".").toString();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickCommit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickCommit.java
new file mode 100644
index 0000000..b44a8b7
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickCommit.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2017 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.common.base.Strings;
+import com.google.gerrit.common.data.Capable;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+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.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.git.IntegrationException;
+import com.google.gerrit.server.project.CommitResource;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.RefControl;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+@Singleton
+public class CherryPickCommit
+    extends RetryingRestModifyView<CommitResource, CherryPickInput, ChangeInfo> {
+
+  private final CherryPickChange cherryPickChange;
+  private final ChangeJson.Factory json;
+
+  @Inject
+  CherryPickCommit(
+      RetryHelper retryHelper, CherryPickChange cherryPickChange, ChangeJson.Factory json) {
+    super(retryHelper);
+    this.cherryPickChange = cherryPickChange;
+    this.json = json;
+  }
+
+  @Override
+  public ChangeInfo applyImpl(
+      BatchUpdate.Factory updateFactory, CommitResource rsrc, CherryPickInput input)
+      throws OrmException, IOException, UpdateException, RestApiException {
+    RevCommit commit = rsrc.getCommit();
+    String message = Strings.nullToEmpty(input.message).trim();
+    input.message = message.isEmpty() ? commit.getFullMessage() : message;
+    String destination = Strings.nullToEmpty(input.destination).trim();
+    input.parent = input.parent == null ? 1 : input.parent;
+
+    if (destination.isEmpty()) {
+      throw new BadRequestException("destination must be non-empty");
+    }
+
+    ProjectControl projectControl = rsrc.getProject();
+    Capable capable = projectControl.canPushToAtLeastOneRef();
+    if (capable != Capable.OK) {
+      throw new AuthException(capable.getMessage());
+    }
+
+    String refName = RefNames.fullName(destination);
+    RefControl refControl = projectControl.controlForRef(refName);
+    if (!refControl.canUpload()) {
+      throw new AuthException("Not allowed to cherry pick " + commit + " to " + destination);
+    }
+
+    Project.NameKey project = projectControl.getProject().getNameKey();
+    try {
+      Change.Id cherryPickedChangeId =
+          cherryPickChange.cherryPick(
+              updateFactory, null, null, null, null, project, commit, input, refName, refControl);
+      return json.noOptions().format(project, cherryPickedChangeId);
+    } catch (InvalidChangeOperationException e) {
+      throw new BadRequestException(e.getMessage());
+    } catch (IntegrationException e) {
+      throw new ResourceConflictException(e.getMessage());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentResource.java
index 40c8515..f7fc576 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentResource.java
@@ -48,4 +48,8 @@
   Account.Id getAuthorId() {
     return comment.author.getId();
   }
+
+  RevisionResource getRevisionResource() {
+    return rev;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
index 41845e3..e1ccff9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -22,6 +22,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.Collections2;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
@@ -44,7 +45,6 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.PatchSetState;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
@@ -54,6 +54,7 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -77,7 +78,6 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -103,7 +103,6 @@
     public abstract List<ProblemInfo> problems();
   }
 
-  private final BatchUpdate.Factory updateFactory;
   private final ChangeControl.GenericFactory changeControlFactory;
   private final ChangeNotes.Factory notesFactory;
   private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
@@ -114,11 +113,14 @@
   private final Provider<CurrentUser> user;
   private final Provider<PersonIdent> serverIdent;
   private final Provider<ReviewDb> db;
+  private final RetryHelper retryHelper;
 
+  private BatchUpdate.Factory updateFactory;
   private FixInput fix;
   private ChangeControl ctl;
   private Repository repo;
   private RevWalk rw;
+  private ObjectInserter oi;
 
   private RevCommit tip;
   private SetMultimap<ObjectId, PatchSet> patchSetsBySha;
@@ -130,7 +132,6 @@
   @Inject
   ConsistencyChecker(
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
-      BatchUpdate.Factory updateFactory,
       ChangeControl.GenericFactory changeControlFactory,
       ChangeNotes.Factory notesFactory,
       DynamicItem<AccountPatchReviewStore> accountPatchReviewStore,
@@ -139,7 +140,8 @@
       PatchSetInserter.Factory patchSetInserterFactory,
       PatchSetUtil psUtil,
       Provider<CurrentUser> user,
-      Provider<ReviewDb> db) {
+      Provider<ReviewDb> db,
+      RetryHelper retryHelper) {
     this.accountPatchReviewStore = accountPatchReviewStore;
     this.changeControlFactory = changeControlFactory;
     this.db = db;
@@ -148,13 +150,14 @@
     this.patchSetInserterFactory = patchSetInserterFactory;
     this.psUtil = psUtil;
     this.repoManager = repoManager;
+    this.retryHelper = retryHelper;
     this.serverIdent = serverIdent;
-    this.updateFactory = updateFactory;
     this.user = user;
     reset();
   }
 
   private void reset() {
+    updateFactory = null;
     ctl = null;
     repo = null;
     rw = null;
@@ -168,21 +171,38 @@
   public Result check(ChangeControl cc, @Nullable FixInput f) {
     checkNotNull(cc);
     try {
-      reset();
-      ctl = cc;
-      fix = f;
-      checkImpl();
-      return result();
-    } finally {
-      if (rw != null) {
-        rw.close();
-      }
-      if (repo != null) {
-        repo.close();
-      }
+      return retryHelper.execute(
+          buf -> {
+            try {
+              reset();
+              this.updateFactory = buf;
+              ctl = cc;
+              fix = f;
+              checkImpl();
+              return result();
+            } finally {
+              if (rw != null) {
+                rw.getObjectReader().close();
+                rw.close();
+                oi.close();
+              }
+              if (repo != null) {
+                repo.close();
+              }
+            }
+          });
+    } catch (RestApiException e) {
+      return logAndReturnOneProblem(e, cc, "Error checking change: " + e.getMessage());
+    } catch (UpdateException e) {
+      return logAndReturnOneProblem(e, cc, "Error checking change");
     }
   }
 
+  private Result logAndReturnOneProblem(Exception e, ChangeControl cc, String problem) {
+    log.warn("Error checking change " + cc.getId(), e);
+    return Result.create(cc, ImmutableList.of(problem(problem)));
+  }
+
   private void checkImpl() {
     checkOwner();
     checkCurrentPatchSetEntity();
@@ -223,7 +243,8 @@
     Project.NameKey project = change().getDest().getParentKey();
     try {
       repo = repoManager.openRepository(project);
-      rw = new RevWalk(repo);
+      oi = repo.newObjectInserter();
+      rw = new RevWalk(oi.newReader());
       return true;
     } catch (RepositoryNotFoundException e) {
       return error("Destination repository not found: " + project, e);
@@ -490,8 +511,7 @@
               ? psIdToDelete
               : ChangeUtil.nextPatchSetId(repo, change().currentPatchSetId());
       PatchSetInserter inserter = patchSetInserterFactory.create(ctl, psId, commit);
-      try (BatchUpdate bu = newBatchUpdate();
-          ObjectInserter oi = repo.newObjectInserter()) {
+      try (BatchUpdate bu = newBatchUpdate()) {
         bu.setRepository(repo, rw, oi);
 
         if (psIdToDelete != null) {
@@ -502,8 +522,7 @@
               new BatchUpdateOp() {
                 @Override
                 public void updateRepo(RepoContext ctx) throws IOException {
-                  ctx.addRefUpdate(
-                      new ReceiveCommand(commit, ObjectId.zeroId(), psIdToDelete.toRefName()));
+                  ctx.addRefUpdate(commit, ObjectId.zeroId(), psIdToDelete.toRefName());
                 }
               });
           if (!reuseOldPsId) {
@@ -516,7 +535,7 @@
         bu.addOp(
             ctl.getId(),
             inserter
-                .setValidatePolicy(CommitValidators.Policy.NONE)
+                .setValidate(false)
                 .setFireRevisionCreated(false)
                 .setNotify(NotifyHandling.NONE)
                 .setAllowClosed(true)
@@ -555,8 +574,7 @@
   }
 
   private void fixMerged(ProblemInfo p) {
-    try (BatchUpdate bu = newBatchUpdate();
-        ObjectInserter oi = repo.newObjectInserter()) {
+    try (BatchUpdate bu = newBatchUpdate()) {
       bu.setRepository(repo, rw, oi);
       bu.addOp(ctl.getId(), new FixMergedOp(p));
       bu.execute();
@@ -607,8 +625,7 @@
   }
 
   private void deletePatchSets(List<DeletePatchSetFromDbOp> ops) {
-    try (BatchUpdate bu = newBatchUpdate();
-        ObjectInserter oi = repo.newObjectInserter()) {
+    try (BatchUpdate bu = newBatchUpdate()) {
       bu.setRepository(repo, rw, oi);
       for (DeletePatchSetFromDbOp op : ops) {
         checkArgument(op.psId.getParentKey().equals(ctl.getId()));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java
index 656c9d1..599ce5e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java
@@ -33,7 +33,6 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Change;
@@ -53,7 +52,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.ProjectControl;
@@ -61,6 +60,8 @@
 import com.google.gerrit.server.project.ProjectsCollection;
 import com.google.gerrit.server.project.RefControl;
 import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -86,7 +87,8 @@
 import org.eclipse.jgit.util.ChangeIdUtil;
 
 @Singleton
-public class CreateChange implements RestModifyView<TopLevelResource, ChangeInput> {
+public class CreateChange
+    extends RetryingRestModifyView<TopLevelResource, ChangeInput, Response<ChangeInfo>> {
 
   private final String anonymousCowardName;
   private final Provider<ReviewDb> db;
@@ -99,7 +101,6 @@
   private final ChangeInserter.Factory changeInserterFactory;
   private final ChangeJson.Factory jsonFactory;
   private final ChangeFinder changeFinder;
-  private final BatchUpdate.Factory updateFactory;
   private final PatchSetUtil psUtil;
   private final boolean allowDrafts;
   private final MergeUtil.Factory mergeUtilFactory;
@@ -119,11 +120,12 @@
       ChangeInserter.Factory changeInserterFactory,
       ChangeJson.Factory json,
       ChangeFinder changeFinder,
-      BatchUpdate.Factory updateFactory,
+      RetryHelper retryHelper,
       PatchSetUtil psUtil,
       @GerritServerConfig Config config,
       MergeUtil.Factory mergeUtilFactory,
       NotifyUtil notifyUtil) {
+    super(retryHelper);
     this.anonymousCowardName = anonymousCowardName;
     this.db = db;
     this.gitManager = gitManager;
@@ -135,7 +137,6 @@
     this.changeInserterFactory = changeInserterFactory;
     this.jsonFactory = json;
     this.changeFinder = changeFinder;
-    this.updateFactory = updateFactory;
     this.psUtil = psUtil;
     this.allowDrafts = config.getBoolean("change", "allowDrafts", true);
     this.submitType = config.getEnum("project", null, "submitType", SubmitType.MERGE_IF_NECESSARY);
@@ -144,9 +145,10 @@
   }
 
   @Override
-  public Response<ChangeInfo> apply(TopLevelResource parent, ChangeInput input)
+  protected Response<ChangeInfo> applyImpl(
+      BatchUpdate.Factory updateFactory, TopLevelResource parent, ChangeInput input)
       throws OrmException, IOException, InvalidChangeOperationException, RestApiException,
-          UpdateException {
+          UpdateException, PermissionBackendException {
     if (Strings.isNullOrEmpty(input.project)) {
       throw new BadRequestException("project must be non-empty");
     }
@@ -178,7 +180,7 @@
     }
 
     RefControl refControl = rsrc.getControl().controlForRef(refName);
-    if (!refControl.canUpload() || !refControl.canRead()) {
+    if (!refControl.canUpload() || !refControl.isVisible()) {
       throw new AuthException("cannot upload review");
     }
 
@@ -192,11 +194,11 @@
       if (input.baseChange != null) {
         List<ChangeControl> ctls = changeFinder.find(input.baseChange, rsrc.getControl().getUser());
         if (ctls.size() != 1) {
-          throw new InvalidChangeOperationException("Base change not found: " + input.baseChange);
+          throw new UnprocessableEntityException("Base change not found: " + input.baseChange);
         }
         ChangeControl ctl = Iterables.getOnlyElement(ctls);
         if (!ctl.isVisible(db.get())) {
-          throw new InvalidChangeOperationException("Base change not found: " + input.baseChange);
+          throw new UnprocessableEntityException("Base change not found: " + input.baseChange);
         }
         PatchSet ps = psUtil.current(db.get(), ctl.getNotes());
         parentCommit = ObjectId.fromString(ps.getRevision().get());
@@ -252,10 +254,7 @@
       }
 
       Change.Id changeId = new Change.Id(seq.nextChangeId());
-      ChangeInserter ins =
-          changeInserterFactory
-              .create(changeId, c, refName)
-              .setValidatePolicy(CommitValidators.Policy.GERRIT);
+      ChangeInserter ins = changeInserterFactory.create(changeId, c, refName);
       ins.setMessage(String.format("Uploaded patch set %s.", ins.getPatchSetId().get()));
       String topic = input.topic;
       if (topic != null) {
@@ -263,6 +262,8 @@
       }
       ins.setTopic(topic);
       ins.setDraft(input.status == ChangeStatus.DRAFT);
+      ins.setPrivate(input.isPrivate != null && input.isPrivate);
+      ins.setWorkInProgress(input.workInProgress != null && input.workInProgress);
       ins.setGroups(groups);
       ins.setNotify(input.notify);
       ins.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
@@ -324,7 +325,14 @@
             Strings.emptyToNull(merge.strategy), mergeUtil.mergeStrategyName());
 
     return MergeUtil.createMergeCommit(
-        repo, oi, mergeTip, sourceCommit, mergeStrategy, authorIdent, commitMessage, rw);
+        oi,
+        repo.getConfig(),
+        mergeTip,
+        sourceCommit,
+        mergeStrategy,
+        authorIdent,
+        commitMessage,
+        rw);
   }
 
   private static ObjectId insert(ObjectInserter inserter, CommitBuilder commit)
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 5032e57..002c8b7 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
@@ -24,7 +24,6 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Comment;
@@ -37,6 +36,8 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -45,9 +46,9 @@
 import java.util.Collections;
 
 @Singleton
-public class CreateDraftComment implements RestModifyView<RevisionResource, DraftInput> {
+public class CreateDraftComment
+    extends RetryingRestModifyView<RevisionResource, DraftInput, Response<CommentInfo>> {
   private final Provider<ReviewDb> db;
-  private final BatchUpdate.Factory updateFactory;
   private final Provider<CommentJson> commentJson;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
@@ -56,13 +57,13 @@
   @Inject
   CreateDraftComment(
       Provider<ReviewDb> db,
-      BatchUpdate.Factory updateFactory,
+      RetryHelper retryHelper,
       Provider<CommentJson> commentJson,
       CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
       PatchListCache patchListCache) {
+    super(retryHelper);
     this.db = db;
-    this.updateFactory = updateFactory;
     this.commentJson = commentJson;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
@@ -70,7 +71,8 @@
   }
 
   @Override
-  public Response<CommentInfo> apply(RevisionResource rsrc, DraftInput in)
+  protected Response<CommentInfo> applyImpl(
+      BatchUpdate.Factory updateFactory, RevisionResource rsrc, DraftInput in)
       throws RestApiException, UpdateException, OrmException {
     if (Strings.isNullOrEmpty(in.path)) {
       throw new BadRequestException("path must be non-empty");
@@ -122,7 +124,7 @@
 
       commentsUtil.putComments(
           ctx.getDb(), ctx.getUpdate(psId), Status.DRAFT, Collections.singleton(comment));
-      ctx.bumpLastUpdatedOn(false);
+      ctx.dontBumpLastUpdatedOn();
       return true;
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateMergePatchSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateMergePatchSet.java
index 5bd651d..b53bdd9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateMergePatchSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateMergePatchSet.java
@@ -22,13 +22,11 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.MergeInput;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -42,10 +40,14 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeIdenticalTreeException;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -65,7 +67,8 @@
 import org.eclipse.jgit.util.ChangeIdUtil;
 
 @Singleton
-public class CreateMergePatchSet implements RestModifyView<ChangeResource, MergePatchSetInput> {
+public class CreateMergePatchSet
+    extends RetryingRestModifyView<ChangeResource, MergePatchSetInput, Response<ChangeInfo>> {
 
   private final Provider<ReviewDb> db;
   private final GitRepositoryManager gitManager;
@@ -74,7 +77,6 @@
   private final ChangeJson.Factory jsonFactory;
   private final PatchSetUtil psUtil;
   private final MergeUtil.Factory mergeUtilFactory;
-  private final BatchUpdate.Factory batchUpdateFactory;
   private final PatchSetInserter.Factory patchSetInserterFactory;
 
   @Inject
@@ -86,8 +88,9 @@
       ChangeJson.Factory json,
       PatchSetUtil psUtil,
       MergeUtil.Factory mergeUtilFactory,
-      BatchUpdate.Factory batchUpdateFactory,
+      RetryHelper retryHelper,
       PatchSetInserter.Factory patchSetInserterFactory) {
+    super(retryHelper);
     this.db = db;
     this.gitManager = gitManager;
     this.serverTimeZone = myIdent.getTimeZone();
@@ -95,32 +98,23 @@
     this.jsonFactory = json;
     this.psUtil = psUtil;
     this.mergeUtilFactory = mergeUtilFactory;
-    this.batchUpdateFactory = batchUpdateFactory;
     this.patchSetInserterFactory = patchSetInserterFactory;
   }
 
   @Override
-  public Response<ChangeInfo> apply(ChangeResource req, MergePatchSetInput in)
+  protected Response<ChangeInfo> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, MergePatchSetInput in)
       throws OrmException, IOException, InvalidChangeOperationException, RestApiException,
-          UpdateException {
-    if (in.merge == null) {
-      throw new BadRequestException("merge field is required");
-    }
+          UpdateException, PermissionBackendException {
+    rsrc.permissions().database(db).check(ChangePermission.ADD_PATCH_SET);
 
     MergeInput merge = in.merge;
-    if (Strings.isNullOrEmpty(merge.source)) {
+    if (merge == null || Strings.isNullOrEmpty(merge.source)) {
       throw new BadRequestException("merge.source must be non-empty");
     }
 
-    ChangeControl ctl = req.getControl();
-    if (!ctl.isVisible(db.get())) {
-      throw new InvalidChangeOperationException("Base change not found: " + req.getId());
-    }
+    ChangeControl ctl = rsrc.getControl();
     PatchSet ps = psUtil.current(db.get(), ctl.getNotes());
-    if (!ctl.canAddPatchSet(db.get())) {
-      throw new AuthException("cannot add patch set");
-    }
-
     ProjectControl projectControl = ctl.getProjectControl();
     Change change = ctl.getChange();
     Project.NameKey project = change.getProject();
@@ -137,11 +131,9 @@
       }
 
       RevCommit currentPsCommit = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
-
       Timestamp now = TimeUtil.nowTs();
       IdentifiedUser me = user.get().asIdentifiedUser();
       PersonIdent author = me.newCommitterIdent(now, serverTimeZone);
-
       RevCommit newCommit =
           createMergeCommit(
               in,
@@ -157,14 +149,15 @@
 
       PatchSet.Id nextPsId = ChangeUtil.nextPatchSetId(ps.getId());
       PatchSetInserter psInserter = patchSetInserterFactory.create(ctl, nextPsId, newCommit);
-      try (BatchUpdate bu = batchUpdateFactory.create(db.get(), project, me, now)) {
+      try (BatchUpdate bu = updateFactory.create(db.get(), project, me, now)) {
         bu.setRepository(git, rw, oi);
         bu.addOp(
             ctl.getId(),
             psInserter
                 .setMessage("Uploaded patch set " + nextPsId.get() + ".")
                 .setDraft(ps.isDraft())
-                .setNotify(NotifyHandling.NONE));
+                .setNotify(NotifyHandling.NONE)
+                .setCheckAddPatchSetPermission(false));
         bu.execute();
       }
 
@@ -216,6 +209,6 @@
             mergeUtilFactory.create(projectControl.getProjectState()).mergeStrategyName());
 
     return MergeUtil.createMergeCommit(
-        git, oi, mergeTip, sourceCommit, mergeStrategy, author, commitMsg, rw);
+        oi, git.getConfig(), mergeTip, sourceCommit, mergeStrategy, author, commitMsg, rw);
   }
 }
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 b8556d6..d3feb31 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
@@ -16,10 +16,8 @@
 
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -30,10 +28,14 @@
 import com.google.gerrit.server.change.DeleteAssignee.Input;
 import com.google.gerrit.server.extensions.events.AssigneeChanged;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -41,10 +43,10 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class DeleteAssignee implements RestModifyView<ChangeResource, Input> {
+public class DeleteAssignee
+    extends RetryingRestModifyView<ChangeResource, Input, Response<AccountInfo>> {
   public static class Input {}
 
-  private final BatchUpdate.Factory batchUpdateFactory;
   private final ChangeMessagesUtil cmUtil;
   private final Provider<ReviewDb> db;
   private final AssigneeChanged assigneeChanged;
@@ -53,13 +55,13 @@
 
   @Inject
   DeleteAssignee(
-      BatchUpdate.Factory batchUpdateFactory,
+      RetryHelper retryHelper,
       ChangeMessagesUtil cmUtil,
       Provider<ReviewDb> db,
       AssigneeChanged assigneeChanged,
       IdentifiedUser.GenericFactory userFactory,
       AccountLoader.Factory accountLoaderFactory) {
-    this.batchUpdateFactory = batchUpdateFactory;
+    super(retryHelper);
     this.cmUtil = cmUtil;
     this.db = db;
     this.assigneeChanged = assigneeChanged;
@@ -68,10 +70,13 @@
   }
 
   @Override
-  public Response<AccountInfo> apply(ChangeResource rsrc, Input input)
-      throws RestApiException, UpdateException, OrmException {
+  protected Response<AccountInfo> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
+      throws RestApiException, UpdateException, OrmException, PermissionBackendException {
+    rsrc.permissions().check(ChangePermission.EDIT_ASSIGNEE);
+
     try (BatchUpdate bu =
-        batchUpdateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       Op op = new Op();
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
@@ -88,9 +93,6 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx) throws RestApiException, OrmException {
-      if (!ctx.getControl().canEditAssignee()) {
-        throw new AuthException("Delete Assignee not permitted");
-      }
       change = ctx.getChange();
       ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
       Account.Id currentAssigneeId = change.getAssignee();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChange.java
index ad823d4..b9b05e8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChange.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChange.java
@@ -15,50 +15,74 @@
 package com.google.gerrit.server.change;
 
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.DeleteChange.Input;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.Order;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
-public class DeleteChange
-    implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
+public class DeleteChange extends RetryingRestModifyView<ChangeResource, Input, Response<?>>
+    implements UiAction<ChangeResource> {
   public static class Input {}
 
   private final Provider<ReviewDb> db;
-  private final BatchUpdate.Factory updateFactory;
   private final Provider<DeleteChangeOp> opProvider;
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
   private final boolean allowDrafts;
 
   @Inject
   public DeleteChange(
       Provider<ReviewDb> db,
-      BatchUpdate.Factory updateFactory,
+      RetryHelper retryHelper,
       Provider<DeleteChangeOp> opProvider,
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
       @GerritServerConfig Config cfg) {
+    super(retryHelper);
     this.db = db;
-    this.updateFactory = updateFactory;
     this.opProvider = opProvider;
+    this.user = user;
+    this.permissionBackend = permissionBackend;
     this.allowDrafts = DeleteChangeOp.allowDrafts(cfg);
   }
 
   @Override
-  public Response<?> apply(ChangeResource rsrc, Input input)
-      throws RestApiException, UpdateException {
+  protected Response<?> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
+      throws RestApiException, UpdateException, PermissionBackendException {
+    if (rsrc.getChange().getStatus() == Change.Status.MERGED) {
+      throw new MethodNotAllowedException("delete not permitted");
+    } else if (!allowDrafts && rsrc.getChange().getStatus() == Change.Status.DRAFT) {
+      // If drafts are disabled, only an administrator can delete a draft.
+      try {
+        permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+      } catch (AuthException e) {
+        throw new MethodNotAllowedException("Draft workflow is disabled");
+      }
+    } else {
+      rsrc.permissions().database(db).check(ChangePermission.DELETE);
+    }
+
     try (BatchUpdate bu =
         updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       Change.Id id = rsrc.getChange().getId();
@@ -71,21 +95,33 @@
 
   @Override
   public UiAction.Description getDescription(ChangeResource rsrc) {
-    try {
-      Change.Status status = rsrc.getChange().getStatus();
-      ChangeControl changeControl = rsrc.getControl();
-      boolean visible =
-          isActionAllowed(changeControl, status) && changeControl.canDelete(db.get(), status);
-      return new UiAction.Description()
-          .setLabel("Delete")
-          .setTitle("Delete change " + rsrc.getId())
-          .setVisible(visible);
-    } catch (OrmException e) {
-      throw new IllegalStateException(e);
-    }
+    Change.Status status = rsrc.getChange().getStatus();
+    PermissionBackend.ForChange perm = rsrc.permissions().database(db);
+    return new UiAction.Description()
+        .setLabel("Delete")
+        .setTitle("Delete change " + rsrc.getId())
+        .setVisible(couldDeleteWhenIn(status) && perm.testOrFalse(ChangePermission.DELETE));
   }
 
-  private boolean isActionAllowed(ChangeControl changeControl, Status status) {
-    return status != Status.DRAFT || allowDrafts || changeControl.isAdmin();
+  private boolean couldDeleteWhenIn(Change.Status status) {
+    switch (status) {
+      case NEW:
+      case ABANDONED:
+        // New or abandoned changes can be deleted with the right permissions.
+        return true;
+
+      case MERGED:
+        // Merged changes should never be deleted.
+        return false;
+
+      case DRAFT:
+        if (allowDrafts) {
+          // Drafts can only be deleted if the server has drafts enabled.
+          return true;
+        }
+        // If drafts are disabled, only administrators may delete.
+        return permissionBackend.user(user).testOrFalse(GlobalPermission.ADMINISTRATE_SERVER);
+    }
+    return false;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeOp.java
index 9d819b1..8ab1d2a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeOp.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -27,7 +26,6 @@
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateReviewDb;
@@ -38,14 +36,11 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.Collection;
-import java.util.Collections;
+import java.util.Map;
+import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
 
 class DeleteChangeOp implements BatchUpdateOp {
   static boolean allowDrafts(Config cfg) {
@@ -65,7 +60,6 @@
   private final PatchSetUtil psUtil;
   private final StarredChangesUtil starredChangesUtil;
   private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
-  private final boolean allowDrafts;
 
   private Change.Id id;
 
@@ -73,12 +67,10 @@
   DeleteChangeOp(
       PatchSetUtil psUtil,
       StarredChangesUtil starredChangesUtil,
-      DynamicItem<AccountPatchReviewStore> accountPatchReviewStore,
-      @GerritServerConfig Config cfg) {
+      DynamicItem<AccountPatchReviewStore> accountPatchReviewStore) {
     this.psUtil = psUtil;
     this.starredChangesUtil = starredChangesUtil;
     this.accountPatchReviewStore = accountPatchReviewStore;
-    this.allowDrafts = allowDrafts(cfg);
   }
 
   @Override
@@ -102,8 +94,7 @@
   }
 
   private void ensureDeletable(ChangeContext ctx, Change.Id id, Collection<PatchSet> patchSets)
-      throws ResourceConflictException, MethodNotAllowedException, OrmException, AuthException,
-          IOException {
+      throws ResourceConflictException, MethodNotAllowedException, IOException {
     Change.Status status = ctx.getChange().getStatus();
     if (status == Change.Status.MERGED) {
       throw new MethodNotAllowedException("Deleting merged change " + id + " is not allowed");
@@ -117,14 +108,7 @@
       }
     }
 
-    if (!ctx.getControl().canDelete(ctx.getDb(), status)) {
-      throw new AuthException("Deleting change " + id + " is not permitted");
-    }
-
     if (status == Change.Status.DRAFT) {
-      if (!allowDrafts && !ctx.getControl().isAdmin()) {
-        throw new MethodNotAllowedException("Draft workflow is disabled");
-      }
       for (PatchSet ps : patchSets) {
         if (!ps.isDraft()) {
           throw new ResourceConflictException(
@@ -139,17 +123,14 @@
   }
 
   private boolean isPatchSetMerged(ChangeContext ctx, PatchSet patchSet) throws IOException {
-    Repository repository = ctx.getRepository();
-    Ref destinationRef = repository.exactRef(ctx.getChange().getDest().get());
-    if (destinationRef == null) {
+    Optional<ObjectId> destId = ctx.getRepoView().getRef(ctx.getChange().getDest().get());
+    if (!destId.isPresent()) {
       return false;
     }
 
     RevWalk revWalk = ctx.getRevWalk();
     ObjectId objectId = ObjectId.fromString(patchSet.getRevision().get());
-    RevCommit revCommit = revWalk.parseCommit(objectId);
-    return IncludedInResolver.includedInOne(
-        repository, revWalk, revCommit, Collections.singletonList(destinationRef));
+    return revWalk.isMergedInto(revWalk.parseCommit(objectId), revWalk.parseCommit(destId.get()));
   }
 
   private void deleteChangeElementsFromDb(ChangeContext ctx, Change.Id id) throws OrmException {
@@ -177,8 +158,8 @@
   public void updateRepo(RepoContext ctx) throws IOException {
     String prefix = new PatchSet.Id(id, 1).toRefName();
     prefix = prefix.substring(0, prefix.length() - 1);
-    for (Ref ref : ctx.getRepository().getRefDatabase().getRefs(prefix).values()) {
-      ctx.addRefUpdate(new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), ref.getName()));
+    for (Map.Entry<String, ObjectId> e : ctx.getRepoView().getRefs(prefix).entrySet()) {
+      ctx.addRefUpdate(e.getValue(), ObjectId.zeroId(), prefix + e.getKey());
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteComment.java
new file mode 100644
index 0000000..17665b0
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteComment.java
@@ -0,0 +1,136 @@
+// Copyright (C) 2017 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.common.base.Strings;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class DeleteComment
+    extends RetryingRestModifyView<CommentResource, DeleteCommentInput, CommentInfo> {
+
+  private final Provider<CurrentUser> userProvider;
+  private final Provider<ReviewDb> dbProvider;
+  private final PermissionBackend permissionBackend;
+  private final CommentsUtil commentsUtil;
+  private final Provider<CommentJson> commentJson;
+  private final ChangeNotes.Factory notesFactory;
+
+  @Inject
+  public DeleteComment(
+      Provider<CurrentUser> userProvider,
+      Provider<ReviewDb> dbProvider,
+      PermissionBackend permissionBackend,
+      RetryHelper retryHelper,
+      CommentsUtil commentsUtil,
+      Provider<CommentJson> commentJson,
+      ChangeNotes.Factory notesFactory) {
+    super(retryHelper);
+    this.userProvider = userProvider;
+    this.dbProvider = dbProvider;
+    this.permissionBackend = permissionBackend;
+    this.commentsUtil = commentsUtil;
+    this.commentJson = commentJson;
+    this.notesFactory = notesFactory;
+  }
+
+  @Override
+  public CommentInfo applyImpl(
+      BatchUpdate.Factory batchUpdateFactory, CommentResource rsrc, DeleteCommentInput input)
+      throws RestApiException, IOException, ConfigInvalidException, OrmException,
+          PermissionBackendException, UpdateException {
+    CurrentUser user = userProvider.get();
+    permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+
+    String newMessage = getCommentNewMessage(user.asIdentifiedUser().getName(), input.reason);
+    DeleteCommentOp deleteCommentOp = new DeleteCommentOp(rsrc, newMessage);
+    try (BatchUpdate batchUpdate =
+        batchUpdateFactory.create(
+            dbProvider.get(), rsrc.getRevisionResource().getProject(), user, TimeUtil.nowTs())) {
+      batchUpdate.addOp(rsrc.getRevisionResource().getChange().getId(), deleteCommentOp).execute();
+    }
+
+    ChangeNotes updatedNotes =
+        notesFactory.createChecked(rsrc.getRevisionResource().getChange().getId());
+    List<Comment> changeComments = commentsUtil.publishedByChange(dbProvider.get(), updatedNotes);
+    Optional<Comment> updatedComment =
+        changeComments.stream().filter(c -> c.key.equals(rsrc.getComment().key)).findFirst();
+    if (!updatedComment.isPresent()) {
+      // This should not happen as this endpoint should not remove the whole comment.
+      throw new ResourceNotFoundException("comment not found: " + rsrc.getComment().key);
+    }
+
+    return commentJson.get().newCommentFormatter().format(updatedComment.get());
+  }
+
+  private static String getCommentNewMessage(String name, String reason) {
+    StringBuilder stringBuilder = new StringBuilder("Comment removed by: ").append(name);
+    if (!Strings.isNullOrEmpty(reason)) {
+      stringBuilder.append("; Reason: ").append(reason);
+    }
+    return stringBuilder.toString();
+  }
+
+  private class DeleteCommentOp implements BatchUpdateOp {
+    private final CommentResource rsrc;
+    private final String newMessage;
+
+    DeleteCommentOp(CommentResource rsrc, String newMessage) {
+      this.rsrc = rsrc;
+      this.newMessage = newMessage;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx)
+        throws ResourceConflictException, OrmException, ResourceNotFoundException {
+      PatchSet.Id psId = ctx.getChange().currentPatchSetId();
+      commentsUtil.deleteCommentByRewritingHistory(
+          ctx.getDb(),
+          ctx.getUpdate(psId),
+          rsrc.getComment().key,
+          rsrc.getPatchSet().getId(),
+          newMessage);
+      return true;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
index 7787260..021fd45 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -32,6 +31,8 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -41,13 +42,13 @@
 import java.util.Optional;
 
 @Singleton
-public class DeleteDraftComment implements RestModifyView<DraftCommentResource, Input> {
+public class DeleteDraftComment
+    extends RetryingRestModifyView<DraftCommentResource, Input, Response<CommentInfo>> {
   static class Input {}
 
   private final Provider<ReviewDb> db;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
-  private final BatchUpdate.Factory updateFactory;
   private final PatchListCache patchListCache;
 
   @Inject
@@ -55,17 +56,18 @@
       Provider<ReviewDb> db,
       CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
-      BatchUpdate.Factory updateFactory,
+      RetryHelper retryHelper,
       PatchListCache patchListCache) {
+    super(retryHelper);
     this.db = db;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
-    this.updateFactory = updateFactory;
     this.patchListCache = patchListCache;
   }
 
   @Override
-  public Response<CommentInfo> apply(DraftCommentResource rsrc, Input input)
+  protected Response<CommentInfo> applyImpl(
+      BatchUpdate.Factory updateFactory, DraftCommentResource rsrc, Input input)
       throws RestApiException, UpdateException {
     try (BatchUpdate bu =
         updateFactory.create(
@@ -101,7 +103,7 @@
       Comment c = maybeComment.get();
       setCommentRevId(c, patchListCache, ctx.getChange(), ps);
       commentsUtil.deleteComments(ctx.getDb(), ctx.getUpdate(psId), Collections.singleton(c));
-      ctx.bumpLastUpdatedOn(false);
+      ctx.dontBumpLastUpdatedOn();
       return true;
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java
index d452489..615c32b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkNotNull;
 
+import com.google.common.collect.Iterables;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -23,7 +24,6 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -34,12 +34,16 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Order;
 import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -50,15 +54,14 @@
 import java.util.Map;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.transport.ReceiveCommand;
 
 @Singleton
 public class DeleteDraftPatchSet
-    implements RestModifyView<RevisionResource, Input>, UiAction<RevisionResource> {
+    extends RetryingRestModifyView<RevisionResource, Input, Response<?>>
+    implements UiAction<RevisionResource> {
   public static class Input {}
 
   private final Provider<ReviewDb> db;
-  private final BatchUpdate.Factory updateFactory;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final PatchSetUtil psUtil;
   private final Provider<DeleteChangeOp> deleteChangeOpProvider;
@@ -68,14 +71,14 @@
   @Inject
   public DeleteDraftPatchSet(
       Provider<ReviewDb> db,
-      BatchUpdate.Factory updateFactory,
+      RetryHelper retryHelper,
       PatchSetInfoFactory patchSetInfoFactory,
       PatchSetUtil psUtil,
       Provider<DeleteChangeOp> deleteChangeOpProvider,
       DynamicItem<AccountPatchReviewStore> accountPatchReviewStore,
       @GerritServerConfig Config cfg) {
+    super(retryHelper);
     this.db = db;
-    this.updateFactory = updateFactory;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.psUtil = psUtil;
     this.deleteChangeOpProvider = deleteChangeOpProvider;
@@ -84,8 +87,14 @@
   }
 
   @Override
-  public Response<?> apply(RevisionResource rsrc, Input input)
-      throws RestApiException, UpdateException {
+  protected Response<?> applyImpl(
+      BatchUpdate.Factory updateFactory, RevisionResource rsrc, Input input)
+      throws RestApiException, UpdateException, OrmException, PermissionBackendException {
+    if (isDeletingOnlyPatchSet(rsrc)) {
+      // A change cannot have zero patch sets; the change is deleted instead.
+      rsrc.permissions().database(db).check(ChangePermission.DELETE);
+    }
+
     try (BatchUpdate bu =
         updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       bu.setOrder(Order.DB_BEFORE_REPO);
@@ -95,6 +104,12 @@
     return Response.none();
   }
 
+  private boolean isDeletingOnlyPatchSet(RevisionResource rsrc) throws OrmException {
+    Collection<PatchSet> patchSets = psUtil.byChange(db.get(), rsrc.getNotes());
+    return patchSets.size() == 1
+        && Iterables.getOnlyElement(patchSets).getId().equals(rsrc.getPatchSet().getId());
+  }
+
   private class Op implements BatchUpdateOp {
     private final PatchSet.Id psId;
 
@@ -137,10 +152,9 @@
         return;
       }
       ctx.addRefUpdate(
-          new ReceiveCommand(
-              ObjectId.fromString(patchSet.getRevision().get()),
-              ObjectId.zeroId(),
-              patchSet.getRefName()));
+          ObjectId.fromString(patchSet.getRevision().get()),
+          ObjectId.zeroId(),
+          patchSet.getRefName());
     }
 
     private void deleteDraftPatchSet(PatchSet patchSet, ChangeContext ctx) throws OrmException {
@@ -199,15 +213,14 @@
   @Override
   public UiAction.Description getDescription(RevisionResource rsrc) {
     try {
-      int psCount = psUtil.byChange(db.get(), rsrc.getNotes()).size();
       return new UiAction.Description()
           .setLabel("Delete")
           .setTitle(String.format("Delete draft revision %d", rsrc.getPatchSet().getPatchSetId()))
           .setVisible(
               allowDrafts
                   && rsrc.getPatchSet().isDraft()
-                  && rsrc.getControl().canDelete(db.get(), Change.Status.DRAFT)
-                  && psCount > 1);
+                  && psUtil.byChange(db.get(), rsrc.getNotes()).size() > 1
+                  && rsrc.getControl().canDelete(db.get(), Change.Status.DRAFT));
     } catch (OrmException e) {
       throw new IllegalStateException(e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeletePrivate.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeletePrivate.java
new file mode 100644
index 0000000..71c940b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeletePrivate.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2017 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.TimeUtil;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class DeletePrivate
+    extends RetryingRestModifyView<ChangeResource, SetPrivateOp.Input, Response<String>> {
+  private final ChangeMessagesUtil cmUtil;
+  private final Provider<ReviewDb> dbProvider;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  DeletePrivate(
+      Provider<ReviewDb> dbProvider,
+      RetryHelper retryHelper,
+      ChangeMessagesUtil cmUtil,
+      PermissionBackend permissionBackend) {
+    super(retryHelper);
+    this.dbProvider = dbProvider;
+    this.cmUtil = cmUtil;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  protected Response<String> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, SetPrivateOp.Input input)
+      throws RestApiException, UpdateException {
+    if (!canDeletePrivate(rsrc)) {
+      throw new AuthException("not allowed to unmark private");
+    }
+
+    if (!rsrc.getChange().isPrivate()) {
+      throw new ResourceConflictException("change is not private");
+    }
+
+    ChangeControl control = rsrc.getControl();
+    SetPrivateOp op = new SetPrivateOp(cmUtil, false, input);
+    try (BatchUpdate u =
+        updateFactory.create(
+            dbProvider.get(),
+            control.getProject().getNameKey(),
+            control.getUser(),
+            TimeUtil.nowTs())) {
+      u.addOp(control.getId(), op).execute();
+    }
+
+    return Response.none();
+  }
+
+  protected boolean canDeletePrivate(ChangeResource rsrc) {
+    PermissionBackend.WithUser user = permissionBackend.user(rsrc.getUser());
+    return user.testOrFalse(GlobalPermission.ADMINISTRATE_SERVER)
+        || (rsrc.isUserOwner() && rsrc.getChange().getStatus() != Change.Status.MERGED);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeletePrivateByPost.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeletePrivateByPost.java
new file mode 100644
index 0000000..9cf85d1
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeletePrivateByPost.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class DeletePrivateByPost extends DeletePrivate implements UiAction<ChangeResource> {
+  @Inject
+  DeletePrivateByPost(
+      Provider<ReviewDb> dbProvider,
+      RetryHelper retryHelper,
+      ChangeMessagesUtil cmUtil,
+      PermissionBackend permissionBackend) {
+    super(dbProvider, retryHelper, cmUtil, permissionBackend);
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Unmark private")
+        .setTitle("Unmark change as private")
+        .setVisible(rsrc.getChange().isPrivate() && canDeletePrivate(rsrc));
+  }
+}
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 1485d03..6bee46d 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
@@ -14,96 +14,44 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.collect.Iterables;
 import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-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.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.extensions.events.ReviewerDeleted;
-import com.google.gerrit.server.mail.send.DeleteReviewerSender;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.BatchUpdateReviewDb;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 @Singleton
-public class DeleteReviewer implements RestModifyView<ReviewerResource, DeleteReviewerInput> {
-  private static final Logger log = LoggerFactory.getLogger(DeleteReviewer.class);
+public class DeleteReviewer
+    extends RetryingRestModifyView<ReviewerResource, DeleteReviewerInput, Response<?>> {
 
   private final Provider<ReviewDb> dbProvider;
-  private final ApprovalsUtil approvalsUtil;
-  private final PatchSetUtil psUtil;
-  private final ChangeMessagesUtil cmUtil;
-  private final BatchUpdate.Factory batchUpdateFactory;
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final ReviewerDeleted reviewerDeleted;
-  private final Provider<IdentifiedUser> user;
-  private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
-  private final NotesMigration migration;
-  private final NotifyUtil notifyUtil;
+  private final DeleteReviewerOp.Factory deleteReviewerOpFactory;
+  private final DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory;
 
   @Inject
   DeleteReviewer(
       Provider<ReviewDb> dbProvider,
-      ApprovalsUtil approvalsUtil,
-      PatchSetUtil psUtil,
-      ChangeMessagesUtil cmUtil,
-      BatchUpdate.Factory batchUpdateFactory,
-      IdentifiedUser.GenericFactory userFactory,
-      ReviewerDeleted reviewerDeleted,
-      Provider<IdentifiedUser> user,
-      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
-      NotesMigration migration,
-      NotifyUtil notifyUtil) {
+      RetryHelper retryHelper,
+      DeleteReviewerOp.Factory deleteReviewerOpFactory,
+      DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory) {
+    super(retryHelper);
     this.dbProvider = dbProvider;
-    this.approvalsUtil = approvalsUtil;
-    this.psUtil = psUtil;
-    this.cmUtil = cmUtil;
-    this.batchUpdateFactory = batchUpdateFactory;
-    this.userFactory = userFactory;
-    this.reviewerDeleted = reviewerDeleted;
-    this.user = user;
-    this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
-    this.migration = migration;
-    this.notifyUtil = notifyUtil;
+    this.deleteReviewerOpFactory = deleteReviewerOpFactory;
+    this.deleteReviewerByEmailOpFactory = deleteReviewerByEmailOpFactory;
   }
 
   @Override
-  public Response<?> apply(ReviewerResource rsrc, DeleteReviewerInput input)
+  protected Response<?> applyImpl(
+      BatchUpdate.Factory updateFactory, ReviewerResource rsrc, DeleteReviewerInput input)
       throws RestApiException, UpdateException {
     if (input == null) {
       input = new DeleteReviewerInput();
@@ -113,156 +61,20 @@
     }
 
     try (BatchUpdate bu =
-        batchUpdateFactory.create(
+        updateFactory.create(
             dbProvider.get(),
             rsrc.getChangeResource().getProject(),
             rsrc.getChangeResource().getUser(),
             TimeUtil.nowTs())) {
-      Op op = new Op(rsrc.getReviewerUser().getAccount(), input);
+      BatchUpdateOp op;
+      if (rsrc.isByEmail()) {
+        op = deleteReviewerByEmailOpFactory.create(rsrc.getReviewerByEmail(), input);
+      } else {
+        op = deleteReviewerOpFactory.create(rsrc.getReviewerUser().getAccount(), input);
+      }
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
     }
-
     return Response.none();
   }
-
-  private class Op implements BatchUpdateOp {
-    private final Account reviewer;
-    private final DeleteReviewerInput input;
-    ChangeMessage changeMessage;
-    Change currChange;
-    PatchSet currPs;
-    Map<String, Short> newApprovals = new HashMap<>();
-    Map<String, Short> oldApprovals = new HashMap<>();
-
-    Op(Account reviewerAccount, DeleteReviewerInput input) {
-      this.reviewer = reviewerAccount;
-      this.input = input;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws AuthException, ResourceNotFoundException, OrmException {
-      Account.Id reviewerId = reviewer.getId();
-      if (!approvalsUtil.getReviewers(ctx.getDb(), ctx.getNotes()).all().contains(reviewerId)) {
-        throw new ResourceNotFoundException();
-      }
-      currChange = ctx.getChange();
-      currPs = psUtil.current(ctx.getDb(), ctx.getNotes());
-
-      LabelTypes labelTypes = ctx.getControl().getLabelTypes();
-      // removing a reviewer will remove all her votes
-      for (LabelType lt : labelTypes.getLabelTypes()) {
-        newApprovals.put(lt.getName(), (short) 0);
-      }
-
-      StringBuilder msg = new StringBuilder();
-      msg.append("Removed reviewer " + reviewer.getFullName());
-      StringBuilder removedVotesMsg = new StringBuilder();
-      removedVotesMsg.append(" with the following votes:\n\n");
-      List<PatchSetApproval> del = new ArrayList<>();
-      boolean votesRemoved = false;
-      for (PatchSetApproval a : approvals(ctx, reviewerId)) {
-        if (ctx.getControl().canRemoveReviewer(a)) {
-          del.add(a);
-          if (a.getPatchSetId().equals(currPs.getId()) && a.getValue() != 0) {
-            oldApprovals.put(a.getLabel(), a.getValue());
-            removedVotesMsg
-                .append("* ")
-                .append(a.getLabel())
-                .append(formatLabelValue(a.getValue()))
-                .append(" by ")
-                .append(userFactory.create(a.getAccountId()).getNameEmail())
-                .append("\n");
-            votesRemoved = true;
-          }
-        } else {
-          throw new AuthException("delete reviewer not permitted");
-        }
-      }
-
-      if (votesRemoved) {
-        msg.append(removedVotesMsg);
-      } else {
-        msg.append(".");
-      }
-      ctx.getDb().patchSetApprovals().delete(del);
-      ChangeUpdate update = ctx.getUpdate(currPs.getId());
-      update.removeReviewer(reviewerId);
-
-      changeMessage =
-          ChangeMessagesUtil.newMessage(
-              ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_REVIEWER);
-      cmUtil.addChangeMessage(ctx.getDb(), update, changeMessage);
-
-      return true;
-    }
-
-    @Override
-    public void postUpdate(Context ctx) {
-      if (NotifyUtil.shouldNotify(input.notify, input.notifyDetails)) {
-        emailReviewers(ctx.getProject(), currChange, changeMessage);
-      }
-      reviewerDeleted.fire(
-          currChange,
-          currPs,
-          reviewer,
-          ctx.getAccount(),
-          changeMessage.getMessage(),
-          newApprovals,
-          oldApprovals,
-          input.notify,
-          ctx.getWhen());
-    }
-
-    private Iterable<PatchSetApproval> approvals(ChangeContext ctx, Account.Id accountId)
-        throws OrmException {
-      Change.Id changeId = ctx.getNotes().getChangeId();
-      Iterable<PatchSetApproval> approvals;
-      PrimaryStorage r = PrimaryStorage.of(ctx.getChange());
-
-      if (migration.readChanges() && r == PrimaryStorage.REVIEW_DB) {
-        // Because NoteDb and ReviewDb have different semantics for zero-value
-        // approvals, we must fall back to ReviewDb as the source of truth here.
-        ReviewDb db = ctx.getDb();
-
-        if (db instanceof BatchUpdateReviewDb) {
-          db = ((BatchUpdateReviewDb) db).unsafeGetDelegate();
-        }
-        db = ReviewDbUtil.unwrapDb(db);
-        approvals = db.patchSetApprovals().byChange(changeId);
-      } else {
-        approvals = approvalsUtil.byChange(ctx.getDb(), ctx.getNotes()).values();
-      }
-
-      return Iterables.filter(approvals, psa -> accountId.equals(psa.getAccountId()));
-    }
-
-    private String formatLabelValue(short value) {
-      if (value > 0) {
-        return "+" + value;
-      }
-      return Short.toString(value);
-    }
-
-    private void emailReviewers(
-        Project.NameKey projectName, Change change, ChangeMessage changeMessage) {
-      Account.Id userId = user.get().getAccountId();
-      if (userId.equals(reviewer.getId())) {
-        // The user knows they removed themselves, don't bother emailing them.
-        return;
-      }
-      try {
-        DeleteReviewerSender cm = deleteReviewerSenderFactory.create(projectName, change.getId());
-        cm.setFrom(userId);
-        cm.addReviewers(Collections.singleton(reviewer.getId()));
-        cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
-        cm.setNotify(input.notify);
-        cm.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
-        cm.send();
-      } catch (Exception err) {
-        log.error("Cannot email update for change " + change.getId(), err);
-      }
-    }
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
new file mode 100644
index 0000000..adfe3f5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+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.ChangeUtil;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Collections;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class DeleteReviewerByEmailOp implements BatchUpdateOp {
+  private static final Logger log = LoggerFactory.getLogger(DeleteReviewer.class);
+
+  public interface Factory {
+    DeleteReviewerByEmailOp create(Address reviewer, DeleteReviewerInput input);
+  }
+
+  private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
+  private final NotifyUtil notifyUtil;
+  private final Address reviewer;
+  private final DeleteReviewerInput input;
+
+  private ChangeMessage changeMessage;
+  private Change.Id changeId;
+
+  @Inject
+  DeleteReviewerByEmailOp(
+      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
+      NotifyUtil notifyUtil,
+      @Assisted Address reviewer,
+      @Assisted DeleteReviewerInput input) {
+    this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
+    this.notifyUtil = notifyUtil;
+    this.reviewer = reviewer;
+    this.input = input;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws OrmException {
+    changeId = ctx.getChange().getId();
+    PatchSet.Id psId = ctx.getChange().currentPatchSetId();
+    String msg = "Removed reviewer " + reviewer;
+    changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(changeId, ChangeUtil.messageUuid()),
+            ctx.getAccountId(),
+            ctx.getWhen(),
+            psId);
+    changeMessage.setMessage(msg);
+
+    ctx.getUpdate(psId).setChangeMessage(msg);
+    ctx.getUpdate(psId).removeReviewerByEmail(reviewer);
+    return true;
+  }
+
+  @Override
+  public void postUpdate(Context ctx) {
+    if (!NotifyUtil.shouldNotify(input.notify, input.notifyDetails)) {
+      return;
+    }
+    try {
+      DeleteReviewerSender cm = deleteReviewerSenderFactory.create(ctx.getProject(), changeId);
+      cm.setFrom(ctx.getAccountId());
+      cm.addReviewersByEmail(Collections.singleton(reviewer));
+      cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
+      cm.setNotify(input.notify);
+      cm.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
+      cm.send();
+    } catch (Exception err) {
+      log.error("Cannot email update for change " + changeId, err);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerOp.java
new file mode 100644
index 0000000..a255f79
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -0,0 +1,232 @@
+// Copyright (C) 2017 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.common.collect.Iterables;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+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.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.extensions.events.ReviewerDeleted;
+import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.BatchUpdateReviewDb;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class DeleteReviewerOp implements BatchUpdateOp {
+  private static final Logger log = LoggerFactory.getLogger(DeleteReviewer.class);
+
+  public interface Factory {
+    DeleteReviewerOp create(Account reviewerAccount, DeleteReviewerInput input);
+  }
+
+  private final ApprovalsUtil approvalsUtil;
+  private final PatchSetUtil psUtil;
+  private final ChangeMessagesUtil cmUtil;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final ReviewerDeleted reviewerDeleted;
+  private final Provider<IdentifiedUser> user;
+  private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
+  private final NotesMigration migration;
+  private final NotifyUtil notifyUtil;
+
+  private final Account reviewer;
+  private final DeleteReviewerInput input;
+
+  ChangeMessage changeMessage;
+  Change currChange;
+  PatchSet currPs;
+  Map<String, Short> newApprovals = new HashMap<>();
+  Map<String, Short> oldApprovals = new HashMap<>();
+
+  @Inject
+  DeleteReviewerOp(
+      ApprovalsUtil approvalsUtil,
+      PatchSetUtil psUtil,
+      ChangeMessagesUtil cmUtil,
+      IdentifiedUser.GenericFactory userFactory,
+      ReviewerDeleted reviewerDeleted,
+      Provider<IdentifiedUser> user,
+      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
+      NotesMigration migration,
+      NotifyUtil notifyUtil,
+      @Assisted Account reviewerAccount,
+      @Assisted DeleteReviewerInput input) {
+    this.approvalsUtil = approvalsUtil;
+    this.psUtil = psUtil;
+    this.cmUtil = cmUtil;
+    this.userFactory = userFactory;
+    this.reviewerDeleted = reviewerDeleted;
+    this.user = user;
+    this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
+    this.migration = migration;
+    this.notifyUtil = notifyUtil;
+
+    this.reviewer = reviewerAccount;
+    this.input = input;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws AuthException, ResourceNotFoundException, OrmException {
+    Account.Id reviewerId = reviewer.getId();
+    if (!approvalsUtil.getReviewers(ctx.getDb(), ctx.getNotes()).all().contains(reviewerId)) {
+      throw new ResourceNotFoundException();
+    }
+    currChange = ctx.getChange();
+    currPs = psUtil.current(ctx.getDb(), ctx.getNotes());
+
+    LabelTypes labelTypes = ctx.getControl().getLabelTypes();
+    // removing a reviewer will remove all her votes
+    for (LabelType lt : labelTypes.getLabelTypes()) {
+      newApprovals.put(lt.getName(), (short) 0);
+    }
+
+    StringBuilder msg = new StringBuilder();
+    msg.append("Removed reviewer " + reviewer.getFullName());
+    StringBuilder removedVotesMsg = new StringBuilder();
+    removedVotesMsg.append(" with the following votes:\n\n");
+    List<PatchSetApproval> del = new ArrayList<>();
+    boolean votesRemoved = false;
+    for (PatchSetApproval a : approvals(ctx, reviewerId)) {
+      if (ctx.getControl().canRemoveReviewer(a)) {
+        del.add(a);
+        if (a.getPatchSetId().equals(currPs.getId()) && a.getValue() != 0) {
+          oldApprovals.put(a.getLabel(), a.getValue());
+          removedVotesMsg
+              .append("* ")
+              .append(a.getLabel())
+              .append(formatLabelValue(a.getValue()))
+              .append(" by ")
+              .append(userFactory.create(a.getAccountId()).getNameEmail())
+              .append("\n");
+          votesRemoved = true;
+        }
+      } else {
+        throw new AuthException("delete reviewer not permitted");
+      }
+    }
+
+    if (votesRemoved) {
+      msg.append(removedVotesMsg);
+    } else {
+      msg.append(".");
+    }
+    ctx.getDb().patchSetApprovals().delete(del);
+    ChangeUpdate update = ctx.getUpdate(currPs.getId());
+    update.removeReviewer(reviewerId);
+
+    changeMessage =
+        ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_REVIEWER);
+    cmUtil.addChangeMessage(ctx.getDb(), update, changeMessage);
+
+    return true;
+  }
+
+  @Override
+  public void postUpdate(Context ctx) {
+    if (NotifyUtil.shouldNotify(input.notify, input.notifyDetails)) {
+      emailReviewers(ctx.getProject(), currChange, changeMessage);
+    }
+    reviewerDeleted.fire(
+        currChange,
+        currPs,
+        reviewer,
+        ctx.getAccount(),
+        changeMessage.getMessage(),
+        newApprovals,
+        oldApprovals,
+        input.notify,
+        ctx.getWhen());
+  }
+
+  private Iterable<PatchSetApproval> approvals(ChangeContext ctx, Account.Id accountId)
+      throws OrmException {
+    Change.Id changeId = ctx.getNotes().getChangeId();
+    Iterable<PatchSetApproval> approvals;
+    PrimaryStorage r = PrimaryStorage.of(ctx.getChange());
+
+    if (migration.readChanges() && r == PrimaryStorage.REVIEW_DB) {
+      // Because NoteDb and ReviewDb have different semantics for zero-value
+      // approvals, we must fall back to ReviewDb as the source of truth here.
+      ReviewDb db = ctx.getDb();
+
+      if (db instanceof BatchUpdateReviewDb) {
+        db = ((BatchUpdateReviewDb) db).unsafeGetDelegate();
+      }
+      db = ReviewDbUtil.unwrapDb(db);
+      approvals = db.patchSetApprovals().byChange(changeId);
+    } else {
+      approvals = approvalsUtil.byChange(ctx.getDb(), ctx.getNotes()).values();
+    }
+
+    return Iterables.filter(approvals, psa -> accountId.equals(psa.getAccountId()));
+  }
+
+  private String formatLabelValue(short value) {
+    if (value > 0) {
+      return "+" + value;
+    }
+    return Short.toString(value);
+  }
+
+  private void emailReviewers(
+      Project.NameKey projectName, Change change, ChangeMessage changeMessage) {
+    Account.Id userId = user.get().getAccountId();
+    if (userId.equals(reviewer.getId())) {
+      // The user knows they removed themselves, don't bother emailing them.
+      return;
+    }
+    try {
+      DeleteReviewerSender cm = deleteReviewerSenderFactory.create(projectName, change.getId());
+      cm.setFrom(userId);
+      cm.addReviewers(Collections.singleton(reviewer.getId()));
+      cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
+      cm.setNotify(input.notify);
+      cm.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
+      cm.send();
+    } catch (Exception err) {
+      log.error("Cannot email update for change " + change.getId(), err);
+    }
+  }
+}
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 963e7b4..6dd1e2c 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
@@ -26,7 +26,6 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -46,6 +45,8 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gwtorm.server.OrmException;
@@ -59,11 +60,10 @@
 import org.slf4j.LoggerFactory;
 
 @Singleton
-public class DeleteVote implements RestModifyView<VoteResource, DeleteVoteInput> {
+public class DeleteVote extends RetryingRestModifyView<VoteResource, DeleteVoteInput, Response<?>> {
   private static final Logger log = LoggerFactory.getLogger(DeleteVote.class);
 
   private final Provider<ReviewDb> db;
-  private final BatchUpdate.Factory batchUpdateFactory;
   private final ApprovalsUtil approvalsUtil;
   private final PatchSetUtil psUtil;
   private final ChangeMessagesUtil cmUtil;
@@ -75,7 +75,7 @@
   @Inject
   DeleteVote(
       Provider<ReviewDb> db,
-      BatchUpdate.Factory batchUpdateFactory,
+      RetryHelper retryHelper,
       ApprovalsUtil approvalsUtil,
       PatchSetUtil psUtil,
       ChangeMessagesUtil cmUtil,
@@ -83,8 +83,8 @@
       VoteDeleted voteDeleted,
       DeleteVoteSender.Factory deleteVoteSenderFactory,
       NotifyUtil notifyUtil) {
+    super(retryHelper);
     this.db = db;
-    this.batchUpdateFactory = batchUpdateFactory;
     this.approvalsUtil = approvalsUtil;
     this.psUtil = psUtil;
     this.cmUtil = cmUtil;
@@ -95,7 +95,8 @@
   }
 
   @Override
-  public Response<?> apply(VoteResource rsrc, DeleteVoteInput input)
+  protected Response<?> applyImpl(
+      BatchUpdate.Factory updateFactory, VoteResource rsrc, DeleteVoteInput input)
       throws RestApiException, UpdateException {
     if (input == null) {
       input = new DeleteVoteInput();
@@ -114,7 +115,7 @@
     }
 
     try (BatchUpdate bu =
-        batchUpdateFactory.create(
+        updateFactory.create(
             db.get(), change.getProject(), r.getControl().getUser(), TimeUtil.nowTs())) {
       bu.addOp(change.getId(), new Op(r.getReviewerUser().getAccountId(), rsrc.getLabel(), input));
       bu.execute();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/EmailReviewComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/EmailReviewComments.java
index 2e8fc2d..8e3ee9f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -50,6 +50,24 @@
   private static final Logger log = LoggerFactory.getLogger(EmailReviewComments.class);
 
   public interface Factory {
+    // TODO(dborowitz/wyatta): Rationalize these arguments so HTML and text templates are operating
+    // on the same set of inputs.
+    /**
+     * @param notify setting for handling notification.
+     * @param accountsToNotify detailed map of accounts to notify.
+     * @param notes change notes.
+     * @param patchSet patch set corresponding to the top-level op
+     * @param user user the email should come from.
+     * @param message used by text template only: the full ChangeMessage that will go in the
+     *     database. The contents of this message typically include the "Patch set N" header and "(M
+     *     comments)".
+     * @param comments inline comments.
+     * @param patchSetComment used by HTML template only: some quasi-human-generated text. The
+     *     contents should *not* include a "Patch set N" header or "(M comments)" footer, as these
+     *     will be added automatically in soy in a structured way.
+     * @param labels labels applied as part of this review operation.
+     * @return handle for sending email.
+     */
     EmailReviewComments create(
         NotifyHandling notify,
         ListMultimap<RecipientType, Account.Id> accountsToNotify,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileContentUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileContentUtil.java
index 732848e..01401b8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileContentUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileContentUtil.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.PatchScript.FileMode;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Patch;
@@ -67,10 +68,41 @@
     this.registry = ftr;
   }
 
-  public BinaryResult getContent(ProjectState project, ObjectId revstr, String path)
-      throws ResourceNotFoundException, IOException {
+  /**
+   * Get the content of a file at a specific commit or one of it's parent commits.
+   *
+   * @param project A {@code Project} that this request refers to.
+   * @param revstr An {@code ObjectId} specifying the commit.
+   * @param path A string specifying the filepath.
+   * @param parent A 1-based parent index to get the content from instead. Null if the content
+   *     should be obtained from {@code revstr} instead.
+   * @return Content of the file as {@code BinaryResult}.
+   * @throws ResourceNotFoundException
+   * @throws IOException
+   */
+  public BinaryResult getContent(
+      ProjectState project, ObjectId revstr, String path, @Nullable Integer parent)
+      throws BadRequestException, ResourceNotFoundException, IOException {
     try (Repository repo = openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
+      if (parent != null) {
+        RevCommit revCommit = rw.parseCommit(revstr);
+        if (revCommit == null) {
+          throw new ResourceNotFoundException("commit not found");
+        }
+        if (parent > revCommit.getParentCount()) {
+          throw new BadRequestException("invalid parent");
+        }
+        revstr = rw.parseCommit(revstr).getParent(Integer.max(0, parent - 1)).toObjectId();
+      }
+      return getContent(repo, project, revstr, path);
+    }
+  }
+
+  public BinaryResult getContent(
+      Repository repo, ProjectState project, ObjectId revstr, String path)
+      throws IOException, ResourceNotFoundException {
+    try (RevWalk rw = new RevWalk(repo)) {
       RevCommit commit = rw.parseCommit(revstr);
       ObjectReader reader = rw.getObjectReader();
       TreeWalk tw = TreeWalk.forPath(reader, path, commit.getTree());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java
index 60a4daf..b25b588 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java
@@ -48,9 +48,14 @@
 
   Map<String, FileInfo> toFileInfoMap(Change change, RevId revision, @Nullable PatchSet base)
       throws PatchListNotAvailableException {
+    ObjectId objectId = ObjectId.fromString(revision.get());
+    return toFileInfoMap(change, objectId, base);
+  }
+
+  Map<String, FileInfo> toFileInfoMap(Change change, ObjectId objectId, @Nullable PatchSet base)
+      throws PatchListNotAvailableException {
     ObjectId a = (base == null) ? null : ObjectId.fromString(base.getRevision().get());
-    ObjectId b = ObjectId.fromString(revision.get());
-    return toFileInfoMap(change, new PatchListKey(a, b, Whitespace.IGNORE_NONE));
+    return toFileInfoMap(change, new PatchListKey(a, objectId, Whitespace.IGNORE_NONE));
   }
 
   Map<String, FileInfo> toFileInfoMap(Change change, RevId revision, int parent)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/FixResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/FixResource.java
new file mode 100644
index 0000000..08e2785
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/FixResource.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.FixReplacement;
+import com.google.inject.TypeLiteral;
+import java.util.List;
+
+public class FixResource implements RestResource {
+  public static final TypeLiteral<RestView<FixResource>> FIX_KIND =
+      new TypeLiteral<RestView<FixResource>>() {};
+
+  private final List<FixReplacement> fixReplacements;
+  private final RevisionResource revisionResource;
+
+  public FixResource(RevisionResource revisionResource, List<FixReplacement> fixReplacements) {
+    this.fixReplacements = fixReplacements;
+    this.revisionResource = revisionResource;
+  }
+
+  public List<FixReplacement> getFixReplacements() {
+    return fixReplacements;
+  }
+
+  public RevisionResource getRevisionResource() {
+    return revisionResource;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Fixes.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Fixes.java
new file mode 100644
index 0000000..af9f60a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Fixes.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.FixSuggestion;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.List;
+import java.util.Objects;
+
+@Singleton
+public class Fixes implements ChildCollection<RevisionResource, FixResource> {
+
+  private final DynamicMap<RestView<FixResource>> views;
+  private final CommentsUtil commentsUtil;
+
+  @Inject
+  Fixes(DynamicMap<RestView<FixResource>> views, CommentsUtil commentsUtil) {
+    this.views = views;
+    this.commentsUtil = commentsUtil;
+  }
+
+  @Override
+  public RestView<RevisionResource> list() throws ResourceNotFoundException {
+    throw new ResourceNotFoundException();
+  }
+
+  @Override
+  public FixResource parse(RevisionResource revisionResource, IdString id)
+      throws ResourceNotFoundException, OrmException {
+    String fixId = id.get();
+    ChangeNotes changeNotes = revisionResource.getNotes();
+
+    List<RobotComment> robotComments =
+        commentsUtil.robotCommentsByPatchSet(changeNotes, revisionResource.getPatchSet().getId());
+    for (RobotComment robotComment : robotComments) {
+      for (FixSuggestion fixSuggestion : robotComment.fixSuggestions) {
+        if (Objects.equals(fixId, fixSuggestion.fixId)) {
+          return new FixResource(revisionResource, fixSuggestion.replacements);
+        }
+      }
+    }
+    throw new ResourceNotFoundException(id);
+  }
+
+  @Override
+  public DynamicMap<RestView<FixResource>> views() {
+    return views;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetContent.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetContent.java
index abb9e66..5433653 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetContent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetContent.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -30,21 +31,23 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.kohsuke.args4j.Option;
 
-@Singleton
 public class GetContent implements RestReadView<FileResource> {
   private final Provider<ReviewDb> db;
   private final GitRepositoryManager gitManager;
   private final PatchSetUtil psUtil;
   private final FileContentUtil fileContentUtil;
 
+  @Option(name = "--parent")
+  private Integer parent;
+
   @Inject
   GetContent(
       Provider<ReviewDb> db,
@@ -59,7 +62,7 @@
 
   @Override
   public BinaryResult apply(FileResource rsrc)
-      throws ResourceNotFoundException, IOException, NoSuchChangeException, OrmException {
+      throws ResourceNotFoundException, IOException, BadRequestException, OrmException {
     String path = rsrc.getPatchKey().get();
     if (Patch.COMMIT_MSG.equals(path)) {
       String msg = getMessage(rsrc.getRevision().getChangeResource().getNotes());
@@ -75,7 +78,8 @@
     return fileContentUtil.getContent(
         rsrc.getRevision().getControl().getProjectControl().getProjectState(),
         ObjectId.fromString(rsrc.getRevision().getPatchSet().getRevision().get()),
-        path);
+        path,
+        parent);
   }
 
   private String getMessage(ChangeNotes notes) throws OrmException, IOException {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetReviewer.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetReviewer.java
index aa0b339..db9af1d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetReviewer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetReviewer.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -31,7 +32,8 @@
   }
 
   @Override
-  public List<ReviewerInfo> apply(ReviewerResource rsrc) throws OrmException {
+  public List<ReviewerInfo> apply(ReviewerResource rsrc)
+      throws OrmException, PermissionBackendException {
     return json.format(rsrc);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Ignore.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Ignore.java
new file mode 100644
index 0000000..83ab811
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Ignore.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class Ignore
+    implements RestModifyView<ChangeResource, Ignore.Input>, UiAction<ChangeResource> {
+  private static final Logger log = LoggerFactory.getLogger(Ignore.class);
+
+  public static class Input {}
+
+  private final Provider<IdentifiedUser> self;
+  private final StarredChangesUtil stars;
+
+  @Inject
+  Ignore(Provider<IdentifiedUser> self, StarredChangesUtil stars) {
+    this.self = self;
+    this.stars = stars;
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Ignore")
+        .setTitle("Ignore the change")
+        .setVisible(!rsrc.isUserOwner() && !isIgnored(rsrc));
+  }
+
+  @Override
+  public Response<String> apply(ChangeResource rsrc, Input input) throws RestApiException {
+    try {
+      if (rsrc.isUserOwner() || isIgnored(rsrc)) {
+        // early exit for own changes and already ignored changes
+        return Response.ok("");
+      }
+      stars.ignore(self.get().getAccountId(), rsrc.getProject(), rsrc.getChange().getId());
+    } catch (OrmException e) {
+      throw new RestApiException("failed to ignore change", e);
+    }
+    return Response.ok("");
+  }
+
+  private boolean isIgnored(ChangeResource rsrc) {
+    try {
+      return stars.isIgnoredBy(rsrc.getChange().getId(), self.get().getAccountId());
+    } catch (OrmException e) {
+      log.error("failed to check ignored star", e);
+    }
+    return false;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Index.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Index.java
index 9257445..7c4d158 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Index.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Index.java
@@ -16,11 +16,16 @@
 
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.Index.Input;
 import com.google.gerrit.server.index.change.ChangeIndexer;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -28,25 +33,33 @@
 import java.io.IOException;
 
 @Singleton
-public class Index implements RestModifyView<ChangeResource, Input> {
+public class Index extends RetryingRestModifyView<ChangeResource, Input, Response<?>> {
   public static class Input {}
 
   private final Provider<ReviewDb> db;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
   private final ChangeIndexer indexer;
 
   @Inject
-  Index(Provider<ReviewDb> db, ChangeIndexer indexer) {
+  Index(
+      Provider<ReviewDb> db,
+      RetryHelper retryHelper,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      ChangeIndexer indexer) {
+    super(retryHelper);
     this.db = db;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
     this.indexer = indexer;
   }
 
   @Override
-  public Response<?> apply(ChangeResource rsrc, Input input)
-      throws IOException, AuthException, OrmException {
-    ChangeControl ctl = rsrc.getControl();
-    if (!ctl.isOwner() && !ctl.getUser().getCapabilities().canMaintainServer()) {
-      throw new AuthException("Only change owner or server maintainer can reindex");
-    }
+  protected Response<?> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
+      throws IOException, AuthException, OrmException, PermissionBackendException {
+    permissionBackend.user(user).check(GlobalPermission.MAINTAIN_SERVER);
     indexer.index(db.get(), rsrc.getChange());
     return Response.none();
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListReviewers.java
index 27ec89d..ba2a10b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListReviewers.java
@@ -19,6 +19,8 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -47,12 +49,18 @@
   }
 
   @Override
-  public List<ReviewerInfo> apply(ChangeResource rsrc) throws OrmException {
-    Map<Account.Id, ReviewerResource> reviewers = new LinkedHashMap<>();
+  public List<ReviewerInfo> apply(ChangeResource rsrc)
+      throws OrmException, PermissionBackendException {
+    Map<String, ReviewerResource> reviewers = new LinkedHashMap<>();
     ReviewDb db = dbProvider.get();
     for (Account.Id accountId : approvalsUtil.getReviewers(db, rsrc.getNotes()).all()) {
-      if (!reviewers.containsKey(accountId)) {
-        reviewers.put(accountId, resourceFactory.create(rsrc, accountId));
+      if (!reviewers.containsKey(accountId.toString())) {
+        reviewers.put(accountId.toString(), resourceFactory.create(rsrc, accountId));
+      }
+    }
+    for (Address adr : rsrc.getNotes().getReviewersByEmail().all()) {
+      if (!reviewers.containsKey(adr.toString())) {
+        reviewers.put(adr.toString(), new ReviewerResource(rsrc, adr));
       }
     }
     return json.format(reviewers.values());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionReviewers.java
index d0c8ca0..6d9dc79 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionReviewers.java
@@ -20,6 +20,8 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -49,16 +51,21 @@
 
   @Override
   public List<ReviewerInfo> apply(RevisionResource rsrc)
-      throws OrmException, MethodNotAllowedException {
+      throws OrmException, MethodNotAllowedException, PermissionBackendException {
     if (!rsrc.isCurrent()) {
       throw new MethodNotAllowedException("Cannot list reviewers on non-current patch set");
     }
 
-    Map<Account.Id, ReviewerResource> reviewers = new LinkedHashMap<>();
+    Map<String, ReviewerResource> reviewers = new LinkedHashMap<>();
     ReviewDb db = dbProvider.get();
     for (Account.Id accountId : approvalsUtil.getReviewers(db, rsrc.getNotes()).all()) {
-      if (!reviewers.containsKey(accountId)) {
-        reviewers.put(accountId, resourceFactory.create(rsrc, accountId));
+      if (!reviewers.containsKey(accountId.toString())) {
+        reviewers.put(accountId.toString(), resourceFactory.create(rsrc, accountId));
+      }
+    }
+    for (Address address : rsrc.getNotes().getReviewersByEmail().all()) {
+      if (!reviewers.containsKey(address.toString())) {
+        reviewers.put(address.toString(), new ReviewerResource(rsrc, address));
       }
     }
     return json.format(reviewers.values());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
index d67f8ce..119051e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
@@ -31,9 +31,7 @@
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
-import com.google.gerrit.server.git.IntegrationException;
 import com.google.gerrit.server.git.strategy.SubmitDryRun;
-import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
@@ -45,7 +43,6 @@
 import java.util.Arrays;
 import java.util.Objects;
 import java.util.Set;
-import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -173,31 +170,6 @@
     }
   }
 
-  private class Loader implements Callable<Boolean> {
-    private final EntryKey key;
-    private final Branch.NameKey dest;
-    private final Repository repo;
-
-    Loader(EntryKey key, Branch.NameKey dest, Repository repo) {
-      this.key = key;
-      this.dest = dest;
-      this.repo = repo;
-    }
-
-    @Override
-    public Boolean call() throws NoSuchProjectException, IntegrationException, IOException {
-      if (key.into.equals(ObjectId.zeroId())) {
-        return true; // Assume yes on new branch.
-      }
-      try (CodeReviewRevWalk rw = CodeReviewCommit.newRevWalk(repo)) {
-        Set<RevCommit> accepted = SubmitDryRun.getAlreadyAccepted(repo, rw);
-        accepted.add(rw.parseCommit(key.into));
-        accepted.addAll(Arrays.asList(rw.parseCommit(key.commit).getParents()));
-        return submitDryRun.run(key.submitType, repo, rw, dest, key.into, key.commit, accepted);
-      }
-    }
-  }
-
   public static class MergeabilityWeigher implements Weigher<EntryKey, Boolean> {
     @Override
     public int weigh(EntryKey k, Boolean v) {
@@ -229,7 +201,20 @@
     ObjectId into = intoRef != null ? intoRef.getObjectId() : ObjectId.zeroId();
     EntryKey key = new EntryKey(commit, into, submitType, mergeStrategy);
     try {
-      return cache.get(key, new Loader(key, dest, repo));
+      return cache.get(
+          key,
+          () -> {
+            if (key.into.equals(ObjectId.zeroId())) {
+              return true; // Assume yes on new branch.
+            }
+            try (CodeReviewRevWalk rw = CodeReviewCommit.newRevWalk(repo)) {
+              Set<RevCommit> accepted = SubmitDryRun.getAlreadyAccepted(repo, rw);
+              accepted.add(rw.parseCommit(key.into));
+              accepted.addAll(Arrays.asList(rw.parseCommit(key.commit).getParents()));
+              return submitDryRun.run(
+                  key.submitType, repo, rw, dest, key.into, key.commit, accepted);
+            }
+          });
     } catch (ExecutionException | UncheckedExecutionException e) {
       log.error(
           String.format(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Mergeable.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Mergeable.java
index e5f9352..69f26b2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Mergeable.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Mergeable.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 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.git.BranchOrderSection;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
@@ -98,7 +99,7 @@
     MergeableInfo result = new MergeableInfo();
 
     if (!change.getStatus().isOpen()) {
-      throw new ResourceConflictException("change is " + Submit.status(change));
+      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
     } else if (!ps.getId().equals(change.currentPatchSetId())) {
       // Only the current revision is mergeable. Others always fail.
       return result;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
index aca6ef1..db31c17 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.server.change.CommentResource.COMMENT_KIND;
 import static com.google.gerrit.server.change.DraftCommentResource.DRAFT_COMMENT_KIND;
 import static com.google.gerrit.server.change.FileResource.FILE_KIND;
+import static com.google.gerrit.server.change.FixResource.FIX_KIND;
 import static com.google.gerrit.server.change.ReviewerResource.REVIEWER_KIND;
 import static com.google.gerrit.server.change.RevisionResource.REVISION_KIND;
 import static com.google.gerrit.server.change.RobotCommentResource.ROBOT_COMMENT_KIND;
@@ -40,12 +41,14 @@
     bind(DraftComments.class);
     bind(Comments.class);
     bind(RobotComments.class);
+    bind(Fixes.class);
     bind(Files.class);
     bind(Votes.class);
 
     DynamicMap.mapOf(binder(), CHANGE_KIND);
     DynamicMap.mapOf(binder(), COMMENT_KIND);
     DynamicMap.mapOf(binder(), ROBOT_COMMENT_KIND);
+    DynamicMap.mapOf(binder(), FIX_KIND);
     DynamicMap.mapOf(binder(), DRAFT_COMMENT_KIND);
     DynamicMap.mapOf(binder(), FILE_KIND);
     DynamicMap.mapOf(binder(), REVIEWER_KIND);
@@ -82,6 +85,15 @@
     post(CHANGE_KIND, "index").to(Index.class);
     post(CHANGE_KIND, "rebuild.notedb").to(Rebuild.class);
     post(CHANGE_KIND, "move").to(Move.class);
+    post(CHANGE_KIND, "private").to(PostPrivate.class);
+    post(CHANGE_KIND, "private.delete").to(DeletePrivateByPost.class);
+    delete(CHANGE_KIND, "private").to(DeletePrivate.class);
+    put(CHANGE_KIND, "ignore").to(Ignore.class);
+    put(CHANGE_KIND, "unignore").to(Unignore.class);
+    put(CHANGE_KIND, "mute").to(Mute.class);
+    put(CHANGE_KIND, "unmute").to(Unmute.class);
+    post(CHANGE_KIND, "wip").to(SetWorkInProgress.class);
+    post(CHANGE_KIND, "ready").to(SetReadyForReview.class);
 
     post(CHANGE_KIND, "reviewers").to(PostReviewers.class);
     get(CHANGE_KIND, "suggest_reviewers").to(SuggestChangeReviewers.class);
@@ -125,9 +137,13 @@
 
     child(REVISION_KIND, "comments").to(Comments.class);
     get(COMMENT_KIND).to(GetComment.class);
+    delete(COMMENT_KIND).to(DeleteComment.class);
+    post(COMMENT_KIND, "delete").to(DeleteComment.class);
 
     child(REVISION_KIND, "robotcomments").to(RobotComments.class);
     get(ROBOT_COMMENT_KIND).to(GetRobotComment.class);
+    child(REVISION_KIND, "fixes").to(Fixes.class);
+    post(FIX_KIND, "apply").to(ApplyFix.class);
 
     child(REVISION_KIND, "files").to(Files.class);
     put(FILE_KIND, "reviewed").to(PutReviewed.class);
@@ -159,5 +175,8 @@
     factory(SetAssigneeOp.Factory.class);
     factory(SetHashtagsOp.Factory.class);
     factory(ChangeResource.Factory.class);
+    factory(DeleteReviewerOp.Factory.class);
+    factory(DeleteReviewerByEmailOp.Factory.class);
+    factory(PostReviewersOp.Factory.class);
   }
 }
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 fb6ae0b..ffc0dc4 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
@@ -23,7 +23,6 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Status;
@@ -33,14 +32,21 @@
 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.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -54,50 +60,71 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 
 @Singleton
-public class Move implements RestModifyView<ChangeResource, MoveInput> {
+public class Move extends RetryingRestModifyView<ChangeResource, MoveInput, ChangeInfo> {
+  private final PermissionBackend permissionBackend;
   private final Provider<ReviewDb> dbProvider;
   private final ChangeJson.Factory json;
   private final GitRepositoryManager repoManager;
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeMessagesUtil cmUtil;
-  private final BatchUpdate.Factory batchUpdateFactory;
   private final PatchSetUtil psUtil;
 
   @Inject
   Move(
+      PermissionBackend permissionBackend,
       Provider<ReviewDb> dbProvider,
       ChangeJson.Factory json,
       GitRepositoryManager repoManager,
       Provider<InternalChangeQuery> queryProvider,
       ChangeMessagesUtil cmUtil,
-      BatchUpdate.Factory batchUpdateFactory,
+      RetryHelper retryHelper,
       PatchSetUtil psUtil) {
+    super(retryHelper);
+    this.permissionBackend = permissionBackend;
     this.dbProvider = dbProvider;
     this.json = json;
     this.repoManager = repoManager;
     this.queryProvider = queryProvider;
     this.cmUtil = cmUtil;
-    this.batchUpdateFactory = batchUpdateFactory;
     this.psUtil = psUtil;
   }
 
   @Override
-  public ChangeInfo apply(ChangeResource req, MoveInput input)
-      throws RestApiException, OrmException, UpdateException {
-    ChangeControl control = req.getControl();
+  protected ChangeInfo applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, MoveInput input)
+      throws RestApiException, OrmException, UpdateException, PermissionBackendException {
+    Change change = rsrc.getChange();
+    Project.NameKey project = rsrc.getProject();
+    IdentifiedUser caller = rsrc.getUser();
     input.destinationBranch = RefNames.fullName(input.destinationBranch);
-    if (!control.canMoveTo(input.destinationBranch, dbProvider.get())) {
-      throw new AuthException("Move not permitted");
+
+    if (change.getStatus().isClosed()) {
+      throw new ResourceConflictException("Change is " + ChangeUtil.status(change));
+    }
+
+    Branch.NameKey newDest = new Branch.NameKey(project, input.destinationBranch);
+    if (change.getDest().equals(newDest)) {
+      throw new ResourceConflictException("Change is already destined for the specified branch");
+    }
+
+    // Move requires abandoning this change, and creating a new change.
+    try {
+      rsrc.permissions().database(dbProvider).check(ChangePermission.ABANDON);
+      permissionBackend
+          .user(caller)
+          .database(dbProvider)
+          .ref(newDest)
+          .check(RefPermission.CREATE_CHANGE);
+    } catch (AuthException denied) {
+      throw new AuthException("move not permitted", denied);
     }
 
     try (BatchUpdate u =
-        batchUpdateFactory.create(
-            dbProvider.get(), req.getChange().getProject(), control.getUser(), TimeUtil.nowTs())) {
-      u.addOp(req.getChange().getId(), new Op(input));
+        updateFactory.create(dbProvider.get(), project, caller, TimeUtil.nowTs())) {
+      u.addOp(change.getId(), new Op(input));
       u.execute();
     }
-
-    return json.noOptions().format(req.getChange());
+    return json.noOptions().format(project, rsrc.getId());
   }
 
   private class Op implements BatchUpdateOp {
@@ -115,7 +142,7 @@
         throws OrmException, ResourceConflictException, RepositoryNotFoundException, IOException {
       change = ctx.getChange();
       if (change.getStatus() != Status.NEW && change.getStatus() != Status.DRAFT) {
-        throw new ResourceConflictException("Change is " + status(change));
+        throw new ResourceConflictException("Change is " + ChangeUtil.status(change));
       }
 
       Project.NameKey projectKey = change.getProject();
@@ -182,8 +209,4 @@
       return true;
     }
   }
-
-  private static String status(Change change) {
-    return change != null ? change.getStatus().name().toLowerCase() : "deleted";
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Mute.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Mute.java
new file mode 100644
index 0000000..d14fec8
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Mute.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class Mute implements RestModifyView<ChangeResource, Mute.Input>, UiAction<ChangeResource> {
+  private static final Logger log = LoggerFactory.getLogger(Mute.class);
+
+  public static class Input {}
+
+  private final Provider<IdentifiedUser> self;
+  private final StarredChangesUtil stars;
+
+  @Inject
+  Mute(Provider<IdentifiedUser> self, StarredChangesUtil stars) {
+    this.self = self;
+    this.stars = stars;
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Mute")
+        .setTitle("Mute the change to unhighlight it in the dashboard")
+        .setVisible(!rsrc.isUserOwner() && isMuteable(rsrc.getChange()));
+  }
+
+  @Override
+  public Response<String> apply(ChangeResource rsrc, Input input) throws RestApiException {
+    try {
+      if (rsrc.isUserOwner() || isMuted(rsrc.getChange())) {
+        // early exit for own changes and already muted changes
+        return Response.ok("");
+      }
+      stars.mute(self.get().getAccountId(), rsrc.getProject(), rsrc.getChange());
+    } catch (OrmException e) {
+      throw new RestApiException("failed to mute change", e);
+    }
+    return Response.ok("");
+  }
+
+  private boolean isMuted(Change change) {
+    try {
+      return stars.isMutedBy(change, self.get().getAccountId());
+    } catch (OrmException e) {
+      log.error("failed to check muted star", e);
+    }
+    return false;
+  }
+
+  private boolean isMuteable(Change change) {
+    try {
+      return !isMuted(change) && !stars.isIgnoredBy(change.getId(), self.get().getAccountId());
+    } catch (OrmException e) {
+      log.error("failed to check ignored star", e);
+    }
+    return false;
+  }
+}
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 7cf62a0..581f2ba 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
@@ -34,6 +34,7 @@
 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;
@@ -43,6 +44,9 @@
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.ssh.NoSshInfo;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -50,13 +54,12 @@
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.Collections;
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -65,10 +68,11 @@
   private static final Logger log = LoggerFactory.getLogger(PatchSetInserter.class);
 
   public interface Factory {
-    PatchSetInserter create(ChangeControl ctl, PatchSet.Id psId, RevCommit commit);
+    PatchSetInserter create(ChangeControl ctl, PatchSet.Id psId, ObjectId commitId);
   }
 
   // Injected fields.
+  private final PermissionBackend permissionBackend;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final CommitValidators.Factory commitValidatorsFactory;
   private final ReplacePatchSetSender.Factory replacePatchSetFactory;
@@ -80,7 +84,7 @@
 
   // Assisted-injected fields.
   private final PatchSet.Id psId;
-  private final RevCommit commit;
+  private final ObjectId commitId;
   // Read prior to running the batch update, so must only be used during
   // updateRepo; updateChange and later must use the control from the
   // ChangeContext.
@@ -89,7 +93,7 @@
   // Fields exposed as setters.
   private String message;
   private String description;
-  private CommitValidators.Policy validatePolicy = CommitValidators.Policy.GERRIT;
+  private boolean validate = true;
   private boolean checkAddPatchSetPermission = true;
   private boolean draft;
   private List<String> groups = Collections.emptyList();
@@ -106,8 +110,9 @@
   private ChangeMessage changeMessage;
   private ReviewerSet oldReviewers;
 
-  @AssistedInject
+  @Inject
   public PatchSetInserter(
+      PermissionBackend permissionBackend,
       ApprovalsUtil approvalsUtil,
       ApprovalCopier approvalCopier,
       ChangeMessagesUtil cmUtil,
@@ -118,7 +123,8 @@
       RevisionCreated revisionCreated,
       @Assisted ChangeControl ctl,
       @Assisted PatchSet.Id psId,
-      @Assisted RevCommit commit) {
+      @Assisted ObjectId commitId) {
+    this.permissionBackend = permissionBackend;
     this.approvalsUtil = approvalsUtil;
     this.approvalCopier = approvalCopier;
     this.cmUtil = cmUtil;
@@ -130,7 +136,7 @@
 
     this.origCtl = ctl;
     this.psId = psId;
-    this.commit = commit;
+    this.commitId = commitId.copy();
   }
 
   public PatchSet.Id getPatchSetId() {
@@ -147,8 +153,8 @@
     return this;
   }
 
-  public PatchSetInserter setValidatePolicy(CommitValidators.Policy validate) {
-    this.validatePolicy = checkNotNull(validate);
+  public PatchSetInserter setValidate(boolean validate) {
+    this.validate = validate;
     return this;
   }
 
@@ -206,11 +212,10 @@
 
   @Override
   public void updateRepo(RepoContext ctx)
-      throws AuthException, ResourceConflictException, IOException, OrmException {
+      throws AuthException, ResourceConflictException, IOException, OrmException,
+          PermissionBackendException {
     validate(ctx);
-    ctx.addRefUpdate(
-        new ReceiveCommand(
-            ObjectId.zeroId(), commit, getPatchSetId().toRefName(), ReceiveCommand.Type.CREATE));
+    ctx.addRefUpdate(ObjectId.zeroId(), commitId, getPatchSetId().toRefName());
   }
 
   @Override
@@ -227,7 +232,7 @@
       throw new ResourceConflictException(
           String.format(
               "Cannot create new patch set of change %s because it is %s",
-              change.getId(), change.getStatus().name().toLowerCase()));
+              change.getId(), ChangeUtil.status(change)));
     }
 
     List<String> newGroups = groups;
@@ -243,7 +248,7 @@
             ctx.getRevWalk(),
             ctx.getUpdate(psId),
             psId,
-            commit,
+            commitId,
             draft,
             newGroups,
             null,
@@ -264,7 +269,8 @@
       changeMessage.setMessage(message);
     }
 
-    patchSetInfo = patchSetInfoFactory.get(ctx.getRevWalk(), commit, psId);
+    patchSetInfo =
+        patchSetInfoFactory.get(ctx.getRevWalk(), ctx.getRevWalk().parseCommit(commitId), psId);
     if (change.getStatus() != Change.Status.DRAFT && !allowClosed) {
       change.setStatus(Change.Status.NEW);
     }
@@ -302,29 +308,32 @@
   }
 
   private void validate(RepoContext ctx)
-      throws AuthException, ResourceConflictException, IOException, OrmException {
-    if (checkAddPatchSetPermission && !origCtl.canAddPatchSet(ctx.getDb())) {
-      throw new AuthException("cannot add patch set");
+      throws AuthException, ResourceConflictException, IOException, PermissionBackendException {
+    if (checkAddPatchSetPermission) {
+      permissionBackend
+          .user(ctx.getUser())
+          .database(ctx.getDb())
+          .change(origCtl.getNotes())
+          .check(ChangePermission.ADD_PATCH_SET);
     }
-    if (validatePolicy == CommitValidators.Policy.NONE) {
+    if (!validate) {
       return;
     }
 
     String refName = getPatchSetId().toRefName();
-    CommitReceivedEvent event =
+    try (CommitReceivedEvent event =
         new CommitReceivedEvent(
             new ReceiveCommand(
                 ObjectId.zeroId(),
-                commit.getId(),
+                commitId,
                 refName.substring(0, refName.lastIndexOf('/') + 1) + "new"),
             origCtl.getProjectControl().getProject(),
             origCtl.getRefControl().getRefName(),
-            commit,
-            ctx.getIdentifiedUser());
-
-    try {
+            ctx.getRevWalk().getObjectReader(),
+            commitId,
+            ctx.getIdentifiedUser())) {
       commitValidatorsFactory
-          .create(validatePolicy, origCtl.getRefControl(), new NoSshInfo(), ctx.getRepository())
+          .forGerritCommits(origCtl.getRefControl(), new NoSshInfo(), ctx.getRevWalk())
           .validate(event);
     } catch (CommitValidationException e) {
       throw new ResourceConflictException(e.getFullMessage());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostHashtags.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostHashtags.java
index 5aa41b1..0c8f010 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostHashtags.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostHashtags.java
@@ -19,10 +19,13 @@
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -30,26 +33,28 @@
 
 @Singleton
 public class PostHashtags
-    implements RestModifyView<ChangeResource, HashtagsInput>, UiAction<ChangeResource> {
+    extends RetryingRestModifyView<
+        ChangeResource, HashtagsInput, Response<ImmutableSortedSet<String>>>
+    implements UiAction<ChangeResource> {
   private final Provider<ReviewDb> db;
-  private final BatchUpdate.Factory batchUpdateFactory;
   private final SetHashtagsOp.Factory hashtagsFactory;
 
   @Inject
   PostHashtags(
-      Provider<ReviewDb> db,
-      BatchUpdate.Factory batchUpdateFactory,
-      SetHashtagsOp.Factory hashtagsFactory) {
+      Provider<ReviewDb> db, RetryHelper retryHelper, SetHashtagsOp.Factory hashtagsFactory) {
+    super(retryHelper);
     this.db = db;
-    this.batchUpdateFactory = batchUpdateFactory;
     this.hashtagsFactory = hashtagsFactory;
   }
 
   @Override
-  public Response<ImmutableSortedSet<String>> apply(ChangeResource req, HashtagsInput input)
-      throws RestApiException, UpdateException {
+  protected Response<ImmutableSortedSet<String>> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource req, HashtagsInput input)
+      throws RestApiException, UpdateException, PermissionBackendException {
+    req.permissions().check(ChangePermission.EDIT_HASHTAGS);
+
     try (BatchUpdate bu =
-        batchUpdateFactory.create(
+        updateFactory.create(
             db.get(), req.getChange().getProject(), req.getControl().getUser(), TimeUtil.nowTs())) {
       SetHashtagsOp op = hashtagsFactory.create(input);
       bu.addOp(req.getId(), op);
@@ -59,9 +64,9 @@
   }
 
   @Override
-  public UiAction.Description getDescription(ChangeResource resource) {
+  public UiAction.Description getDescription(ChangeResource rsrc) {
     return new UiAction.Description()
         .setLabel("Edit Hashtags")
-        .setVisible(resource.getControl().canEditHashtags());
+        .setVisible(rsrc.permissions().testOrFalse(ChangePermission.EDIT_HASHTAGS));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostPrivate.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostPrivate.java
new file mode 100644
index 0000000..771e669
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostPrivate.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2017 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.TimeUtil;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class PostPrivate
+    extends RetryingRestModifyView<ChangeResource, SetPrivateOp.Input, Response<String>>
+    implements UiAction<ChangeResource> {
+  private final ChangeMessagesUtil cmUtil;
+  private final Provider<ReviewDb> dbProvider;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  PostPrivate(
+      Provider<ReviewDb> dbProvider,
+      RetryHelper retryHelper,
+      ChangeMessagesUtil cmUtil,
+      PermissionBackend permissionBackend) {
+    super(retryHelper);
+    this.dbProvider = dbProvider;
+    this.cmUtil = cmUtil;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  public Response<String> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, SetPrivateOp.Input input)
+      throws RestApiException, UpdateException {
+    if (!canSetPrivate(rsrc)) {
+      throw new AuthException("not allowed to mark private");
+    }
+
+    if (rsrc.getChange().isPrivate()) {
+      return Response.ok("");
+    }
+
+    ChangeControl control = rsrc.getControl();
+    SetPrivateOp op = new SetPrivateOp(cmUtil, true, input);
+    try (BatchUpdate u =
+        updateFactory.create(
+            dbProvider.get(),
+            control.getProject().getNameKey(),
+            control.getUser(),
+            TimeUtil.nowTs())) {
+      u.addOp(control.getId(), op).execute();
+    }
+
+    return Response.created("");
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    Change change = rsrc.getChange();
+    return new UiAction.Description()
+        .setLabel("Mark private")
+        .setTitle("Mark change as private")
+        .setVisible(!change.isPrivate() && canSetPrivate(rsrc));
+  }
+
+  private boolean canSetPrivate(ChangeResource rsrc) {
+    PermissionBackend.WithUser user = permissionBackend.user(rsrc.getUser());
+    return user.testOrFalse(GlobalPermission.ADMINISTRATE_SERVER)
+        || (rsrc.isUserOwner() && rsrc.getChange().getStatus() != Change.Status.MERGED);
+  }
+}
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 76cc7e8..0f229f2 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
@@ -18,7 +18,9 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.groupingBy;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
@@ -27,7 +29,6 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
@@ -38,8 +39,6 @@
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -50,6 +49,7 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.client.Comment.Range;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -61,7 +61,6 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Account;
@@ -81,28 +80,40 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.OutputFormat;
 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.GerritServerConfig;
 import com.google.gerrit.server.extensions.events.CommentAdded;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.LabelPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.LabelVote;
+import com.google.gson.Gson;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -113,16 +124,20 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.OptionalInt;
 import java.util.Set;
+import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 @Singleton
-public class PostReview implements RestModifyView<RevisionResource, ReviewInput> {
+public class PostReview
+    extends RetryingRestModifyView<RevisionResource, ReviewInput, Response<ReviewResult>> {
   private static final Logger log = LoggerFactory.getLogger(PostReview.class);
+  private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
+  private static final int DEFAULT_ROBOT_COMMENT_SIZE_LIMIT_IN_BYTES = 1024 * 1024;
 
   private final Provider<ReviewDb> db;
-  private final BatchUpdate.Factory batchUpdateFactory;
   private final ChangesCollection changes;
   private final ChangeData.Factory changeDataFactory;
   private final ApprovalsUtil approvalsUtil;
@@ -136,11 +151,12 @@
   private final PostReviewers postReviewers;
   private final NotesMigration migration;
   private final NotifyUtil notifyUtil;
+  private final Config gerritConfig;
 
   @Inject
   PostReview(
       Provider<ReviewDb> db,
-      BatchUpdate.Factory batchUpdateFactory,
+      RetryHelper retryHelper,
       ChangesCollection changes,
       ChangeData.Factory changeDataFactory,
       ApprovalsUtil approvalsUtil,
@@ -153,9 +169,10 @@
       CommentAdded commentAdded,
       PostReviewers postReviewers,
       NotesMigration migration,
-      NotifyUtil notifyUtil) {
+      NotifyUtil notifyUtil,
+      @GerritServerConfig Config gerritConfig) {
+    super(retryHelper);
     this.db = db;
-    this.batchUpdateFactory = batchUpdateFactory;
     this.changes = changes;
     this.changeDataFactory = changeDataFactory;
     this.commentsUtil = commentsUtil;
@@ -169,16 +186,21 @@
     this.postReviewers = postReviewers;
     this.migration = migration;
     this.notifyUtil = notifyUtil;
+    this.gerritConfig = gerritConfig;
   }
 
   @Override
-  public Response<ReviewResult> apply(RevisionResource revision, ReviewInput input)
-      throws RestApiException, UpdateException, OrmException, IOException {
-    return apply(revision, input, TimeUtil.nowTs());
+  protected Response<ReviewResult> applyImpl(
+      BatchUpdate.Factory updateFactory, RevisionResource revision, ReviewInput input)
+      throws RestApiException, UpdateException, OrmException, IOException,
+          PermissionBackendException {
+    return apply(updateFactory, revision, input, TimeUtil.nowTs());
   }
 
-  public Response<ReviewResult> apply(RevisionResource revision, ReviewInput input, Timestamp ts)
-      throws RestApiException, UpdateException, OrmException, IOException {
+  public Response<ReviewResult> apply(
+      BatchUpdate.Factory updateFactory, RevisionResource revision, ReviewInput input, Timestamp ts)
+      throws RestApiException, UpdateException, OrmException, IOException,
+          PermissionBackendException {
     // Respect timestamp, but truncate at change created-on time.
     ts = Ordering.natural().max(ts, revision.getChange().getCreatedOn());
     if (revision.getEdit().isPresent()) {
@@ -244,8 +266,7 @@
     output.labels = input.labels;
 
     try (BatchUpdate bu =
-        batchUpdateFactory.create(
-            db.get(), revision.getChange().getProject(), revision.getUser(), ts)) {
+        updateFactory.create(db.get(), revision.getChange().getProject(), revision.getUser(), ts)) {
       Account.Id id = revision.getUser().getAccountId();
       boolean ccOrReviewer = false;
       if (input.labels != null && !input.labels.isEmpty()) {
@@ -293,7 +314,7 @@
 
       bu.addOp(
           revision.getChange().getId(),
-          new Op(revision.getPatchSet().getId(), input, accountsToNotify, reviewerResults));
+          new Op(revision.getPatchSet().getId(), input, accountsToNotify));
       bu.execute();
 
       for (PostReviewers.Addition reviewerResult : reviewerResults) {
@@ -309,22 +330,32 @@
   private void emailReviewers(
       Change change,
       List<PostReviewers.Addition> reviewerAdditions,
-      NotifyHandling notify,
+      @Nullable NotifyHandling notify,
       ListMultimap<RecipientType, Account.Id> accountsToNotify) {
     List<Account.Id> to = new ArrayList<>();
     List<Account.Id> cc = new ArrayList<>();
+    List<Address> toByEmail = new ArrayList<>();
+    List<Address> ccByEmail = new ArrayList<>();
     for (PostReviewers.Addition addition : reviewerAdditions) {
-      if (addition.op.state == ReviewerState.REVIEWER) {
-        to.addAll(addition.op.reviewers.keySet());
-      } else if (addition.op.state == ReviewerState.CC) {
-        cc.addAll(addition.op.reviewers.keySet());
+      if (addition.state == ReviewerState.REVIEWER) {
+        to.addAll(addition.reviewers);
+        toByEmail.addAll(addition.reviewersByEmail);
+      } else if (addition.state == ReviewerState.CC) {
+        cc.addAll(addition.reviewers);
+        ccByEmail.addAll(addition.reviewersByEmail);
       }
     }
-    postReviewers.emailReviewers(change, to, cc, notify, accountsToNotify);
+    if (reviewerAdditions.size() > 0) {
+      reviewerAdditions
+          .get(0)
+          .op
+          .emailReviewers(change, to, cc, toByEmail, ccByEmail, notify, accountsToNotify);
+    }
   }
 
   private RevisionResource onBehalfOf(RevisionResource rev, ReviewInput in)
-      throws BadRequestException, AuthException, UnprocessableEntityException, OrmException {
+      throws BadRequestException, AuthException, UnprocessableEntityException, OrmException,
+          PermissionBackendException {
     if (in.labels == null || in.labels.isEmpty()) {
       throw new AuthException(
           String.format("label required to post review on behalf of \"%s\"", in.onBehalfOf));
@@ -336,11 +367,13 @@
       throw new AuthException("not allowed to modify other user's drafts");
     }
 
-    ChangeControl caller = rev.getControl();
+    CurrentUser caller = rev.getUser();
+    PermissionBackend.ForChange perm = rev.permissions().database(db);
+    LabelTypes labelTypes = rev.getControl().getLabelTypes();
     Iterator<Map.Entry<String, Short>> itr = in.labels.entrySet().iterator();
     while (itr.hasNext()) {
       Map.Entry<String, Short> ent = itr.next();
-      LabelType type = caller.getLabelTypes().byLabel(ent.getKey());
+      LabelType type = labelTypes.byLabel(ent.getKey());
       if (type == null && in.strictLabels) {
         throw new BadRequestException(
             String.format("label \"%s\" is not a configured label", ent.getKey()));
@@ -349,16 +382,15 @@
         continue;
       }
 
-      if (caller.getUser().isInternalUser()) {
-        continue;
-      }
-
-      PermissionRange r = caller.getRange(Permission.forLabelAs(type.getName()));
-      if (r == null || r.isEmpty() || !r.contains(ent.getValue())) {
-        throw new AuthException(
-            String.format(
-                "not permitted to modify label \"%s\" on behalf of \"%s\"",
-                ent.getKey(), in.onBehalfOf));
+      if (!caller.isInternalUser()) {
+        try {
+          perm.check(new LabelPermission.WithValue(ON_BEHALF_OF, type, ent.getValue()));
+        } catch (AuthException e) {
+          throw new AuthException(
+              String.format(
+                  "not permitted to modify label \"%s\" on behalf of \"%s\"",
+                  type.getName(), in.onBehalfOf));
+        }
       }
     }
     if (in.labels.isEmpty()) {
@@ -366,25 +398,26 @@
           String.format("label required to post review on behalf of \"%s\"", in.onBehalfOf));
     }
 
-    ChangeControl target =
-        caller.forUser(accounts.parseOnBehalfOf(caller.getUser(), in.onBehalfOf));
-    if (!target.getRefControl().isVisible()) {
+    IdentifiedUser reviewer = accounts.parseOnBehalfOf(caller, in.onBehalfOf);
+    try {
+      perm.user(reviewer).check(ChangePermission.READ);
+    } catch (AuthException e) {
       throw new UnprocessableEntityException(
-          String.format(
-              "on_behalf_of account %s cannot see destination ref",
-              target.getUser().getAccountId()));
+          String.format("on_behalf_of account %s cannot see change", reviewer.getAccountId()));
     }
-    return new RevisionResource(changes.parse(target), rev.getPatchSet());
+
+    ChangeControl ctl = rev.getControl().forUser(reviewer);
+    return new RevisionResource(changes.parse(ctl), rev.getPatchSet());
   }
 
-  private void checkLabels(RevisionResource revision, boolean strict, Map<String, Short> labels)
-      throws BadRequestException, AuthException {
-    ChangeControl ctl = revision.getControl();
+  private void checkLabels(RevisionResource rsrc, boolean strict, Map<String, Short> labels)
+      throws BadRequestException, AuthException, PermissionBackendException {
+    LabelTypes types = rsrc.getControl().getLabelTypes();
+    PermissionBackend.ForChange perm = rsrc.permissions();
     Iterator<Map.Entry<String, Short>> itr = labels.entrySet().iterator();
     while (itr.hasNext()) {
       Map.Entry<String, Short> ent = itr.next();
-
-      LabelType lt = revision.getControl().getLabelTypes().byLabel(ent.getKey());
+      LabelType lt = types.byLabel(ent.getKey());
       if (lt == null) {
         if (strict) {
           throw new BadRequestException(
@@ -409,23 +442,21 @@
         continue;
       }
 
-      String name = lt.getName();
-      PermissionRange range = ctl.getRange(Permission.forLabel(name));
-      if (range == null || !range.contains(ent.getValue())) {
+      short val = ent.getValue();
+      try {
+        perm.check(new LabelPermission.WithValue(lt, val));
+      } catch (AuthException e) {
         if (strict) {
           throw new AuthException(
-              String.format(
-                  "Applying label \"%s\": %d is restricted", ent.getKey(), ent.getValue()));
-        } else if (range == null || range.isEmpty()) {
-          ent.setValue((short) 0);
-        } else {
-          ent.setValue((short) range.squash(ent.getValue()));
+              String.format("Applying label \"%s\": %d is restricted", lt.getName(), val));
         }
+        ent.setValue(perm.squashThenCheck(lt, val));
       }
     }
   }
 
-  private <T extends CommentInput> void cleanUpComments(Map<String, List<T>> commentsPerPath) {
+  private static <T extends CommentInput> void cleanUpComments(
+      Map<String, List<T>> commentsPerPath) {
     Iterator<List<T>> mapValueIterator = commentsPerPath.values().iterator();
     while (mapValueIterator.hasNext()) {
       List<T> comments = mapValueIterator.next();
@@ -441,7 +472,7 @@
     }
   }
 
-  private <T extends CommentInput> void cleanUpComments(List<T> comments) {
+  private static <T extends CommentInput> void cleanUpComments(List<T> comments) {
     Iterator<T> commentsIterator = comments.iterator();
     while (commentsIterator.hasNext()) {
       T comment = commentsIterator.next();
@@ -480,7 +511,7 @@
     return new HashSet<>(changeData.filePaths(revision.getPatchSet()));
   }
 
-  private void ensurePathRefersToAvailableOrMagicFile(
+  private static void ensurePathRefersToAvailableOrMagicFile(
       String path, Set<String> availableFilePaths, PatchSet.Id patchSetId)
       throws BadRequestException {
     if (!availableFilePaths.contains(path) && !Patch.isMagic(path)) {
@@ -489,14 +520,15 @@
     }
   }
 
-  private void ensureLineIsNonNegative(Integer line, String path) throws BadRequestException {
+  private static void ensureLineIsNonNegative(Integer line, String path)
+      throws BadRequestException {
     if (line != null && line < 0) {
       throw new BadRequestException(
           String.format("negative line number %d not allowed on %s", line, path));
     }
   }
 
-  private <T extends CommentInput> void ensureCommentNotOnMagicFilesOfAutoMerge(
+  private static <T extends CommentInput> void ensureCommentNotOnMagicFilesOfAutoMerge(
       String path, T comment) throws BadRequestException {
     if (Patch.isMagic(path) && comment.side == Side.PARENT && comment.parent == null) {
       throw new BadRequestException(String.format("cannot comment on %s on auto-merge", path));
@@ -510,6 +542,7 @@
     for (Map.Entry<String, List<RobotCommentInput>> e : in.entrySet()) {
       String commentPath = e.getKey();
       for (RobotCommentInput c : e.getValue()) {
+        ensureSizeOfJsonInputIsWithinBounds(c);
         ensureRobotIdIsSet(c.robotId, commentPath);
         ensureRobotRunIdIsSet(c.robotRunId, commentPath);
         ensureFixSuggestionsAreAddable(c.fixSuggestions, commentPath);
@@ -518,14 +551,41 @@
     checkComments(revision, in);
   }
 
-  private void ensureRobotIdIsSet(String robotId, String commentPath) throws BadRequestException {
+  private void ensureSizeOfJsonInputIsWithinBounds(RobotCommentInput robotCommentInput)
+      throws BadRequestException {
+    OptionalInt robotCommentSizeLimit = getRobotCommentSizeLimit();
+    if (robotCommentSizeLimit.isPresent()) {
+      int sizeLimit = robotCommentSizeLimit.getAsInt();
+      byte[] robotCommentBytes = GSON.toJson(robotCommentInput).getBytes(StandardCharsets.UTF_8);
+      int robotCommentSize = robotCommentBytes.length;
+      if (robotCommentSize > sizeLimit) {
+        throw new BadRequestException(
+            String.format(
+                "Size %d (bytes) of robot comment is greater than limit %d (bytes)",
+                robotCommentSize, sizeLimit));
+      }
+    }
+  }
+
+  private OptionalInt getRobotCommentSizeLimit() {
+    int robotCommentSizeLimit =
+        gerritConfig.getInt(
+            "change", "robotCommentSizeLimit", DEFAULT_ROBOT_COMMENT_SIZE_LIMIT_IN_BYTES);
+    if (robotCommentSizeLimit <= 0) {
+      return OptionalInt.empty();
+    }
+    return OptionalInt.of(robotCommentSizeLimit);
+  }
+
+  private static void ensureRobotIdIsSet(String robotId, String commentPath)
+      throws BadRequestException {
     if (robotId == null) {
       throw new BadRequestException(
           String.format("robotId is missing for robot comment on %s", commentPath));
     }
   }
 
-  private void ensureRobotRunIdIsSet(String robotRunId, String commentPath)
+  private static void ensureRobotRunIdIsSet(String robotRunId, String commentPath)
       throws BadRequestException {
     if (robotRunId == null) {
       throw new BadRequestException(
@@ -533,7 +593,7 @@
     }
   }
 
-  private void ensureFixSuggestionsAreAddable(
+  private static void ensureFixSuggestionsAreAddable(
       List<FixSuggestionInfo> fixSuggestionInfos, String commentPath) throws BadRequestException {
     if (fixSuggestionInfos == null) {
       return;
@@ -545,7 +605,7 @@
     }
   }
 
-  private void ensureDescriptionIsSet(String commentPath, String description)
+  private static void ensureDescriptionIsSet(String commentPath, String description)
       throws BadRequestException {
     if (description == null) {
       throw new BadRequestException(
@@ -555,20 +615,25 @@
     }
   }
 
-  private void ensureFixReplacementsAreAddable(
+  private static void ensureFixReplacementsAreAddable(
       String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
     ensureReplacementsArePresent(commentPath, fixReplacementInfos);
 
     for (FixReplacementInfo fixReplacementInfo : fixReplacementInfos) {
       ensureReplacementPathIsSet(commentPath, fixReplacementInfo.path);
-      ensureReplacementPathRefersToFileOfComment(commentPath, fixReplacementInfo.path);
       ensureRangeIsSet(commentPath, fixReplacementInfo.range);
       ensureRangeIsValid(commentPath, fixReplacementInfo.range);
       ensureReplacementStringIsSet(commentPath, fixReplacementInfo.replacement);
     }
+
+    Map<String, List<FixReplacementInfo>> replacementsPerFilePath =
+        fixReplacementInfos.stream().collect(groupingBy(fixReplacement -> fixReplacement.path));
+    for (List<FixReplacementInfo> sameFileReplacements : replacementsPerFilePath.values()) {
+      ensureRangesDoNotOverlap(commentPath, sameFileReplacements);
+    }
   }
 
-  private void ensureReplacementsArePresent(
+  private static void ensureReplacementsArePresent(
       String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
     if (fixReplacementInfos == null || fixReplacementInfos.isEmpty()) {
       throw new BadRequestException(
@@ -579,7 +644,7 @@
     }
   }
 
-  private void ensureReplacementPathIsSet(String commentPath, String replacementPath)
+  private static void ensureReplacementPathIsSet(String commentPath, String replacementPath)
       throws BadRequestException {
     if (replacementPath == null) {
       throw new BadRequestException(
@@ -589,20 +654,7 @@
     }
   }
 
-  private void ensureReplacementPathRefersToFileOfComment(
-      String commentPath, String replacementPath) throws BadRequestException {
-    if (!Objects.equals(commentPath, replacementPath)) {
-      throw new BadRequestException(
-          String.format(
-              "Replacements may only be "
-                  + "specified for the file %s on which the robot comment was added",
-              commentPath));
-    }
-  }
-
-  private void ensureRangeIsSet(
-      String commentPath, com.google.gerrit.extensions.client.Comment.Range range)
-      throws BadRequestException {
+  private static void ensureRangeIsSet(String commentPath, Range range) throws BadRequestException {
     if (range == null) {
       throw new BadRequestException(
           String.format(
@@ -610,8 +662,7 @@
     }
   }
 
-  private void ensureRangeIsValid(
-      String commentPath, com.google.gerrit.extensions.client.Comment.Range range)
+  private static void ensureRangeIsValid(String commentPath, Range range)
       throws BadRequestException {
     if (range == null) {
       return;
@@ -628,7 +679,7 @@
     }
   }
 
-  private void ensureReplacementStringIsSet(String commentPath, String replacement)
+  private static void ensureReplacementStringIsSet(String commentPath, String replacement)
       throws BadRequestException {
     if (replacement == null) {
       throw new BadRequestException(
@@ -639,6 +690,28 @@
     }
   }
 
+  private static void ensureRangesDoNotOverlap(
+      String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
+    List<Range> sortedRanges =
+        fixReplacementInfos
+            .stream()
+            .map(fixReplacementInfo -> fixReplacementInfo.range)
+            .sorted()
+            .collect(toList());
+
+    int previousEndLine = 0;
+    int previousOffset = -1;
+    for (Range range : sortedRanges) {
+      if (range.startLine < previousEndLine
+          || (range.startLine == previousEndLine && range.startCharacter < previousOffset)) {
+        throw new BadRequestException(
+            String.format("Replacements overlap for the robot comment on %s", commentPath));
+      }
+      previousEndLine = range.endLine;
+      previousOffset = range.endCharacter;
+    }
+  }
+
   /** Used to compare Comments with CommentInput comments. */
   @AutoValue
   abstract static class CommentSetEntry {
@@ -682,7 +755,6 @@
     private final PatchSet.Id psId;
     private final ReviewInput in;
     private final ListMultimap<RecipientType, Account.Id> accountsToNotify;
-    private final List<PostReviewers.Addition> reviewerResults;
 
     private IdentifiedUser user;
     private ChangeNotes notes;
@@ -696,12 +768,10 @@
     private Op(
         PatchSet.Id psId,
         ReviewInput in,
-        ListMultimap<RecipientType, Account.Id> accountsToNotify,
-        List<PostReviewers.Addition> reviewerResults) {
+        ListMultimap<RecipientType, Account.Id> accountsToNotify) {
       this.psId = psId;
       this.in = in;
       this.accountsToNotify = checkNotNull(accountsToNotify);
-      this.reviewerResults = reviewerResults;
     }
 
     @Override
@@ -801,12 +871,9 @@
           toDel.addAll(drafts.values());
           break;
         case PUBLISH:
-          for (Comment e : drafts.values()) {
-            toPublish.add(publishComment(ctx, e, ps));
-          }
-          break;
         case PUBLISH_ALL_REVISIONS:
-          publishAllRevisions(ctx, drafts, toPublish);
+          commentsUtil.publish(ctx, psId, drafts.values(), in.tag);
+          comments.addAll(drafts.values());
           break;
       }
       ChangeUpdate u = ctx.getUpdate(psId);
@@ -939,37 +1006,6 @@
       return labels;
     }
 
-    private Comment publishComment(ChangeContext ctx, 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;
-    }
-
-    private void publishAllRevisions(
-        ChangeContext ctx, Map<String, Comment> drafts, List<Comment> ups) throws OrmException {
-      boolean needOtherPatchSets = false;
-      for (Comment c : drafts.values()) {
-        if (c.key.patchSetId != psId.get()) {
-          needOtherPatchSets = true;
-          break;
-        }
-      }
-      Map<PatchSet.Id, PatchSet> patchSets =
-          needOtherPatchSets
-              ? psUtil.byChangeAsMap(ctx.getDb(), ctx.getNotes())
-              : ImmutableMap.of(psId, ps);
-      for (Comment e : drafts.values()) {
-        ups.add(
-            publishComment(
-                ctx, e, patchSets.get(new PatchSet.Id(ctx.getChange().getId(), e.key.patchSetId))));
-      }
-    }
-
     private Map<String, Short> getAllApprovals(
         LabelTypes labelTypes, Map<String, Short> current, Map<String, Short> input) {
       Map<String, Short> allApprovals = new HashMap<>();
@@ -1005,16 +1041,6 @@
       if (ctx.getAccountId().equals(ctx.getChange().getOwner())) {
         return true;
       }
-      for (PostReviewers.Addition addition : reviewerResults) {
-        if (addition.op.addedReviewers == null) {
-          continue;
-        }
-        for (PatchSetApproval psa : addition.op.addedReviewers) {
-          if (psa.getAccountId().equals(ctx.getAccountId())) {
-            return true;
-          }
-        }
-      }
       ChangeData cd = changeDataFactory.create(db.get(), ctx.getControl());
       ReviewerSet reviewers = cd.reviewers();
       if (reviewers.byState(REVIEWER).contains(ctx.getAccountId())) {
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 7031d51..c7b0031 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
@@ -14,15 +14,16 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.errors.NoSuchGroupException;
@@ -32,36 +33,39 @@
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 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.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 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;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.extensions.events.ReviewerAdded;
 import com.google.gerrit.server.group.GroupsCollection;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.mail.send.AddReviewerSender;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -69,85 +73,82 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.text.MessageFormat;
-import java.util.ArrayList;
 import java.util.Collection;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
+import java.util.HashSet;
 import java.util.Set;
 import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 @Singleton
-public class PostReviewers implements RestModifyView<ChangeResource, AddReviewerInput> {
-  private static final Logger log = LoggerFactory.getLogger(PostReviewers.class);
+public class PostReviewers
+    extends RetryingRestModifyView<ChangeResource, AddReviewerInput, AddReviewerResult> {
 
   public static final int DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK = 10;
   public static final int DEFAULT_MAX_REVIEWERS = 20;
 
   private final AccountsCollection accounts;
   private final ReviewerResource.Factory reviewerFactory;
-  private final ApprovalsUtil approvalsUtil;
-  private final PatchSetUtil psUtil;
-  private final AddReviewerSender.Factory addReviewerSenderFactory;
+  private final PermissionBackend permissionBackend;
+
   private final GroupsCollection groupsCollection;
   private final GroupMembers.Factory groupMembersFactory;
   private final AccountLoader.Factory accountLoaderFactory;
   private final Provider<ReviewDb> dbProvider;
-  private final BatchUpdate.Factory batchUpdateFactory;
-  private final Provider<IdentifiedUser> user;
+  private final ChangeData.Factory changeDataFactory;
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
   private final Config cfg;
   private final ReviewerJson json;
-  private final ReviewerAdded reviewerAdded;
   private final NotesMigration migration;
-  private final AccountCache accountCache;
   private final NotifyUtil notifyUtil;
+  private final ProjectCache projectCache;
+  private final Provider<AnonymousUser> anonymousProvider;
+  private final PostReviewersOp.Factory postReviewersOpFactory;
+  private final OutgoingEmailValidator validator;
 
   @Inject
   PostReviewers(
       AccountsCollection accounts,
       ReviewerResource.Factory reviewerFactory,
-      ApprovalsUtil approvalsUtil,
-      PatchSetUtil psUtil,
-      AddReviewerSender.Factory addReviewerSenderFactory,
+      PermissionBackend permissionBackend,
       GroupsCollection groupsCollection,
       GroupMembers.Factory groupMembersFactory,
       AccountLoader.Factory accountLoaderFactory,
       Provider<ReviewDb> db,
-      BatchUpdate.Factory batchUpdateFactory,
-      Provider<IdentifiedUser> user,
+      ChangeData.Factory changeDataFactory,
+      RetryHelper retryHelper,
       IdentifiedUser.GenericFactory identifiedUserFactory,
       @GerritServerConfig Config cfg,
       ReviewerJson json,
-      ReviewerAdded reviewerAdded,
       NotesMigration migration,
-      AccountCache accountCache,
-      NotifyUtil notifyUtil) {
+      NotifyUtil notifyUtil,
+      ProjectCache projectCache,
+      Provider<AnonymousUser> anonymousProvider,
+      PostReviewersOp.Factory postReviewersOpFactory,
+      OutgoingEmailValidator validator) {
+    super(retryHelper);
     this.accounts = accounts;
     this.reviewerFactory = reviewerFactory;
-    this.approvalsUtil = approvalsUtil;
-    this.psUtil = psUtil;
-    this.addReviewerSenderFactory = addReviewerSenderFactory;
+    this.permissionBackend = permissionBackend;
     this.groupsCollection = groupsCollection;
     this.groupMembersFactory = groupMembersFactory;
     this.accountLoaderFactory = accountLoaderFactory;
     this.dbProvider = db;
-    this.batchUpdateFactory = batchUpdateFactory;
-    this.user = user;
+    this.changeDataFactory = changeDataFactory;
     this.identifiedUserFactory = identifiedUserFactory;
     this.cfg = cfg;
     this.json = json;
-    this.reviewerAdded = reviewerAdded;
     this.migration = migration;
-    this.accountCache = accountCache;
     this.notifyUtil = notifyUtil;
+    this.projectCache = projectCache;
+    this.anonymousProvider = anonymousProvider;
+    this.postReviewersOpFactory = postReviewersOpFactory;
+    this.validator = validator;
   }
 
   @Override
-  public AddReviewerResult apply(ChangeResource rsrc, AddReviewerInput input)
-      throws IOException, OrmException, RestApiException, UpdateException {
+  protected AddReviewerResult applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, AddReviewerInput input)
+      throws IOException, OrmException, RestApiException, UpdateException,
+          PermissionBackendException {
     if (input.reviewer == null) {
       throw new BadRequestException("missing reviewer field");
     }
@@ -157,7 +158,7 @@
       return addition.result;
     }
     try (BatchUpdate bu =
-        batchUpdateFactory.create(
+        updateFactory.create(
             dbProvider.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       Change.Id id = rsrc.getChange().getId();
       bu.addOp(id, addition.op);
@@ -169,75 +170,120 @@
 
   public Addition prepareApplication(
       ChangeResource rsrc, AddReviewerInput input, boolean allowGroup)
-      throws OrmException, RestApiException, IOException {
-    Account.Id accountId;
+      throws OrmException, IOException, PermissionBackendException {
+    String reviewer = input.reviewer;
+    ReviewerState state = input.state();
+    NotifyHandling notify = input.notify;
+    ListMultimap<RecipientType, Account.Id> accountsToNotify = null;
     try {
-      accountId = accounts.parse(input.reviewer).getAccountId();
-    } catch (UnprocessableEntityException e) {
-      if (allowGroup) {
-        try {
-          return putGroup(rsrc, input);
-        } catch (UnprocessableEntityException e2) {
-          throw new UnprocessableEntityException(
-              MessageFormat.format(
-                  ChangeMessages.get().reviewerNotFoundUserOrGroup, input.reviewer));
-        }
-      }
-      throw new UnprocessableEntityException(
-          MessageFormat.format(ChangeMessages.get().reviewerNotFoundUser, input.reviewer));
+      accountsToNotify = notifyUtil.resolveAccounts(input.notifyDetails);
+    } catch (BadRequestException e) {
+      return fail(reviewer, e.getMessage());
     }
-    return putAccount(
-        input.reviewer,
-        reviewerFactory.create(rsrc, accountId),
-        input.state(),
-        input.notify,
-        notifyUtil.resolveAccounts(input.notifyDetails));
+    boolean confirmed = input.confirmed();
+    boolean allowByEmail = projectCache.checkedGet(rsrc.getProject()).isEnableReviewerByEmail();
+
+    Addition byAccountId =
+        addByAccountId(reviewer, rsrc, state, notify, accountsToNotify, allowGroup, allowByEmail);
+    if (byAccountId != null) {
+      return byAccountId;
+    }
+
+    Addition wholeGroup =
+        addWholeGroup(
+            reviewer, rsrc, state, notify, accountsToNotify, confirmed, allowGroup, allowByEmail);
+    if (wholeGroup != null) {
+      return wholeGroup;
+    }
+
+    return addByEmail(reviewer, rsrc, state, notify, accountsToNotify);
   }
 
   Addition ccCurrentUser(CurrentUser user, RevisionResource revision) {
     return new Addition(
         user.getUserName(),
         revision.getChangeResource(),
-        ImmutableMap.of(user.getAccountId(), revision.getControl()),
+        ImmutableSet.of(user.getAccountId()),
+        null,
         CC,
         NotifyHandling.NONE,
         ImmutableListMultimap.of());
   }
 
-  private Addition putAccount(
+  @Nullable
+  private Addition addByAccountId(
       String reviewer,
-      ReviewerResource rsrc,
+      ChangeResource rsrc,
       ReviewerState state,
       NotifyHandling notify,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify)
-      throws UnprocessableEntityException {
-    Account member = rsrc.getReviewerUser().getAccount();
-    ChangeControl control = rsrc.getReviewerControl();
-    if (isValidReviewer(member, control)) {
+      ListMultimap<RecipientType, Account.Id> accountsToNotify,
+      boolean allowGroup,
+      boolean allowByEmail)
+      throws OrmException, PermissionBackendException {
+    Account.Id accountId = null;
+    try {
+      accountId = accounts.parse(reviewer).getAccountId();
+    } catch (UnprocessableEntityException | AuthException e) {
+      // AuthException won't occur since the user is authenticated at this point.
+      if (!allowGroup && !allowByEmail) {
+        // Only return failure if we aren't going to try other interpretations.
+        return fail(
+            reviewer, MessageFormat.format(ChangeMessages.get().reviewerNotFoundUser, reviewer));
+      }
+      return null;
+    }
+
+    ReviewerResource rrsrc = reviewerFactory.create(rsrc, accountId);
+    Account member = rrsrc.getReviewerUser().getAccount();
+    PermissionBackend.ForRef perm =
+        permissionBackend.user(rrsrc.getReviewerUser()).ref(rrsrc.getChange().getDest());
+    if (isValidReviewer(member, perm)) {
       return new Addition(
-          reviewer,
-          rsrc.getChangeResource(),
-          ImmutableMap.of(member.getId(), control),
-          state,
-          notify,
-          accountsToNotify);
+          reviewer, rsrc, ImmutableSet.of(member.getId()), null, state, notify, accountsToNotify);
     }
-    if (member.isActive()) {
-      throw new UnprocessableEntityException(String.format("Change not visible to %s", reviewer));
+    if (!member.isActive()) {
+      if (allowByEmail && state == CC) {
+        return null;
+      }
+      return fail(reviewer, MessageFormat.format(ChangeMessages.get().reviewerInactive, reviewer));
     }
-    throw new UnprocessableEntityException(String.format("Account of %s is inactive.", reviewer));
+    return fail(
+        reviewer, MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, reviewer));
   }
 
-  private Addition putGroup(ChangeResource rsrc, AddReviewerInput input)
-      throws RestApiException, OrmException, IOException {
-    GroupDescription.Basic group = groupsCollection.parseInternal(input.reviewer);
-    if (!isLegalReviewerGroup(group.getGroupUUID())) {
-      return fail(
-          input.reviewer,
-          MessageFormat.format(ChangeMessages.get().groupIsNotAllowed, group.getName()));
+  @Nullable
+  private Addition addWholeGroup(
+      String reviewer,
+      ChangeResource rsrc,
+      ReviewerState state,
+      NotifyHandling notify,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify,
+      boolean confirmed,
+      boolean allowGroup,
+      boolean allowByEmail)
+      throws OrmException, IOException, PermissionBackendException {
+    if (!allowGroup) {
+      return null;
     }
 
-    Map<Account.Id, ChangeControl> reviewers = new HashMap<>();
+    GroupDescription.Basic group = null;
+    try {
+      group = groupsCollection.parseInternal(reviewer);
+    } catch (UnprocessableEntityException e) {
+      if (!allowByEmail) {
+        return fail(
+            reviewer,
+            MessageFormat.format(ChangeMessages.get().reviewerNotFoundUserOrGroup, reviewer));
+      }
+      return null;
+    }
+
+    if (!isLegalReviewerGroup(group.getGroupUUID())) {
+      return fail(
+          reviewer, MessageFormat.format(ChangeMessages.get().groupIsNotAllowed, group.getName()));
+    }
+
+    Set<Account.Id> reviewers = new HashSet<>();
     ChangeControl control = rsrc.getControl();
     Set<Account> members;
     try {
@@ -246,9 +292,11 @@
               .create(control.getUser())
               .listAccounts(group.getGroupUUID(), control.getProject().getNameKey());
     } catch (NoSuchGroupException e) {
-      throw new UnprocessableEntityException(e.getMessage());
+      return fail(
+          reviewer,
+          MessageFormat.format(ChangeMessages.get().reviewerNotFoundUserOrGroup, group.getName()));
     } catch (NoSuchProjectException e) {
-      throw new BadRequestException(e.getMessage());
+      return fail(reviewer, e.getMessage());
     }
 
     // if maxAllowed is set to 0, it is allowed to add any number of
@@ -256,44 +304,69 @@
     int maxAllowed = cfg.getInt("addreviewer", "maxAllowed", DEFAULT_MAX_REVIEWERS);
     if (maxAllowed > 0 && members.size() > maxAllowed) {
       return fail(
-          input.reviewer,
+          reviewer,
           MessageFormat.format(ChangeMessages.get().groupHasTooManyMembers, group.getName()));
     }
 
     // if maxWithoutCheck is set to 0, we never ask for confirmation
     int maxWithoutConfirmation =
         cfg.getInt("addreviewer", "maxWithoutConfirmation", DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
-    if (!input.confirmed()
-        && maxWithoutConfirmation > 0
-        && members.size() > maxWithoutConfirmation) {
+    if (!confirmed && maxWithoutConfirmation > 0 && members.size() > maxWithoutConfirmation) {
       return fail(
-          input.reviewer,
+          reviewer,
           true,
           MessageFormat.format(
               ChangeMessages.get().groupManyMembersConfirmation, group.getName(), members.size()));
     }
 
+    PermissionBackend.ForRef perm =
+        permissionBackend.user(rsrc.getUser()).ref(rsrc.getChange().getDest());
     for (Account member : members) {
-      if (isValidReviewer(member, control)) {
-        reviewers.put(member.getId(), control);
+      if (isValidReviewer(member, perm)) {
+        reviewers.add(member.getId());
       }
     }
 
-    return new Addition(
-        input.reviewer,
-        rsrc,
-        reviewers,
-        input.state(),
-        input.notify,
-        notifyUtil.resolveAccounts(input.notifyDetails));
+    return new Addition(reviewer, rsrc, reviewers, null, state, notify, accountsToNotify);
   }
 
-  private boolean isValidReviewer(Account member, ChangeControl control) {
+  @Nullable
+  private Addition addByEmail(
+      String reviewer,
+      ChangeResource rsrc,
+      ReviewerState state,
+      NotifyHandling notify,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify)
+      throws OrmException {
+    if (!rsrc.getControl().forUser(anonymousProvider.get()).isVisible(dbProvider.get())) {
+      return fail(
+          reviewer, MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, reviewer));
+    }
+    if (!migration.readChanges()) {
+      // addByEmail depends on NoteDb.
+      return fail(
+          reviewer, MessageFormat.format(ChangeMessages.get().reviewerNotFoundUser, reviewer));
+    }
+    Address adr = Address.tryParse(reviewer);
+    if (adr == null || !validator.isValid(adr.getEmail())) {
+      return fail(reviewer, MessageFormat.format(ChangeMessages.get().reviewerInvalid, reviewer));
+    }
+    return new Addition(
+        reviewer, rsrc, null, ImmutableList.of(adr), state, notify, accountsToNotify);
+  }
+
+  private boolean isValidReviewer(Account member, PermissionBackend.ForRef perm)
+      throws PermissionBackendException {
     if (member.isActive()) {
       IdentifiedUser user = identifiedUserFactory.create(member.getId());
       // Does not account for draft status as a user might want to let a
       // reviewer see a draft.
-      return control.forUser(user).isRefVisible();
+      try {
+        perm.user(user).check(RefPermission.READ);
+        return true;
+      } catch (AuthException e) {
+        return false;
+      }
     }
     return false;
   }
@@ -311,177 +384,87 @@
 
   public class Addition {
     final AddReviewerResult result;
-    final Op op;
+    final PostReviewersOp op;
+    final Set<Account.Id> reviewers;
+    final Collection<Address> reviewersByEmail;
+    final ReviewerState state;
+    final ChangeNotes notes;
+    final IdentifiedUser caller;
 
-    private final Map<Account.Id, ChangeControl> reviewers;
-
-    protected Addition(String reviewer) {
-      this(reviewer, null, null, REVIEWER, null, ImmutableListMultimap.of());
+    Addition(String reviewer) {
+      result = new AddReviewerResult(reviewer);
+      op = null;
+      reviewers = ImmutableSet.of();
+      reviewersByEmail = ImmutableSet.of();
+      state = REVIEWER;
+      notes = null;
+      caller = null;
     }
 
     protected Addition(
         String reviewer,
         ChangeResource rsrc,
-        Map<Account.Id, ChangeControl> reviewers,
+        @Nullable Set<Account.Id> reviewers,
+        @Nullable Collection<Address> reviewersByEmail,
         ReviewerState state,
-        NotifyHandling notify,
+        @Nullable NotifyHandling notify,
         ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+      checkArgument(
+          reviewers != null || reviewersByEmail != null,
+          "must have either reviewers or reviewersByEmail");
+
       result = new AddReviewerResult(reviewer);
-      if (reviewers == null) {
-        this.reviewers = ImmutableMap.of();
-        op = null;
-        return;
-      }
-      this.reviewers = reviewers;
-      op = new Op(rsrc, reviewers, state, notify, accountsToNotify);
+      this.reviewers = reviewers == null ? ImmutableSet.of() : reviewers;
+      this.reviewersByEmail = reviewersByEmail == null ? ImmutableList.of() : reviewersByEmail;
+      this.state = state;
+      notes = rsrc.getNotes();
+      caller = rsrc.getUser();
+      op =
+          postReviewersOpFactory.create(
+              rsrc, this.reviewers, this.reviewersByEmail, state, notify, accountsToNotify);
     }
 
-    void gatherResults() throws OrmException {
+    void gatherResults() throws OrmException, PermissionBackendException {
+      if (notes == null || caller == null) {
+        // When notes or caller is missing this is likely just carrying an error message
+        // in the contained AddReviewerResult.
+        return;
+      }
+
+      ChangeData cd = changeDataFactory.create(dbProvider.get(), notes);
+      PermissionBackend.ForChange perm =
+          permissionBackend.user(caller).database(dbProvider).change(cd);
+
       // Generate result details and fill AccountLoader. This occurs outside
       // the Op because the accounts are in a different table.
-      if (migration.readChanges() && op.state == CC) {
-        result.ccs = Lists.newArrayListWithCapacity(op.addedCCs.size());
-        for (Account.Id accountId : op.addedCCs) {
-          result.ccs.add(json.format(new ReviewerInfo(accountId.get()), reviewers.get(accountId)));
+      PostReviewersOp.Result opResult = op.getResult();
+      if (migration.readChanges() && state == CC) {
+        result.ccs = Lists.newArrayListWithCapacity(opResult.addedCCs().size());
+        for (Account.Id accountId : opResult.addedCCs()) {
+          IdentifiedUser u = identifiedUserFactory.create(accountId);
+          result.ccs.add(json.format(new ReviewerInfo(accountId.get()), perm.user(u), cd));
         }
         accountLoaderFactory.create(true).fill(result.ccs);
+        for (Address a : reviewersByEmail) {
+          result.ccs.add(new AccountInfo(a.getName(), a.getEmail()));
+        }
       } else {
-        result.reviewers = Lists.newArrayListWithCapacity(op.addedReviewers.size());
-        for (PatchSetApproval psa : op.addedReviewers) {
+        result.reviewers = Lists.newArrayListWithCapacity(opResult.addedReviewers().size());
+        for (PatchSetApproval psa : opResult.addedReviewers()) {
           // New reviewers have value 0, don't bother normalizing.
+          IdentifiedUser u = identifiedUserFactory.create(psa.getAccountId());
           result.reviewers.add(
               json.format(
                   new ReviewerInfo(psa.getAccountId().get()),
-                  reviewers.get(psa.getAccountId()),
+                  perm.user(u),
+                  cd,
                   ImmutableList.of(psa)));
         }
         accountLoaderFactory.create(true).fill(result.reviewers);
-      }
-    }
-  }
-
-  public class Op implements BatchUpdateOp {
-    final Map<Account.Id, ChangeControl> reviewers;
-    final ReviewerState state;
-    final NotifyHandling notify;
-    final ListMultimap<RecipientType, Account.Id> accountsToNotify;
-    List<PatchSetApproval> addedReviewers;
-    Collection<Account.Id> addedCCs;
-
-    private final ChangeResource rsrc;
-    private PatchSet patchSet;
-
-    Op(
-        ChangeResource rsrc,
-        Map<Account.Id, ChangeControl> reviewers,
-        ReviewerState state,
-        NotifyHandling notify,
-        ListMultimap<RecipientType, Account.Id> accountsToNotify) {
-      this.rsrc = rsrc;
-      this.reviewers = reviewers;
-      this.state = state;
-      this.notify = notify;
-      this.accountsToNotify = checkNotNull(accountsToNotify);
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws RestApiException, OrmException, IOException {
-      if (migration.readChanges() && state == CC) {
-        addedCCs =
-            approvalsUtil.addCcs(
-                ctx.getNotes(),
-                ctx.getUpdate(ctx.getChange().currentPatchSetId()),
-                reviewers.keySet());
-        if (addedCCs.isEmpty()) {
-          return false;
-        }
-      } else {
-        addedReviewers =
-            approvalsUtil.addReviewers(
-                ctx.getDb(),
-                ctx.getNotes(),
-                ctx.getUpdate(ctx.getChange().currentPatchSetId()),
-                rsrc.getControl().getLabelTypes(),
-                rsrc.getChange(),
-                reviewers.keySet());
-        if (addedReviewers.isEmpty()) {
-          return false;
+        for (Address a : reviewersByEmail) {
+          result.reviewers.add(ReviewerInfo.byEmail(a.getName(), a.getEmail()));
         }
       }
-
-      patchSet = psUtil.current(dbProvider.get(), rsrc.getNotes());
-      return true;
-    }
-
-    @Override
-    public void postUpdate(Context ctx) throws Exception {
-      if (addedReviewers != null || addedCCs != null) {
-        if (addedReviewers == null) {
-          addedReviewers = new ArrayList<>();
-        }
-        if (addedCCs == null) {
-          addedCCs = new ArrayList<>();
-        }
-        emailReviewers(
-            rsrc.getChange(),
-            Lists.transform(addedReviewers, r -> r.getAccountId()),
-            addedCCs,
-            notify,
-            accountsToNotify);
-        if (!addedReviewers.isEmpty()) {
-          List<Account> reviewers =
-              Lists.transform(
-                  addedReviewers, psa -> accountCache.get(psa.getAccountId()).getAccount());
-          reviewerAdded.fire(
-              rsrc.getChange(), patchSet, reviewers, ctx.getAccount(), ctx.getWhen());
-        }
-      }
-    }
-  }
-
-  public void emailReviewers(
-      Change change,
-      Collection<Account.Id> added,
-      Collection<Account.Id> copied,
-      NotifyHandling notify,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify) {
-    if (added.isEmpty() && copied.isEmpty()) {
-      return;
-    }
-
-    // Email the reviewers
-    //
-    // The user knows they added themselves, don't bother emailing them.
-    List<Account.Id> toMail = Lists.newArrayListWithCapacity(added.size());
-    Account.Id userId = user.get().getAccountId();
-    for (Account.Id id : added) {
-      if (!id.equals(userId)) {
-        toMail.add(id);
-      }
-    }
-    List<Account.Id> toCopy = Lists.newArrayListWithCapacity(copied.size());
-    for (Account.Id id : copied) {
-      if (!id.equals(userId)) {
-        toCopy.add(id);
-      }
-    }
-    if (toMail.isEmpty() && toCopy.isEmpty()) {
-      return;
-    }
-
-    try {
-      AddReviewerSender cm = addReviewerSenderFactory.create(change.getProject(), change.getId());
-      if (notify != null) {
-        cm.setNotify(notify);
-      }
-      cm.setAccountsToNotify(accountsToNotify);
-      cm.setFrom(userId);
-      cm.addReviewers(toMail);
-      cm.addExtraCC(toCopy);
-      cm.send();
-    } catch (Exception err) {
-      log.error("Cannot send email to new reviewers of change " + change.getId(), err);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewersOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewersOp.java
new file mode 100644
index 0000000..2e80122
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewersOp.java
@@ -0,0 +1,258 @@
+// Copyright (C) 2017 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 static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.extensions.client.ReviewerState.CC;
+import static java.util.stream.Collectors.toList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+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.extensions.events.ReviewerAdded;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.send.AddReviewerSender;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class PostReviewersOp implements BatchUpdateOp {
+  private static final Logger log = LoggerFactory.getLogger(PostReviewersOp.class);
+
+  public interface Factory {
+    PostReviewersOp create(
+        ChangeResource rsrc,
+        Set<Account.Id> reviewers,
+        Collection<Address> reviewersByEmail,
+        ReviewerState state,
+        @Nullable NotifyHandling notify,
+        ListMultimap<RecipientType, Account.Id> accountsToNotify);
+  }
+
+  @AutoValue
+  public abstract static class Result {
+    public abstract ImmutableList<PatchSetApproval> addedReviewers();
+
+    public abstract ImmutableList<Account.Id> addedCCs();
+
+    static Builder builder() {
+      return new AutoValue_PostReviewersOp_Result.Builder();
+    }
+
+    @AutoValue.Builder
+    abstract static class Builder {
+      abstract Builder setAddedReviewers(ImmutableList<PatchSetApproval> addedReviewers);
+
+      abstract Builder setAddedCCs(ImmutableList<Account.Id> addedCCs);
+
+      abstract Result build();
+    }
+  }
+
+  private final ApprovalsUtil approvalsUtil;
+  private final PatchSetUtil psUtil;
+  private final ReviewerAdded reviewerAdded;
+  private final AccountCache accountCache;
+  private final AddReviewerSender.Factory addReviewerSenderFactory;
+  private final NotesMigration migration;
+  private final Provider<IdentifiedUser> user;
+  private final Provider<ReviewDb> dbProvider;
+  private final ChangeResource rsrc;
+  private final Set<Account.Id> reviewers;
+  private final Collection<Address> reviewersByEmail;
+  private final ReviewerState state;
+  private final NotifyHandling notify;
+  private final ListMultimap<RecipientType, Account.Id> accountsToNotify;
+
+  private List<PatchSetApproval> addedReviewers = new ArrayList<>();
+  private Collection<Account.Id> addedCCs = new ArrayList<>();
+  private Collection<Address> addedCCsByEmail = new ArrayList<>();
+  private PatchSet patchSet;
+  private Result opResult;
+
+  @Inject
+  PostReviewersOp(
+      ApprovalsUtil approvalsUtil,
+      PatchSetUtil psUtil,
+      ReviewerAdded reviewerAdded,
+      AccountCache accountCache,
+      AddReviewerSender.Factory addReviewerSenderFactory,
+      NotesMigration migration,
+      Provider<IdentifiedUser> user,
+      Provider<ReviewDb> dbProvider,
+      @Assisted ChangeResource rsrc,
+      @Assisted Set<Account.Id> reviewers,
+      @Assisted Collection<Address> reviewersByEmail,
+      @Assisted ReviewerState state,
+      @Assisted @Nullable NotifyHandling notify,
+      @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+    this.approvalsUtil = approvalsUtil;
+    this.psUtil = psUtil;
+    this.reviewerAdded = reviewerAdded;
+    this.accountCache = accountCache;
+    this.addReviewerSenderFactory = addReviewerSenderFactory;
+    this.migration = migration;
+    this.user = user;
+    this.dbProvider = dbProvider;
+
+    this.rsrc = rsrc;
+    this.reviewers = reviewers;
+    this.reviewersByEmail = reviewersByEmail;
+    this.state = state;
+    this.notify = notify;
+    this.accountsToNotify = accountsToNotify;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws RestApiException, OrmException, IOException {
+    if (!reviewers.isEmpty()) {
+      if (migration.readChanges() && state == CC) {
+        addedCCs =
+            approvalsUtil.addCcs(
+                ctx.getNotes(), ctx.getUpdate(ctx.getChange().currentPatchSetId()), reviewers);
+        if (addedCCs.isEmpty()) {
+          return false;
+        }
+      } else {
+        addedReviewers =
+            approvalsUtil.addReviewers(
+                ctx.getDb(),
+                ctx.getNotes(),
+                ctx.getUpdate(ctx.getChange().currentPatchSetId()),
+                rsrc.getControl().getLabelTypes(),
+                rsrc.getChange(),
+                reviewers);
+        if (addedReviewers.isEmpty()) {
+          return false;
+        }
+      }
+    }
+
+    for (Address a : reviewersByEmail) {
+      ctx.getUpdate(ctx.getChange().currentPatchSetId())
+          .putReviewerByEmail(a, ReviewerStateInternal.fromReviewerState(state));
+    }
+
+    patchSet = psUtil.current(dbProvider.get(), rsrc.getNotes());
+    return true;
+  }
+
+  @Override
+  public void postUpdate(Context ctx) throws Exception {
+    opResult =
+        Result.builder()
+            .setAddedReviewers(ImmutableList.copyOf(addedReviewers))
+            .setAddedCCs(ImmutableList.copyOf(addedCCs))
+            .build();
+    emailReviewers(
+        rsrc.getChange(),
+        Lists.transform(addedReviewers, r -> r.getAccountId()),
+        addedCCs == null ? ImmutableList.of() : addedCCs,
+        reviewersByEmail,
+        addedCCsByEmail,
+        notify,
+        accountsToNotify);
+    if (!addedReviewers.isEmpty()) {
+      List<Account> reviewers =
+          addedReviewers
+              .stream()
+              .map(r -> accountCache.get(r.getAccountId()).getAccount())
+              .collect(toList());
+      reviewerAdded.fire(rsrc.getChange(), patchSet, reviewers, ctx.getAccount(), ctx.getWhen());
+    }
+  }
+
+  public void emailReviewers(
+      Change change,
+      Collection<Account.Id> added,
+      Collection<Account.Id> copied,
+      Collection<Address> addedByEmail,
+      Collection<Address> copiedByEmail,
+      NotifyHandling notify,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+    if (added.isEmpty() && copied.isEmpty() && addedByEmail.isEmpty() && copiedByEmail.isEmpty()) {
+      return;
+    }
+
+    // Email the reviewers
+    //
+    // The user knows they added themselves, don't bother emailing them.
+    List<Account.Id> toMail = Lists.newArrayListWithCapacity(added.size());
+    Account.Id userId = user.get().getAccountId();
+    for (Account.Id id : added) {
+      if (!id.equals(userId)) {
+        toMail.add(id);
+      }
+    }
+    List<Account.Id> toCopy = Lists.newArrayListWithCapacity(copied.size());
+    for (Account.Id id : copied) {
+      if (!id.equals(userId)) {
+        toCopy.add(id);
+      }
+    }
+    if (toMail.isEmpty() && toCopy.isEmpty() && addedByEmail.isEmpty() && copiedByEmail.isEmpty()) {
+      return;
+    }
+
+    try {
+      AddReviewerSender cm = addReviewerSenderFactory.create(change.getProject(), change.getId());
+      cm.setNotify(MoreObjects.firstNonNull(notify, NotifyHandling.ALL));
+      cm.setAccountsToNotify(accountsToNotify);
+      cm.setFrom(userId);
+      cm.addReviewers(toMail);
+      cm.addReviewersByEmail(addedByEmail);
+      cm.addExtraCC(toCopy);
+      cm.addExtraCCByEmail(copiedByEmail);
+      cm.send();
+    } catch (Exception err) {
+      log.error("Cannot send email to new reviewers of change " + change.getId(), err);
+    }
+  }
+
+  public Result getResult() {
+    checkState(opResult != null, "Batch update wasn't executed yet");
+    return opResult;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java
index f4356db..a074818 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java
@@ -25,7 +25,9 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.LimitedByteArrayOutputStream.LimitExceededException;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -45,6 +47,7 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.storage.pack.PackConfig;
 import org.eclipse.jgit.transport.BundleWriter;
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.kohsuke.args4j.Option;
@@ -94,7 +97,7 @@
 
     Change change = rsrc.getChange();
     if (!change.getStatus().isOpen()) {
-      throw new PreconditionFailedException("change is " + Submit.status(change));
+      throw new PreconditionFailedException("change is " + ChangeUtil.status(change));
     }
     ChangeControl control = rsrc.getControl();
     if (!control.getUser().isIdentifiedUser()) {
@@ -144,14 +147,16 @@
         MergeOpRepoManager orm = mergeOp.getMergeOpRepoManager();
         for (Project.NameKey p : mergeOp.getAllProjects()) {
           OpenRepo or = orm.getRepo(p);
-          BundleWriter bw = new BundleWriter(or.getRepo());
+          BundleWriter bw = new BundleWriter(or.getCodeReviewRevWalk().getObjectReader());
           bw.setObjectCountCallback(null);
-          bw.setPackConfig(null);
-          Collection<ReceiveCommand> refs = or.getUpdate().getRefUpdates();
+          bw.setPackConfig(new PackConfig(or.getRepo()));
+          Collection<ReceiveCommand> refs = or.getUpdate().getRefUpdates().values();
           for (ReceiveCommand r : refs) {
             bw.include(r.getRefName(), r.getNewId());
             ObjectId oldId = r.getOldId();
-            if (!oldId.equals(ObjectId.zeroId())) {
+            if (!oldId.equals(ObjectId.zeroId())
+                // Probably the client doesn't already have NoteDb data.
+                && !RefNames.isNoteDbMetaRef(r.getRefName())) {
               bw.assume(or.getCodeReviewRevWalk().parseCommit(oldId));
             }
           }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java
index 0e72979..658b87b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java
@@ -25,10 +25,12 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -69,19 +71,22 @@
   }
 
   @Singleton
-  public static class Publish implements RestModifyView<ChangeResource, PublishChangeEditInput> {
+  public static class Publish
+      extends RetryingRestModifyView<ChangeResource, PublishChangeEditInput, Response<?>> {
 
     private final ChangeEditUtil editUtil;
     private final NotifyUtil notifyUtil;
 
     @Inject
-    Publish(ChangeEditUtil editUtil, NotifyUtil notifyUtil) {
+    Publish(RetryHelper retryHelper, ChangeEditUtil editUtil, NotifyUtil notifyUtil) {
+      super(retryHelper);
       this.editUtil = editUtil;
       this.notifyUtil = notifyUtil;
     }
 
     @Override
-    public Response<?> apply(ChangeResource rsrc, PublishChangeEditInput in)
+    protected Response<?> applyImpl(
+        BatchUpdate.Factory updateFactory, ChangeResource rsrc, PublishChangeEditInput in)
         throws IOException, OrmException, RestApiException, UpdateException {
       Capable r = rsrc.getControl().getProjectControl().canPushToAtLeastOneRef();
       if (r != Capable.OK) {
@@ -96,7 +101,12 @@
       if (in == null) {
         in = new PublishChangeEditInput();
       }
-      editUtil.publish(edit.get(), in.notify, notifyUtil.resolveAccounts(in.notifyDetails));
+      editUtil.publish(
+          updateFactory,
+          rsrc.getControl(),
+          edit.get(),
+          in.notify,
+          notifyUtil.resolveAccounts(in.notifyDetails));
       return Response.none();
     }
   }
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 4cbeaf63c..3a614a3 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
@@ -25,7 +25,6 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
@@ -49,6 +48,8 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -65,14 +66,14 @@
 
 @Singleton
 public class PublishDraftPatchSet
-    implements RestModifyView<RevisionResource, Input>, UiAction<RevisionResource> {
+    extends RetryingRestModifyView<RevisionResource, Input, Response<?>>
+    implements UiAction<RevisionResource> {
   private static final Logger log = LoggerFactory.getLogger(PublishDraftPatchSet.class);
 
   public static class Input {}
 
   private final AccountResolver accountResolver;
   private final ApprovalsUtil approvalsUtil;
-  private final BatchUpdate.Factory updateFactory;
   private final CreateChangeSender.Factory createChangeSenderFactory;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final PatchSetUtil psUtil;
@@ -84,16 +85,16 @@
   public PublishDraftPatchSet(
       AccountResolver accountResolver,
       ApprovalsUtil approvalsUtil,
-      BatchUpdate.Factory updateFactory,
+      RetryHelper retryHelper,
       CreateChangeSender.Factory createChangeSenderFactory,
       PatchSetInfoFactory patchSetInfoFactory,
       PatchSetUtil psUtil,
       Provider<ReviewDb> dbProvider,
       ReplacePatchSetSender.Factory replacePatchSetFactory,
       DraftPublished draftPublished) {
+    super(retryHelper);
     this.accountResolver = accountResolver;
     this.approvalsUtil = approvalsUtil;
-    this.updateFactory = updateFactory;
     this.createChangeSenderFactory = createChangeSenderFactory;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.psUtil = psUtil;
@@ -103,12 +104,19 @@
   }
 
   @Override
-  public Response<?> apply(RevisionResource rsrc, Input input)
+  protected Response<?> applyImpl(
+      BatchUpdate.Factory updateFactory, RevisionResource rsrc, Input input)
       throws RestApiException, UpdateException {
-    return apply(rsrc.getUser(), rsrc.getChange(), rsrc.getPatchSet().getId(), rsrc.getPatchSet());
+    return apply(
+        updateFactory,
+        rsrc.getUser(),
+        rsrc.getChange(),
+        rsrc.getPatchSet().getId(),
+        rsrc.getPatchSet());
   }
 
-  private Response<?> apply(CurrentUser u, Change c, PatchSet.Id psId, PatchSet ps)
+  private Response<?> apply(
+      BatchUpdate.Factory updateFactory, CurrentUser u, Change c, PatchSet.Id psId, PatchSet ps)
       throws RestApiException, UpdateException {
     try (BatchUpdate bu =
         updateFactory.create(dbProvider.get(), c.getProject(), u, TimeUtil.nowTs())) {
@@ -131,18 +139,22 @@
     }
   }
 
-  public static class CurrentRevision implements RestModifyView<ChangeResource, Input> {
+  public static class CurrentRevision
+      extends RetryingRestModifyView<ChangeResource, Input, Response<?>> {
     private final PublishDraftPatchSet publish;
 
     @Inject
-    CurrentRevision(PublishDraftPatchSet publish) {
+    CurrentRevision(RetryHelper retryHelper, PublishDraftPatchSet publish) {
+      super(retryHelper);
       this.publish = publish;
     }
 
     @Override
-    public Response<?> apply(ChangeResource rsrc, Input input)
+    protected Response<?> applyImpl(
+        BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
         throws RestApiException, UpdateException {
       return publish.apply(
+          updateFactory,
           rsrc.getControl().getUser(),
           rsrc.getChange(),
           rsrc.getChange().currentPatchSetId(),
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 e64abaa..b07d24b 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,6 +14,7 @@
 
 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;
@@ -22,14 +23,19 @@
 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;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.AccountsCollection;
 import com.google.gerrit.server.change.PostReviewers.Addition;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -38,58 +44,72 @@
 import java.io.IOException;
 
 @Singleton
-public class PutAssignee
-    implements RestModifyView<ChangeResource, AssigneeInput>, UiAction<ChangeResource> {
+public class PutAssignee extends RetryingRestModifyView<ChangeResource, AssigneeInput, AccountInfo>
+    implements UiAction<ChangeResource> {
 
+  private final AccountsCollection accounts;
   private final SetAssigneeOp.Factory assigneeFactory;
-  private final BatchUpdate.Factory batchUpdateFactory;
   private final Provider<ReviewDb> db;
   private final PostReviewers postReviewers;
   private final AccountLoader.Factory accountLoaderFactory;
 
   @Inject
   PutAssignee(
+      AccountsCollection accounts,
       SetAssigneeOp.Factory assigneeFactory,
-      BatchUpdate.Factory batchUpdateFactory,
+      RetryHelper retryHelper,
       Provider<ReviewDb> db,
       PostReviewers postReviewers,
       AccountLoader.Factory accountLoaderFactory) {
+    super(retryHelper);
+    this.accounts = accounts;
     this.assigneeFactory = assigneeFactory;
-    this.batchUpdateFactory = batchUpdateFactory;
     this.db = db;
     this.postReviewers = postReviewers;
     this.accountLoaderFactory = accountLoaderFactory;
   }
 
   @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 (input.assignee == null || input.assignee.trim().isEmpty()) {
+  protected AccountInfo applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, AssigneeInput input)
+      throws RestApiException, UpdateException, OrmException, IOException,
+          PermissionBackendException {
+    rsrc.permissions().check(ChangePermission.EDIT_ASSIGNEE);
+
+    input.assignee = Strings.nullToEmpty(input.assignee).trim();
+    if (input.assignee.isEmpty()) {
       throw new BadRequestException("missing assignee field");
     }
 
+    IdentifiedUser assignee = accounts.parse(input.assignee);
+    if (!assignee.getAccount().isActive()) {
+      throw new UnprocessableEntityException(input.assignee + " is not active");
+    }
+    try {
+      rsrc.permissions().database(db).user(assignee).check(ChangePermission.READ);
+    } catch (AuthException e) {
+      throw new AuthException("read not permitted for " + input.assignee);
+    }
+
     try (BatchUpdate bu =
-        batchUpdateFactory.create(
+        updateFactory.create(
             db.get(),
             rsrc.getChange().getProject(),
             rsrc.getControl().getUser(),
             TimeUtil.nowTs())) {
-      SetAssigneeOp op = assigneeFactory.create(input.assignee);
+      SetAssigneeOp op = assigneeFactory.create(assignee);
       bu.addOp(rsrc.getId(), op);
 
       PostReviewers.Addition reviewersAddition = addAssigneeAsCC(rsrc, input.assignee);
       bu.addOp(rsrc.getId(), reviewersAddition.op);
 
       bu.execute();
-      return Response.ok(accountLoaderFactory.create(true).fillOne(op.getNewAssignee()));
+      return accountLoaderFactory.create(true).fillOne(assignee.getAccountId());
     }
   }
 
   private Addition addAssigneeAsCC(ChangeResource rsrc, String assignee)
-      throws OrmException, RestApiException, IOException {
+      throws OrmException, IOException, PermissionBackendException {
     AddReviewerInput reviewerInput = new AddReviewerInput();
     reviewerInput.reviewer = assignee;
     reviewerInput.state = ReviewerState.CC;
@@ -99,9 +119,9 @@
   }
 
   @Override
-  public UiAction.Description getDescription(ChangeResource resource) {
+  public UiAction.Description getDescription(ChangeResource rsrc) {
     return new UiAction.Description()
         .setLabel("Edit Assignee")
-        .setVisible(resource.getControl().canEditAssignee());
+        .setVisible(rsrc.permissions().testOrFalse(ChangePermission.EDIT_ASSIGNEE));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java
index 3c2633e..f2614c3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java
@@ -16,11 +16,9 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -28,10 +26,14 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -41,10 +43,10 @@
 
 @Singleton
 public class PutDescription
-    implements RestModifyView<RevisionResource, PutDescription.Input>, UiAction<RevisionResource> {
+    extends RetryingRestModifyView<RevisionResource, PutDescription.Input, Response<String>>
+    implements UiAction<RevisionResource> {
   private final Provider<ReviewDb> dbProvider;
   private final ChangeMessagesUtil cmUtil;
-  private final BatchUpdate.Factory batchUpdateFactory;
   private final PatchSetUtil psUtil;
 
   public static class Input {
@@ -55,24 +57,24 @@
   PutDescription(
       Provider<ReviewDb> dbProvider,
       ChangeMessagesUtil cmUtil,
-      BatchUpdate.Factory batchUpdateFactory,
+      RetryHelper retryHelper,
       PatchSetUtil psUtil) {
+    super(retryHelper);
     this.dbProvider = dbProvider;
     this.cmUtil = cmUtil;
-    this.batchUpdateFactory = batchUpdateFactory;
     this.psUtil = psUtil;
   }
 
   @Override
-  public Response<String> apply(RevisionResource rsrc, Input input)
-      throws UpdateException, RestApiException {
+  protected Response<String> applyImpl(
+      BatchUpdate.Factory updateFactory, RevisionResource rsrc, Input input)
+      throws UpdateException, RestApiException, PermissionBackendException {
+    rsrc.permissions().check(ChangePermission.EDIT_DESCRIPTION);
+
     ChangeControl ctl = rsrc.getControl();
-    if (!ctl.canEditDescription()) {
-      throw new AuthException("changing description not permitted");
-    }
     Op op = new Op(input != null ? input : new Input(), rsrc.getPatchSet().getId());
     try (BatchUpdate u =
-        batchUpdateFactory.create(
+        updateFactory.create(
             dbProvider.get(), rsrc.getChange().getProject(), ctl.getUser(), TimeUtil.nowTs())) {
       u.addOp(rsrc.getChange().getId(), op);
       u.execute();
@@ -129,6 +131,6 @@
   public UiAction.Description getDescription(RevisionResource rsrc) {
     return new UiAction.Description()
         .setLabel("Edit Description")
-        .setVisible(rsrc.getControl().canEditDescription());
+        .setVisible(rsrc.permissions().testOrFalse(ChangePermission.EDIT_DESCRIPTION));
   }
 }
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 b289da8..90716ce 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
@@ -23,7 +23,6 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
@@ -36,6 +35,8 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -46,13 +47,13 @@
 import java.util.Optional;
 
 @Singleton
-public class PutDraftComment implements RestModifyView<DraftCommentResource, DraftInput> {
+public class PutDraftComment
+    extends RetryingRestModifyView<DraftCommentResource, DraftInput, Response<CommentInfo>> {
 
   private final Provider<ReviewDb> db;
   private final DeleteDraftComment delete;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
-  private final BatchUpdate.Factory updateFactory;
   private final Provider<CommentJson> commentJson;
   private final PatchListCache patchListCache;
 
@@ -62,23 +63,24 @@
       DeleteDraftComment delete,
       CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
-      BatchUpdate.Factory updateFactory,
+      RetryHelper retryHelper,
       Provider<CommentJson> commentJson,
       PatchListCache patchListCache) {
+    super(retryHelper);
     this.db = db;
     this.delete = delete;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
-    this.updateFactory = updateFactory;
     this.commentJson = commentJson;
     this.patchListCache = patchListCache;
   }
 
   @Override
-  public Response<CommentInfo> apply(DraftCommentResource rsrc, DraftInput in)
+  protected Response<CommentInfo> applyImpl(
+      BatchUpdate.Factory updateFactory, DraftCommentResource rsrc, DraftInput in)
       throws RestApiException, UpdateException, OrmException {
     if (in == null || in.message == null || in.message.trim().isEmpty()) {
-      return delete.apply(rsrc, null);
+      return delete.applyImpl(updateFactory, rsrc, null);
     } else if (in.id != null && !rsrc.getId().equals(in.id)) {
       throw new BadRequestException("id must match URL");
     } else if (in.line != null && in.line < 0) {
@@ -146,7 +148,7 @@
           update,
           Status.DRAFT,
           Collections.singleton(update(comment, in, ctx.getWhen())));
-      ctx.bumpLastUpdatedOn(false);
+      ctx.dontBumpLastUpdatedOn();
       return true;
     }
   }
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 783ab9d..5d9f7b9 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
@@ -16,11 +16,9 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -29,11 +27,14 @@
 import com.google.gerrit.server.change.PutTopic.Input;
 import com.google.gerrit.server.extensions.events.TopicEdited;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -41,10 +42,10 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class PutTopic implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
+public class PutTopic extends RetryingRestModifyView<ChangeResource, Input, Response<String>>
+    implements UiAction<ChangeResource> {
   private final Provider<ReviewDb> dbProvider;
   private final ChangeMessagesUtil cmUtil;
-  private final BatchUpdate.Factory batchUpdateFactory;
   private final TopicEdited topicEdited;
 
   public static class Input {
@@ -55,32 +56,27 @@
   PutTopic(
       Provider<ReviewDb> dbProvider,
       ChangeMessagesUtil cmUtil,
-      BatchUpdate.Factory batchUpdateFactory,
+      RetryHelper retryHelper,
       TopicEdited topicEdited) {
+    super(retryHelper);
     this.dbProvider = dbProvider;
     this.cmUtil = cmUtil;
-    this.batchUpdateFactory = batchUpdateFactory;
     this.topicEdited = topicEdited;
   }
 
   @Override
-  public Response<String> apply(ChangeResource req, Input input)
-      throws UpdateException, RestApiException {
-    ChangeControl ctl = req.getControl();
-    if (!ctl.canEditTopicName()) {
-      throw new AuthException("changing topic not permitted");
-    }
-
+  protected Response<String> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource req, Input input)
+      throws UpdateException, RestApiException, PermissionBackendException {
+    req.permissions().check(ChangePermission.EDIT_TOPIC_NAME);
     Op op = new Op(input != null ? input : new Input());
     try (BatchUpdate u =
-        batchUpdateFactory.create(
-            dbProvider.get(), req.getChange().getProject(), ctl.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(
+            dbProvider.get(), req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
       u.addOp(req.getId(), op);
       u.execute();
     }
-    return Strings.isNullOrEmpty(op.newTopicName)
-        ? Response.<String>none()
-        : Response.ok(op.newTopicName);
+    return Strings.isNullOrEmpty(op.newTopicName) ? Response.none() : Response.ok(op.newTopicName);
   }
 
   private class Op implements BatchUpdateOp {
@@ -129,9 +125,9 @@
   }
 
   @Override
-  public UiAction.Description getDescription(ChangeResource resource) {
+  public UiAction.Description getDescription(ChangeResource rsrc) {
     return new UiAction.Description()
         .setLabel("Edit Topic")
-        .setVisible(resource.getControl().canEditTopicName());
+        .setVisible(rsrc.permissions().testOrFalse(ChangePermission.EDIT_TOPIC_NAME));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebase.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebase.java
index 93e1e4e..7131e20 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebase.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebase.java
@@ -31,13 +31,17 @@
 import com.google.gerrit.reviewdb.client.Change.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.PatchSetUtil;
 import com.google.gerrit.server.change.RebaseUtil.Base;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -46,6 +50,8 @@
 import java.io.IOException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -53,13 +59,12 @@
 import org.slf4j.LoggerFactory;
 
 @Singleton
-public class Rebase
+public class Rebase extends RetryingRestModifyView<RevisionResource, RebaseInput, ChangeInfo>
     implements RestModifyView<RevisionResource, RebaseInput>, UiAction<RevisionResource> {
   private static final Logger log = LoggerFactory.getLogger(Rebase.class);
   private static final ImmutableSet<ListChangesOption> OPTIONS =
       Sets.immutableEnumSet(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT);
 
-  private final BatchUpdate.Factory updateFactory;
   private final GitRepositoryManager repoManager;
   private final RebaseChangeOp.Factory rebaseFactory;
   private final RebaseUtil rebaseUtil;
@@ -68,13 +73,13 @@
 
   @Inject
   public Rebase(
-      BatchUpdate.Factory updateFactory,
+      RetryHelper retryHelper,
       GitRepositoryManager repoManager,
       RebaseChangeOp.Factory rebaseFactory,
       RebaseUtil rebaseUtil,
       ChangeJson.Factory json,
       Provider<ReviewDb> dbProvider) {
-    this.updateFactory = updateFactory;
+    super(retryHelper);
     this.repoManager = repoManager;
     this.rebaseFactory = rebaseFactory;
     this.rebaseUtil = rebaseUtil;
@@ -83,21 +88,23 @@
   }
 
   @Override
-  public ChangeInfo apply(RevisionResource rsrc, RebaseInput input)
+  protected ChangeInfo applyImpl(
+      BatchUpdate.Factory updateFactory, RevisionResource rsrc, RebaseInput input)
       throws EmailException, OrmException, UpdateException, RestApiException, IOException,
-          NoSuchChangeException {
+          NoSuchChangeException, PermissionBackendException {
+    rsrc.permissions().database(dbProvider).check(ChangePermission.REBASE);
+
     ChangeControl control = rsrc.getControl();
     Change change = rsrc.getChange();
     try (Repository repo = repoManager.openRepository(change.getProject());
-        RevWalk rw = new RevWalk(repo);
         ObjectInserter oi = repo.newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        RevWalk rw = new RevWalk(reader);
         BatchUpdate bu =
             updateFactory.create(
                 dbProvider.get(), change.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
-      if (!control.canRebase(dbProvider.get())) {
-        throw new AuthException("rebase not permitted");
-      } else if (!change.getStatus().isOpen()) {
-        throw new ResourceConflictException("change is " + change.getStatus().name().toLowerCase());
+      if (!change.getStatus().isOpen()) {
+        throw new ResourceConflictException("change is " + ChangeUtil.status(change));
       } else if (!hasOneParent(rw, rsrc.getPatchSet())) {
         throw new ResourceConflictException(
             "cannot rebase merge commits or commit with no ancestor");
@@ -106,27 +113,32 @@
       bu.addOp(
           change.getId(),
           rebaseFactory
-              .create(control, rsrc.getPatchSet(), findBaseRev(rw, rsrc, input))
+              .create(control, rsrc.getPatchSet(), findBaseRev(repo, rw, rsrc, input))
               .setForceContentMerge(true)
-              .setFireRevisionCreated(true)
-              .setValidatePolicy(CommitValidators.Policy.GERRIT));
+              .setFireRevisionCreated(true));
       bu.execute();
     }
     return json.create(OPTIONS).format(change.getProject(), change.getId());
   }
 
-  private String findBaseRev(RevWalk rw, RevisionResource rsrc, RebaseInput input)
-      throws AuthException, ResourceConflictException, OrmException, IOException,
-          NoSuchChangeException {
+  private ObjectId findBaseRev(
+      Repository repo, RevWalk rw, RevisionResource rsrc, RebaseInput input)
+      throws RestApiException, OrmException, IOException, NoSuchChangeException {
+    Branch.NameKey destRefKey = rsrc.getChange().getDest();
     if (input == null || input.base == null) {
-      return null;
+      return rebaseUtil.findBaseRevision(rsrc.getPatchSet(), destRefKey, repo, rw);
     }
 
     Change change = rsrc.getChange();
     String str = input.base.trim();
     if (str.equals("")) {
-      // remove existing dependency to other patch set
-      return change.getDest().get();
+      // Remove existing dependency to other patch set.
+      Ref destRef = repo.exactRef(destRefKey.get());
+      if (destRef == null) {
+        throw new ResourceConflictException(
+            "can't rebase onto tip of branch " + destRefKey.get() + "; branch doesn't exist");
+      }
+      return destRef.getObjectId();
     }
 
     @SuppressWarnings("resource")
@@ -157,7 +169,7 @@
               + baseChange.getKey()
               + " is a descendant of the current change - recursion not allowed");
     }
-    return base.patchSet().getRevision().get();
+    return ObjectId.fromString(base.patchSet().getRevision().get());
   }
 
   private boolean isMergedInto(RevWalk rw, PatchSet base, PatchSet tip) throws IOException {
@@ -175,15 +187,12 @@
   @Override
   public UiAction.Description getDescription(RevisionResource resource) {
     PatchSet patchSet = resource.getPatchSet();
-    Branch.NameKey dest = resource.getChange().getDest();
-    boolean canRebase = false;
-    try {
-      canRebase = resource.getControl().canRebase(dbProvider.get());
-    } catch (OrmException e) {
-      log.error("Cannot check canRebase status. Assuming false.", e);
-    }
+    Change change = resource.getChange();
+    Branch.NameKey dest = change.getDest();
     boolean visible =
-        resource.getChange().getStatus().isOpen() && resource.isCurrent() && canRebase;
+        change.getStatus().isOpen()
+            && resource.isCurrent()
+            && resource.permissions().database(dbProvider).testOrFalse(ChangePermission.REBASE);
     boolean enabled = true;
 
     if (visible) {
@@ -196,35 +205,37 @@
         visible = false;
       }
     }
-    UiAction.Description descr =
-        new UiAction.Description()
-            .setLabel("Rebase")
-            .setTitle("Rebase onto tip of branch or parent change")
-            .setVisible(visible)
-            .setEnabled(enabled);
-    return descr;
+    return new UiAction.Description()
+        .setLabel("Rebase")
+        .setTitle("Rebase onto tip of branch or parent change")
+        .setVisible(visible)
+        .setEnabled(enabled);
   }
 
-  public static class CurrentRevision implements RestModifyView<ChangeResource, RebaseInput> {
+  public static class CurrentRevision
+      extends RetryingRestModifyView<ChangeResource, RebaseInput, ChangeInfo> {
     private final PatchSetUtil psUtil;
     private final Rebase rebase;
 
     @Inject
-    CurrentRevision(PatchSetUtil psUtil, Rebase rebase) {
+    CurrentRevision(RetryHelper retryHelper, PatchSetUtil psUtil, Rebase rebase) {
+      super(retryHelper);
       this.psUtil = psUtil;
       this.rebase = rebase;
     }
 
     @Override
-    public ChangeInfo apply(ChangeResource rsrc, RebaseInput input)
-        throws EmailException, OrmException, UpdateException, RestApiException, IOException {
+    protected ChangeInfo applyImpl(
+        BatchUpdate.Factory updateFactory, ChangeResource rsrc, RebaseInput input)
+        throws EmailException, OrmException, UpdateException, RestApiException, IOException,
+            PermissionBackendException {
       PatchSet ps = psUtil.current(rebase.dbProvider.get(), rsrc.getNotes());
       if (ps == null) {
         throw new ResourceConflictException("current revision is missing");
       } else if (!rsrc.getControl().isPatchVisible(ps, rebase.dbProvider.get())) {
         throw new AuthException("current revision not accessible");
       }
-      return rebase.apply(new RevisionResource(rsrc, ps), input);
+      return rebase.applyImpl(updateFactory, new RevisionResource(rsrc, ps), input);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeEdit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeEdit.java
index 7b673dd..9e840c3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeEdit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeEdit.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.edit.ChangeEditModifier;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -82,7 +83,8 @@
 
     @Override
     public Response<?> apply(ChangeResource rsrc, Rebase.Input in)
-        throws AuthException, ResourceConflictException, IOException, OrmException {
+        throws AuthException, ResourceConflictException, IOException, OrmException,
+            PermissionBackendException {
       Project.NameKey project = rsrc.getProject();
       try (Repository repository = repositoryManager.openRepository(project)) {
         editModifier.rebaseEdit(repository, rsrc.getControl());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
index c03bb6f..50c03fe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.Preconditions.checkState;
 
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -26,7 +25,7 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.RebaseUtil.Base;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
@@ -36,8 +35,8 @@
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
@@ -48,8 +47,7 @@
 
 public class RebaseChangeOp implements BatchUpdateOp {
   public interface Factory {
-    RebaseChangeOp create(
-        ChangeControl ctl, PatchSet originalPatchSet, @Nullable String baseCommitish);
+    RebaseChangeOp create(ChangeControl ctl, PatchSet originalPatchSet, ObjectId baseCommitId);
   }
 
   private final PatchSetInserter.Factory patchSetInserterFactory;
@@ -60,10 +58,10 @@
   private final ChangeControl ctl;
   private final PatchSet originalPatchSet;
 
-  private String baseCommitish;
+  private ObjectId baseCommitId;
   private PersonIdent committerIdent;
   private boolean fireRevisionCreated = true;
-  private CommitValidators.Policy validate;
+  private boolean validate = true;
   private boolean checkAddPatchSetPermission = true;
   private boolean forceContentMerge;
   private boolean copyApprovals = true;
@@ -75,7 +73,7 @@
   private PatchSetInserter patchSetInserter;
   private PatchSet rebasedPatchSet;
 
-  @AssistedInject
+  @Inject
   RebaseChangeOp(
       PatchSetInserter.Factory patchSetInserterFactory,
       MergeUtil.Factory mergeUtilFactory,
@@ -83,14 +81,14 @@
       ChangeResource.Factory changeResourceFactory,
       @Assisted ChangeControl ctl,
       @Assisted PatchSet originalPatchSet,
-      @Assisted @Nullable String baseCommitish) {
+      @Assisted ObjectId baseCommitId) {
     this.patchSetInserterFactory = patchSetInserterFactory;
     this.mergeUtilFactory = mergeUtilFactory;
     this.rebaseUtil = rebaseUtil;
     this.changeResourceFactory = changeResourceFactory;
     this.ctl = ctl;
     this.originalPatchSet = originalPatchSet;
-    this.baseCommitish = baseCommitish;
+    this.baseCommitId = baseCommitId;
   }
 
   public RebaseChangeOp setCommitterIdent(PersonIdent committerIdent) {
@@ -98,7 +96,7 @@
     return this;
   }
 
-  public RebaseChangeOp setValidatePolicy(CommitValidators.Policy validate) {
+  public RebaseChangeOp setValidate(boolean validate) {
     this.validate = validate;
     return this;
   }
@@ -136,7 +134,7 @@
   @Override
   public void updateRepo(RepoContext ctx)
       throws MergeConflictException, InvalidChangeOperationException, RestApiException, IOException,
-          OrmException, NoSuchChangeException {
+          OrmException, NoSuchChangeException, PermissionBackendException {
     // Ok that originalPatchSet was not read in a transaction, since we just
     // need its revision.
     RevId oldRev = originalPatchSet.getRevision();
@@ -144,19 +142,7 @@
     RevWalk rw = ctx.getRevWalk();
     RevCommit original = rw.parseCommit(ObjectId.fromString(oldRev.get()));
     rw.parseBody(original);
-
-    RevCommit baseCommit;
-    if (baseCommitish != null) {
-      baseCommit = rw.parseCommit(ctx.getRepository().resolve(baseCommitish));
-    } else {
-      baseCommit =
-          rw.parseCommit(
-              rebaseUtil.findBaseRevision(
-                  originalPatchSet,
-                  ctl.getChange().getDest(),
-                  ctx.getRepository(),
-                  ctx.getRevWalk()));
-    }
+    RevCommit baseCommit = rw.parseCommit(baseCommitId);
 
     String newCommitMessage;
     if (detailedCommitMessage) {
@@ -169,16 +155,15 @@
     }
 
     rebasedCommit = rebaseCommit(ctx, original, baseCommit, newCommitMessage);
-
-    RevId baseRevId =
-        new RevId((baseCommitish != null) ? baseCommitish : ObjectId.toString(baseCommit.getId()));
     Base base =
         rebaseUtil.parseBase(
             new RevisionResource(changeResourceFactory.create(ctl), originalPatchSet),
-            baseRevId.get());
+            baseCommitId.name());
 
     rebasedPatchSetId =
-        ChangeUtil.nextPatchSetId(ctx.getRepository(), ctl.getChange().currentPatchSetId());
+        ChangeUtil.nextPatchSetIdFromChangeRefsMap(
+            ctx.getRepoView().getRefs(originalPatchSet.getId().getParentKey().toRefPrefix()),
+            ctl.getChange().currentPatchSetId());
     patchSetInserter =
         patchSetInserterFactory
             .create(ctl, rebasedPatchSetId, rebasedCommit)
@@ -187,7 +172,8 @@
             .setNotify(NotifyHandling.NONE)
             .setFireRevisionCreated(fireRevisionCreated)
             .setCopyApprovals(copyApprovals)
-            .setCheckAddPatchSetPermission(checkAddPatchSetPermission);
+            .setCheckAddPatchSetPermission(checkAddPatchSetPermission)
+            .setValidate(validate);
     if (postMessage) {
       patchSetInserter.setMessage(
           "Patch Set "
@@ -200,9 +186,6 @@
     if (base != null) {
       patchSetInserter.setGroups(base.patchSet().getGroups());
     }
-    if (validate != null) {
-      patchSetInserter.setValidatePolicy(validate);
-    }
     patchSetInserter.updateRepo(ctx);
   }
 
@@ -261,7 +244,7 @@
     }
 
     ThreeWayMerger merger =
-        newMergeUtil().newThreeWayMerger(ctx.getRepository(), ctx.getInserter());
+        newMergeUtil().newThreeWayMerger(ctx.getInserter(), ctx.getRepoView().getConfig());
     merger.setBase(parentCommit);
     merger.merge(original, base);
 
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 b6c4d02..6f74ddd 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
@@ -18,10 +18,8 @@
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.RestoreInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Status;
@@ -29,16 +27,21 @@
 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.mail.send.ReplyToChangeSender;
 import com.google.gerrit.server.mail.send.RestoredSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -48,8 +51,8 @@
 import org.slf4j.LoggerFactory;
 
 @Singleton
-public class Restore
-    implements RestModifyView<ChangeResource, RestoreInput>, UiAction<ChangeResource> {
+public class Restore extends RetryingRestModifyView<ChangeResource, RestoreInput, ChangeInfo>
+    implements UiAction<ChangeResource> {
   private static final Logger log = LoggerFactory.getLogger(Restore.class);
 
   private final RestoredSender.Factory restoredSenderFactory;
@@ -57,7 +60,6 @@
   private final ChangeJson.Factory json;
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
-  private final BatchUpdate.Factory batchUpdateFactory;
   private final ChangeRestored changeRestored;
 
   @Inject
@@ -67,28 +69,27 @@
       ChangeJson.Factory json,
       ChangeMessagesUtil cmUtil,
       PatchSetUtil psUtil,
-      BatchUpdate.Factory batchUpdateFactory,
+      RetryHelper retryHelper,
       ChangeRestored changeRestored) {
+    super(retryHelper);
     this.restoredSenderFactory = restoredSenderFactory;
     this.dbProvider = dbProvider;
     this.json = json;
     this.cmUtil = cmUtil;
     this.psUtil = psUtil;
-    this.batchUpdateFactory = batchUpdateFactory;
     this.changeRestored = changeRestored;
   }
 
   @Override
-  public ChangeInfo apply(ChangeResource req, RestoreInput input)
-      throws RestApiException, UpdateException, OrmException {
-    ChangeControl ctl = req.getControl();
-    if (!ctl.canRestore(dbProvider.get())) {
-      throw new AuthException("restore not permitted");
-    }
+  protected ChangeInfo applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource req, RestoreInput input)
+      throws RestApiException, UpdateException, OrmException, PermissionBackendException {
+    req.permissions().database(dbProvider).check(ChangePermission.RESTORE);
 
+    ChangeControl ctl = req.getControl();
     Op op = new Op(input);
     try (BatchUpdate u =
-        batchUpdateFactory.create(
+        updateFactory.create(
             dbProvider.get(), req.getChange().getProject(), ctl.getUser(), TimeUtil.nowTs())) {
       u.addOp(req.getId(), op).execute();
     }
@@ -110,7 +111,7 @@
     public boolean updateChange(ChangeContext ctx) throws OrmException, ResourceConflictException {
       change = ctx.getChange();
       if (change == null || change.getStatus() != Status.ABANDONED) {
-        throw new ResourceConflictException("change is " + status(change));
+        throw new ResourceConflictException("change is " + ChangeUtil.status(change));
       }
       PatchSet.Id psId = change.currentPatchSetId();
       ChangeUpdate update = ctx.getUpdate(psId);
@@ -150,20 +151,12 @@
   }
 
   @Override
-  public UiAction.Description getDescription(ChangeResource resource) {
-    boolean canRestore = false;
-    try {
-      canRestore = resource.getControl().canRestore(dbProvider.get());
-    } catch (OrmException e) {
-      log.error("Cannot check canRestore status. Assuming false.", e);
-    }
+  public UiAction.Description getDescription(ChangeResource rsrc) {
     return new UiAction.Description()
         .setLabel("Restore")
         .setTitle("Restore the change")
-        .setVisible(resource.getChange().getStatus() == Status.ABANDONED && canRestore);
-  }
-
-  private static String status(Change change) {
-    return change != null ? change.getStatus().name().toLowerCase() : "deleted";
+        .setVisible(
+            rsrc.getChange().getStatus() == Status.ABANDONED
+                && rsrc.permissions().database(dbProvider).testOrFalse(ChangePermission.RESTORE));
   }
 }
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 14ba111..039aa9e 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
@@ -23,7 +23,6 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
@@ -34,13 +33,13 @@
 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;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.extensions.events.ChangeReverted;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.mail.send.RevertedSender;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
@@ -50,6 +49,8 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -74,15 +75,14 @@
 import org.slf4j.LoggerFactory;
 
 @Singleton
-public class Revert
-    implements RestModifyView<ChangeResource, RevertInput>, UiAction<ChangeResource> {
+public class Revert extends RetryingRestModifyView<ChangeResource, RevertInput, ChangeInfo>
+    implements UiAction<ChangeResource> {
   private static final Logger log = LoggerFactory.getLogger(Revert.class);
 
   private final Provider<ReviewDb> db;
   private final GitRepositoryManager repoManager;
   private final ChangeInserter.Factory changeInserterFactory;
   private final ChangeMessagesUtil cmUtil;
-  private final BatchUpdate.Factory updateFactory;
   private final Sequences seq;
   private final PatchSetUtil psUtil;
   private final RevertedSender.Factory revertedSenderFactory;
@@ -97,7 +97,7 @@
       GitRepositoryManager repoManager,
       ChangeInserter.Factory changeInserterFactory,
       ChangeMessagesUtil cmUtil,
-      BatchUpdate.Factory updateFactory,
+      RetryHelper retryHelper,
       Sequences seq,
       PatchSetUtil psUtil,
       RevertedSender.Factory revertedSenderFactory,
@@ -105,11 +105,11 @@
       @GerritPersonIdent PersonIdent serverIdent,
       ApprovalsUtil approvalsUtil,
       ChangeReverted changeReverted) {
+    super(retryHelper);
     this.db = db;
     this.repoManager = repoManager;
     this.changeInserterFactory = changeInserterFactory;
     this.cmUtil = cmUtil;
-    this.updateFactory = updateFactory;
     this.seq = seq;
     this.psUtil = psUtil;
     this.revertedSenderFactory = revertedSenderFactory;
@@ -120,7 +120,8 @@
   }
 
   @Override
-  public ChangeInfo apply(ChangeResource req, RevertInput input)
+  protected ChangeInfo applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource req, RevertInput input)
       throws IOException, OrmException, RestApiException, UpdateException, NoSuchChangeException {
     RefControl refControl = req.getControl().getRefControl();
     ProjectControl projectControl = req.getControl().getProjectControl();
@@ -134,14 +135,15 @@
     if (!refControl.canUpload()) {
       throw new AuthException("revert not permitted");
     } else if (change.getStatus() != Status.MERGED) {
-      throw new ResourceConflictException("change is " + status(change));
+      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
     }
 
-    Change.Id revertedChangeId = revert(req.getControl(), Strings.emptyToNull(input.message));
+    Change.Id revertedChangeId =
+        revert(updateFactory, req.getControl(), Strings.emptyToNull(input.message));
     return json.noOptions().format(req.getProject(), revertedChangeId);
   }
 
-  private Change.Id revert(ChangeControl ctl, String message)
+  private Change.Id revert(BatchUpdate.Factory updateFactory, ChangeControl ctl, String message)
       throws OrmException, IOException, RestApiException, UpdateException {
     Change.Id changeIdToRevert = ctl.getChange().getId();
     PatchSet.Id patchSetId = ctl.getChange().currentPatchSetId();
@@ -201,7 +203,6 @@
       ChangeInserter ins =
           changeInserterFactory
               .create(changeId, revertCommit, ctl.getChange().getDest().get())
-              .setValidatePolicy(CommitValidators.Policy.GERRIT)
               .setTopic(changeToRevert.getTopic());
       ins.setMessage("Uploaded patch set 1.");
 
@@ -234,10 +235,6 @@
                 && resource.getControl().getRefControl().canUpload());
   }
 
-  private static String status(Change change) {
-    return change != null ? change.getStatus().name().toLowerCase() : "deleted";
-  }
-
   private class NotifyOp implements BatchUpdateOp {
     private final Change change;
     private final ChangeInserter ins;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java
index ac7f15e..33c773a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -20,7 +20,6 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
@@ -30,6 +29,9 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.permissions.LabelPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -44,6 +46,7 @@
 @Singleton
 public class ReviewerJson {
   private final Provider<ReviewDb> db;
+  private final PermissionBackend permissionBackend;
   private final ChangeData.Factory changeDataFactory;
   private final ApprovalsUtil approvalsUtil;
   private final AccountLoader.Factory accountLoaderFactory;
@@ -51,23 +54,31 @@
   @Inject
   ReviewerJson(
       Provider<ReviewDb> db,
+      PermissionBackend permissionBackend,
       ChangeData.Factory changeDataFactory,
       ApprovalsUtil approvalsUtil,
       AccountLoader.Factory accountLoaderFactory) {
     this.db = db;
+    this.permissionBackend = permissionBackend;
     this.changeDataFactory = changeDataFactory;
     this.approvalsUtil = approvalsUtil;
     this.accountLoaderFactory = accountLoaderFactory;
   }
 
-  public List<ReviewerInfo> format(Collection<ReviewerResource> rsrcs) throws OrmException {
+  public List<ReviewerInfo> format(Collection<ReviewerResource> rsrcs)
+      throws OrmException, PermissionBackendException {
     List<ReviewerInfo> infos = Lists.newArrayListWithCapacity(rsrcs.size());
     AccountLoader loader = accountLoaderFactory.create(true);
+    ChangeData cd = null;
     for (ReviewerResource rsrc : rsrcs) {
+      if (cd == null || !cd.getId().equals(rsrc.getChangeId())) {
+        cd = changeDataFactory.create(db.get(), rsrc.getControl().getNotes());
+      }
       ReviewerInfo info =
           format(
               new ReviewerInfo(rsrc.getReviewerUser().getAccountId().get()),
-              rsrc.getReviewerControl());
+              permissionBackend.user(rsrc.getReviewerUser()).database(db).change(cd),
+              cd);
       loader.put(info);
       infos.add(info);
     }
@@ -75,27 +86,34 @@
     return infos;
   }
 
-  public List<ReviewerInfo> format(ReviewerResource rsrc) throws OrmException {
+  public List<ReviewerInfo> format(ReviewerResource rsrc)
+      throws OrmException, PermissionBackendException {
     return format(ImmutableList.<ReviewerResource>of(rsrc));
   }
 
-  public ReviewerInfo format(ReviewerInfo out, ChangeControl ctl) throws OrmException {
-    PatchSet.Id psId = ctl.getChange().currentPatchSetId();
+  public ReviewerInfo format(ReviewerInfo out, PermissionBackend.ForChange perm, ChangeData cd)
+      throws OrmException, PermissionBackendException {
+    PatchSet.Id psId = cd.change().currentPatchSetId();
+    ChangeControl ctl = cd.changeControl().forUser(perm.user());
     return format(
         out,
-        ctl,
+        perm,
+        cd,
         approvalsUtil.byPatchSetUser(db.get(), ctl, psId, new Account.Id(out._accountId)));
   }
 
   public ReviewerInfo format(
-      ReviewerInfo out, ChangeControl ctl, Iterable<PatchSetApproval> approvals)
-      throws OrmException {
-    LabelTypes labelTypes = ctl.getLabelTypes();
+      ReviewerInfo out,
+      PermissionBackend.ForChange perm,
+      ChangeData cd,
+      Iterable<PatchSetApproval> approvals)
+      throws OrmException, PermissionBackendException {
+    LabelTypes labelTypes = cd.getLabelTypes();
 
     // Don't use Maps.newTreeMap(Comparator) due to OpenJDK bug 100167.
     out.approvals = new TreeMap<>(labelTypes.nameComparator());
     for (PatchSetApproval ca : approvals) {
-      for (PermissionRange pr : ctl.getLabelRanges()) {
+      for (PermissionRange pr : cd.changeControl().getLabelRanges()) {
         if (!pr.isEmpty()) {
           LabelType at = labelTypes.byLabel(ca.getLabelId());
           if (at != null) {
@@ -107,7 +125,6 @@
 
     // Add dummy approvals for all permitted labels for the user even if they
     // do not exist in the DB.
-    ChangeData cd = changeDataFactory.create(db.get(), ctl);
     PatchSet ps = cd.currentPatchSet();
     if (ps != null) {
       for (SubmitRecord rec :
@@ -117,8 +134,10 @@
         }
         for (SubmitRecord.Label label : rec.labels) {
           String name = label.label;
+          LabelType type = labelTypes.byLabel(name);
           if (!out.approvals.containsKey(name)
-              && !ctl.getRange(Permission.forLabel(name)).isEmpty()) {
+              && type != null
+              && perm.test(new LabelPermission(type))) {
             out.approvals.put(name, formatValue((short) 0));
           }
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java
index 6ff4a50..f6f7919 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java
@@ -14,11 +14,15 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.inject.TypeLiteral;
 import com.google.inject.assistedinject.Assisted;
@@ -36,7 +40,8 @@
 
   private final ChangeResource change;
   private final RevisionResource revision;
-  private final IdentifiedUser user;
+  @Nullable private final IdentifiedUser user;
+  @Nullable private final Address address;
 
   @AssistedInject
   ReviewerResource(
@@ -44,8 +49,9 @@
       @Assisted ChangeResource change,
       @Assisted Account.Id id) {
     this.change = change;
-    this.revision = null;
     this.user = userFactory.create(id);
+    this.revision = null;
+    this.address = null;
   }
 
   @AssistedInject
@@ -56,6 +62,21 @@
     this.revision = revision;
     this.change = revision.getChangeResource();
     this.user = userFactory.create(id);
+    this.address = null;
+  }
+
+  ReviewerResource(ChangeResource change, Address address) {
+    this.change = change;
+    this.address = address;
+    this.revision = null;
+    this.user = null;
+  }
+
+  ReviewerResource(RevisionResource revision, Address address) {
+    this.revision = revision;
+    this.change = revision.getChangeResource();
+    this.address = address;
+    this.user = null;
   }
 
   public ChangeResource getChangeResource() {
@@ -75,10 +96,28 @@
   }
 
   public IdentifiedUser getReviewerUser() {
+    checkArgument(user != null, "no user provided");
     return user;
   }
 
+  public Address getReviewerByEmail() {
+    checkArgument(address != null, "no address provided");
+    return address;
+  }
+
   /**
+   * Check if this resource was constructed by email or by {@code Account.Id}.
+   *
+   * @return true if the resource was constructed by providing an {@code Address}; false if the
+   *     resource was constructed by providing an {@code Account.Id}.
+   */
+  public boolean isByEmail() {
+    return user == null;
+  }
+
+  /**
+   * Get the control for the caller's user.
+   *
    * @return the control for the caller's user (as opposed to the reviewer's user as returned by
    *     {@link #getReviewerControl()}).
    */
@@ -87,10 +126,13 @@
   }
 
   /**
+   * Get the control for the reviewer's user.
+   *
    * @return the control for the reviewer's user (as opposed to the caller's user as returned by
    *     {@link #getControl()}).
    */
   public ChangeControl getReviewerControl() {
+    checkArgument(user != null, "no user provided");
     return change.getControl().forUser(user);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewers.java
index 14c74bc..0762f0e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewers.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountsCollection;
+import com.google.gerrit.server.mail.Address;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -69,12 +70,26 @@
   @Override
   public ReviewerResource parse(ChangeResource rsrc, IdString id)
       throws OrmException, ResourceNotFoundException, AuthException {
-    Account.Id accountId = accounts.parse(TopLevelResource.INSTANCE, id).getUser().getAccountId();
+    Address address = Address.tryParse(id.get());
 
+    Account.Id accountId = null;
+    try {
+      accountId = accounts.parse(TopLevelResource.INSTANCE, id).getUser().getAccountId();
+    } catch (ResourceNotFoundException e) {
+      if (address == null) {
+        throw e;
+      }
+    }
     // See if the id exists as a reviewer for this change
-    if (fetchAccountIds(rsrc).contains(accountId)) {
+    if (accountId != null && fetchAccountIds(rsrc).contains(accountId)) {
       return resourceFactory.create(rsrc, accountId);
     }
+
+    // See if the address exists as a reviewer on the change
+    if (address != null && rsrc.getNotes().getReviewersByEmail().all().contains(address)) {
+      return new ReviewerResource(rsrc, address);
+    }
+
     throw new ResourceNotFoundException(id);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionResource.java
index 4d35f9e..32132bc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionResource.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.inject.TypeLiteral;
 import java.util.Optional;
@@ -51,6 +52,10 @@
     return cacheable;
   }
 
+  public PermissionBackend.ForChange permissions() {
+    return change.permissions();
+  }
+
   public ChangeResource getChangeResource() {
     return change;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionReviewers.java
index d3623cf..2dc7ad8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionReviewers.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountsCollection;
+import com.google.gerrit.server.mail.Address;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -73,14 +74,28 @@
     if (!rsrc.isCurrent()) {
       throw new MethodNotAllowedException("Cannot access on non-current patch set");
     }
+    Address address = Address.tryParse(id.get());
 
-    Account.Id accountId = accounts.parse(TopLevelResource.INSTANCE, id).getUser().getAccountId();
-
+    Account.Id accountId = null;
+    try {
+      accountId = accounts.parse(TopLevelResource.INSTANCE, id).getUser().getAccountId();
+    } catch (ResourceNotFoundException e) {
+      if (address == null) {
+        throw e;
+      }
+    }
     Collection<Account.Id> reviewers =
         approvalsUtil.getReviewers(dbProvider.get(), rsrc.getNotes()).all();
+    // See if the id exists as a reviewer for this change
     if (reviewers.contains(accountId)) {
       return resourceFactory.create(rsrc, accountId);
     }
+
+    // See if the address exists as a reviewer on the change
+    if (address != null && rsrc.getNotes().getReviewersByEmail().all().contains(address)) {
+      return new ReviewerResource(rsrc, address);
+    }
+
     throw new ResourceNotFoundException(id);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revisions.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Revisions.java
index a16f2f9..5ecb904 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revisions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Revisions.java
@@ -37,6 +37,7 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 
 @Singleton
 public class Revisions implements ChildCollection<ChangeResource, RevisionResource> {
@@ -143,8 +144,9 @@
     Optional<ChangeEdit> edit = editUtil.byChange(change.getChange());
     if (edit.isPresent()) {
       PatchSet ps = new PatchSet(new PatchSet.Id(change.getId(), 0));
-      ps.setRevision(edit.get().getRevision());
-      if (revid == null || edit.get().getRevision().equals(revid)) {
+      RevId editRevId = new RevId(ObjectId.toString(edit.get().getEditCommit()));
+      ps.setRevision(editRevId);
+      if (revid == null || editRevId.equals(revid)) {
         return Collections.singletonList(new RevisionResource(change, ps, edit));
       }
     }
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 409be9d..73a6c60 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
@@ -17,16 +17,12 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.restapi.AuthException;
 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.IdentifiedUser;
-import com.google.gerrit.server.account.AccountsCollection;
 import com.google.gerrit.server.extensions.events.AssigneeChanged;
 import com.google.gerrit.server.mail.send.SetAssigneeSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -36,9 +32,9 @@
 import com.google.gerrit.server.validators.AssigneeValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -46,93 +42,74 @@
   private static final Logger log = LoggerFactory.getLogger(SetAssigneeOp.class);
 
   public interface Factory {
-    SetAssigneeOp create(String assignee);
+    SetAssigneeOp create(IdentifiedUser assignee);
   }
 
-  private final AccountsCollection accounts;
   private final ChangeMessagesUtil cmUtil;
   private final DynamicSet<AssigneeValidationListener> validationListeners;
-  private final String assignee;
+  private final IdentifiedUser newAssignee;
   private final AssigneeChanged assigneeChanged;
   private final SetAssigneeSender.Factory setAssigneeSenderFactory;
   private final Provider<IdentifiedUser> user;
   private final IdentifiedUser.GenericFactory userFactory;
 
   private Change change;
-  private Account newAssignee;
-  private Account oldAssignee;
+  private IdentifiedUser oldAssignee;
 
-  @AssistedInject
+  @Inject
   SetAssigneeOp(
-      AccountsCollection accounts,
       ChangeMessagesUtil cmUtil,
       DynamicSet<AssigneeValidationListener> validationListeners,
       AssigneeChanged assigneeChanged,
       SetAssigneeSender.Factory setAssigneeSenderFactory,
       Provider<IdentifiedUser> user,
       IdentifiedUser.GenericFactory userFactory,
-      @Assisted String assignee) {
-    this.accounts = accounts;
+      @Assisted IdentifiedUser newAssignee) {
     this.cmUtil = cmUtil;
     this.validationListeners = validationListeners;
     this.assigneeChanged = assigneeChanged;
     this.setAssigneeSenderFactory = setAssigneeSenderFactory;
     this.user = user;
     this.userFactory = userFactory;
-    this.assignee = checkNotNull(assignee);
+    this.newAssignee = checkNotNull(newAssignee, "assignee");
   }
 
   @Override
   public boolean updateChange(ChangeContext ctx) throws OrmException, RestApiException {
     change = ctx.getChange();
-    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
-    IdentifiedUser newAssigneeUser = accounts.parse(assignee);
-    newAssignee = newAssigneeUser.getAccount();
-    IdentifiedUser oldAssigneeUser = null;
-    if (change.getAssignee() != null) {
-      oldAssigneeUser = userFactory.create(change.getAssignee());
-      oldAssignee = oldAssigneeUser.getAccount();
-      if (newAssignee.equals(oldAssignee)) {
-        return false;
-      }
-    }
-    if (!newAssignee.isActive()) {
-      throw new UnprocessableEntityException(
-          String.format("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(), assignee));
+    if (newAssignee.getAccountId().equals(change.getAssignee())) {
+      return false;
     }
     try {
       for (AssigneeValidationListener validator : validationListeners) {
-        validator.validateAssignee(change, newAssignee);
+        validator.validateAssignee(change, newAssignee.getAccount());
       }
     } catch (ValidationException e) {
       throw new ResourceConflictException(e.getMessage());
     }
+
+    if (change.getAssignee() != null) {
+      oldAssignee = userFactory.create(change.getAssignee());
+    }
+
+    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
     // notedb
-    update.setAssignee(newAssignee.getId());
+    update.setAssignee(newAssignee.getAccountId());
     // reviewdb
-    change.setAssignee(newAssignee.getId());
-    addMessage(ctx, update, oldAssigneeUser, newAssigneeUser);
+    change.setAssignee(newAssignee.getAccountId());
+    addMessage(ctx, update);
     return true;
   }
 
-  private void addMessage(
-      ChangeContext ctx,
-      ChangeUpdate update,
-      IdentifiedUser previousAssignee,
-      IdentifiedUser newAssignee)
-      throws OrmException {
+  private void addMessage(ChangeContext ctx, ChangeUpdate update) throws OrmException {
     StringBuilder msg = new StringBuilder();
     msg.append("Assignee ");
-    if (previousAssignee == null) {
+    if (oldAssignee == null) {
       msg.append("added: ");
       msg.append(newAssignee.getNameEmail());
     } else {
       msg.append("changed from: ");
-      msg.append(previousAssignee.getNameEmail());
+      msg.append(oldAssignee.getNameEmail());
       msg.append(" to: ");
       msg.append(newAssignee.getNameEmail());
     }
@@ -145,16 +122,17 @@
   public void postUpdate(Context ctx) throws OrmException {
     try {
       SetAssigneeSender cm =
-          setAssigneeSenderFactory.create(change.getProject(), change.getId(), newAssignee.getId());
+          setAssigneeSenderFactory.create(
+              change.getProject(), change.getId(), newAssignee.getAccountId());
       cm.setFrom(user.get().getAccountId());
       cm.send();
     } catch (Exception err) {
       log.error("Cannot send email to new assignee of change " + change.getId(), err);
     }
-    assigneeChanged.fire(change, ctx.getAccount(), oldAssignee, ctx.getWhen());
-  }
-
-  public Account.Id getNewAssignee() {
-    return newAssignee != null ? newAssignee.getId() : null;
+    assigneeChanged.fire(
+        change,
+        ctx.getAccount(),
+        oldAssignee != null ? oldAssignee.getAccount() : null,
+        ctx.getWhen());
   }
 }
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 0e78c18..724598a 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
@@ -39,8 +39,8 @@
 import com.google.gerrit.server.validators.HashtagValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.HashSet;
@@ -64,7 +64,7 @@
   private Set<String> toRemove;
   private ImmutableSortedSet<String> updatedHashtags;
 
-  @AssistedInject
+  @Inject
   SetHashtagsOp(
       NotesMigration notesMigration,
       ChangeMessagesUtil cmUtil,
@@ -94,9 +94,7 @@
       updatedHashtags = ImmutableSortedSet.of();
       return false;
     }
-    if (!ctx.getControl().canEditHashtags()) {
-      throw new AuthException("Editing hashtags not permitted");
-    }
+
     change = ctx.getChange();
     ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
     ChangeNotes notes = update.getNotes().load();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetPrivateOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetPrivateOp.java
new file mode 100644
index 0000000..d0bb70b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetPrivateOp.java
@@ -0,0 +1,78 @@
+// Copyright (C) 2017 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.common.base.Strings;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+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.notedb.ChangeUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gwtorm.server.OrmException;
+
+public class SetPrivateOp implements BatchUpdateOp {
+  public static class Input {
+    String message;
+
+    public Input() {}
+
+    public Input(String message) {
+      this.message = message;
+    }
+  }
+
+  private final ChangeMessagesUtil cmUtil;
+  private final boolean isPrivate;
+  private final Input input;
+
+  SetPrivateOp(ChangeMessagesUtil cmUtil, boolean isPrivate, Input input) {
+    this.cmUtil = cmUtil;
+    this.isPrivate = isPrivate;
+    this.input = input;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws ResourceConflictException, OrmException {
+    Change change = ctx.getChange();
+    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+    change.setPrivate(isPrivate);
+    change.setLastUpdatedOn(ctx.getWhen());
+    update.setPrivate(isPrivate);
+    addMessage(ctx, update);
+    return true;
+  }
+
+  private void addMessage(ChangeContext ctx, ChangeUpdate update) throws OrmException {
+    Change c = ctx.getChange();
+    StringBuilder buf = new StringBuilder(c.isPrivate() ? "Set private" : "Unset private");
+
+    String m = Strings.nullToEmpty(input == null ? null : input.message).trim();
+    if (!m.isEmpty()) {
+      buf.append("\n\n");
+      buf.append(m);
+    }
+
+    ChangeMessage cmsg =
+        ChangeMessagesUtil.newMessage(
+            ctx,
+            buf.toString(),
+            c.isPrivate()
+                ? ChangeMessagesUtil.TAG_SET_PRIVATE
+                : ChangeMessagesUtil.TAG_UNSET_PRIVATE);
+    cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetReadyForReview.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetReadyForReview.java
new file mode 100644
index 0000000..ef174ac
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetReadyForReview.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2017 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.TimeUtil;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Status;
+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.WorkInProgressOp.Input;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class SetReadyForReview extends RetryingRestModifyView<ChangeResource, Input, Response<?>>
+    implements UiAction<ChangeResource> {
+  private final ChangeMessagesUtil cmUtil;
+  private final Provider<ReviewDb> db;
+
+  @Inject
+  SetReadyForReview(RetryHelper retryHelper, ChangeMessagesUtil cmUtil, Provider<ReviewDb> db) {
+    super(retryHelper);
+    this.cmUtil = cmUtil;
+    this.db = db;
+  }
+
+  @Override
+  protected Response<?> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
+      throws RestApiException, UpdateException {
+    Change change = rsrc.getChange();
+    if (!rsrc.isUserOwner()) {
+      throw new AuthException("not allowed to set ready for review");
+    }
+
+    if (change.getStatus() != Status.NEW) {
+      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
+    }
+
+    if (!change.isWorkInProgress()) {
+      throw new ResourceConflictException("change is not work in progress");
+    }
+
+    try (BatchUpdate bu =
+        updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      bu.addOp(rsrc.getChange().getId(), new WorkInProgressOp(cmUtil, false, input));
+      bu.execute();
+      return Response.ok("");
+    }
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new Description()
+        .setLabel("Ready")
+        .setTitle("Set Ready For Review")
+        .setVisible(
+            rsrc.isUserOwner()
+                && rsrc.getChange().getStatus() == Status.NEW
+                && rsrc.getChange().isWorkInProgress());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetWorkInProgress.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetWorkInProgress.java
new file mode 100644
index 0000000..2b481a4
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetWorkInProgress.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2017 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.TimeUtil;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Status;
+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.WorkInProgressOp.Input;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class SetWorkInProgress extends RetryingRestModifyView<ChangeResource, Input, Response<?>>
+    implements UiAction<ChangeResource> {
+  private final ChangeMessagesUtil cmUtil;
+  private final Provider<ReviewDb> db;
+
+  @Inject
+  SetWorkInProgress(RetryHelper retryHelper, ChangeMessagesUtil cmUtil, Provider<ReviewDb> db) {
+    super(retryHelper);
+    this.cmUtil = cmUtil;
+    this.db = db;
+  }
+
+  @Override
+  protected Response<?> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
+      throws RestApiException, UpdateException {
+    Change change = rsrc.getChange();
+    if (!rsrc.isUserOwner()) {
+      throw new AuthException("not allowed to set work in progress");
+    }
+
+    if (change.getStatus() != Status.NEW) {
+      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
+    }
+
+    if (change.isWorkInProgress()) {
+      throw new ResourceConflictException("change is already work in progress");
+    }
+
+    try (BatchUpdate bu =
+        updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      bu.addOp(rsrc.getChange().getId(), new WorkInProgressOp(cmUtil, true, input));
+      bu.execute();
+      return Response.ok("");
+    }
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new Description()
+        .setLabel("WIP")
+        .setTitle("Set Work In Progress")
+        .setVisible(
+            rsrc.isUserOwner()
+                && rsrc.getChange().getStatus() == Status.NEW
+                && !rsrc.getChange().isWorkInProgress());
+  }
+}
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 01020fa..b1b980f 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
@@ -40,6 +40,7 @@
 import com.google.gerrit.reviewdb.client.RevId;
 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.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
@@ -51,7 +52,9 @@
 import com.google.gerrit.server.git.MergeOp;
 import com.google.gerrit.server.git.MergeSuperSet;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
@@ -62,6 +65,7 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Collection;
+import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -93,6 +97,7 @@
       "This change depends on other changes which are not ready";
   private static final String BLOCKED_HIDDEN_SUBMIT_TOOLTIP =
       "This change depends on other hidden changes which are not ready";
+  private static final String BLOCKED_WORK_IN_PROGRESS = "This change is marked work in progress";
   private static final String CLICK_FAILURE_TOOLTIP = "Clicking the button would fail";
   private static final String CHANGE_UNMERGEABLE = "Problems with integrating this change";
   private static final String CHANGES_NOT_MERGEABLE = "Problems with change(s): ";
@@ -122,13 +127,13 @@
 
   private final Provider<ReviewDb> dbProvider;
   private final GitRepositoryManager repoManager;
+  private final PermissionBackend permissionBackend;
   private final ChangeData.Factory changeDataFactory;
   private final ChangeMessagesUtil cmUtil;
   private final ChangeNotes.Factory changeNotesFactory;
   private final Provider<MergeOp> mergeOpProvider;
   private final Provider<MergeSuperSet> mergeSuperSet;
   private final AccountsCollection accounts;
-  private final ChangesCollection changes;
   private final String label;
   private final String labelWithParents;
   private final ParameterizedString titlePattern;
@@ -143,25 +148,25 @@
   Submit(
       Provider<ReviewDb> dbProvider,
       GitRepositoryManager repoManager,
+      PermissionBackend permissionBackend,
       ChangeData.Factory changeDataFactory,
       ChangeMessagesUtil cmUtil,
       ChangeNotes.Factory changeNotesFactory,
       Provider<MergeOp> mergeOpProvider,
       Provider<MergeSuperSet> mergeSuperSet,
       AccountsCollection accounts,
-      ChangesCollection changes,
       @GerritServerConfig Config cfg,
       Provider<InternalChangeQuery> queryProvider,
       PatchSetUtil psUtil) {
     this.dbProvider = dbProvider;
     this.repoManager = repoManager;
+    this.permissionBackend = permissionBackend;
     this.changeDataFactory = changeDataFactory;
     this.cmUtil = cmUtil;
     this.changeNotesFactory = changeNotesFactory;
     this.mergeOpProvider = mergeOpProvider;
     this.mergeSuperSet = mergeSuperSet;
     this.accounts = accounts;
-    this.changes = changes;
     this.label =
         MoreObjects.firstNonNull(
             Strings.emptyToNull(cfg.getString("change", null, "submitLabel")), "Submit");
@@ -193,18 +198,25 @@
 
   @Override
   public Output apply(RevisionResource rsrc, SubmitInput input)
-      throws RestApiException, RepositoryNotFoundException, IOException, OrmException {
+      throws RestApiException, RepositoryNotFoundException, IOException, OrmException,
+          PermissionBackendException {
     input.onBehalfOf = Strings.emptyToNull(input.onBehalfOf);
+    IdentifiedUser submitter;
     if (input.onBehalfOf != null) {
-      rsrc = onBehalfOf(rsrc, input);
+      submitter = onBehalfOf(rsrc, input);
+    } else {
+      rsrc.permissions().check(ChangePermission.SUBMIT);
+      submitter = rsrc.getUser().asIdentifiedUser();
     }
-    ChangeControl control = rsrc.getControl();
-    IdentifiedUser caller = control.getUser().asIdentifiedUser();
+
+    return new Output(mergeChange(rsrc, submitter, input));
+  }
+
+  public Change mergeChange(RevisionResource rsrc, IdentifiedUser submitter, SubmitInput input)
+      throws OrmException, RestApiException, IOException {
     Change change = rsrc.getChange();
-    if (input.onBehalfOf == null && !control.canSubmit()) {
-      throw new AuthException("submit not permitted");
-    } else if (!change.getStatus().isOpen()) {
-      throw new ResourceConflictException("change is " + status(change));
+    if (!change.getStatus().isOpen()) {
+      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
     } else if (!ProjectUtil.branchExists(repoManager, change.getDest())) {
       throw new ResourceConflictException(
           String.format("destination branch \"%s\" not found.", change.getDest().get()));
@@ -217,7 +229,7 @@
 
     try (MergeOp op = mergeOpProvider.get()) {
       ReviewDb db = dbProvider.get();
-      op.merge(db, change, caller, true, input, false);
+      op.merge(db, change, submitter, true, input, false);
       try {
         change =
             changeNotesFactory.createChecked(db, change.getProject(), change.getId()).getChange();
@@ -228,7 +240,7 @@
 
     switch (change.getStatus()) {
       case MERGED:
-        return new Output(change);
+        return change;
       case NEW:
         ChangeMessage msg = getConflictMessage(rsrc);
         if (msg != null) {
@@ -238,7 +250,7 @@
       case ABANDONED:
       case DRAFT:
       default:
-        throw new ResourceConflictException("change is " + status(change));
+        throw new ResourceConflictException("change is " + ChangeUtil.status(change));
     }
   }
 
@@ -250,20 +262,25 @@
    */
   private String problemsForSubmittingChangeset(ChangeData cd, ChangeSet cs, CurrentUser user) {
     try {
-      @SuppressWarnings("resource")
-      ReviewDb db = dbProvider.get();
       if (cs.furtherHiddenChanges()) {
         return BLOCKED_HIDDEN_SUBMIT_TOOLTIP;
       }
       for (ChangeData c : cs.changes()) {
-        ChangeControl changeControl = c.changeControl(user);
-
-        if (!changeControl.isVisible(db)) {
+        Set<ChangePermission> can =
+            permissionBackend
+                .user(user)
+                .database(dbProvider)
+                .change(c)
+                .test(EnumSet.of(ChangePermission.READ, ChangePermission.SUBMIT));
+        if (!can.contains(ChangePermission.READ)) {
           return BLOCKED_HIDDEN_SUBMIT_TOOLTIP;
         }
-        if (!changeControl.canSubmit()) {
+        if (!can.contains(ChangePermission.SUBMIT)) {
           return BLOCKED_SUBMIT_TOOLTIP;
         }
+        if (c.change().isWorkInProgress()) {
+          return BLOCKED_WORK_IN_PROGRESS;
+        }
         MergeOp.checkSubmitRule(c);
       }
 
@@ -281,7 +298,7 @@
       }
     } catch (ResourceConflictException e) {
       return BLOCKED_SUBMIT_TOOLTIP;
-    } catch (OrmException | IOException e) {
+    } catch (PermissionBackendException | OrmException | IOException e) {
       log.error("Error checking if change is submittable", e);
       throw new OrmRuntimeException("Could not determine problems for the change", e);
     }
@@ -290,20 +307,23 @@
 
   @Override
   public UiAction.Description getDescription(RevisionResource resource) {
-    PatchSet.Id current = resource.getChange().currentPatchSetId();
-    String topic = resource.getChange().getTopic();
-    boolean visible =
-        !resource.getPatchSet().isDraft()
-            && resource.getChange().getStatus().isOpen()
-            && resource.getPatchSet().getId().equals(current)
-            && resource.getControl().canSubmit();
+    Change change = resource.getChange();
+    String topic = change.getTopic();
     ReviewDb db = dbProvider.get();
     ChangeData cd = changeDataFactory.create(db, resource.getControl());
-
+    boolean visible;
     try {
+      visible =
+          change.getStatus().isOpen()
+              && resource.isCurrent()
+              && !resource.getPatchSet().isDraft()
+              && resource.permissions().test(ChangePermission.SUBMIT);
       MergeOp.checkSubmitRule(cd);
     } catch (ResourceConflictException e) {
       visible = false;
+    } catch (PermissionBackendException e) {
+      log.error("Error checking if change is submittable", e);
+      throw new OrmRuntimeException("Could not check submit permission", e);
     } catch (OrmException e) {
       log.error("Error checking if change is submittable", e);
       throw new OrmRuntimeException("Could not determine problems for the change", e);
@@ -367,7 +387,7 @@
     Map<String, String> params =
         ImmutableMap.of(
             "patchSet", String.valueOf(resource.getPatchSet().getPatchSetId()),
-            "branch", resource.getChange().getDest().getShortName(),
+            "branch", change.getDest().getShortName(),
             "commit", ObjectId.fromString(revId.get()).abbreviate(7).name(),
             "submitSize", String.valueOf(cs.size()));
     ParameterizedString tp = cs.size() > 1 ? titlePatternWithAncestors : titlePattern;
@@ -390,10 +410,6 @@
         .orNull();
   }
 
-  static String status(Change change) {
-    return change != null ? change.getStatus().name().toLowerCase() : "deleted";
-  }
-
   public Collection<ChangeData> unmergeableChanges(ChangeSet cs) throws OrmException, IOException {
     Set<ChangeData> mergeabilityMap = new HashSet<>();
     for (ChangeData change : cs.changes()) {
@@ -458,24 +474,21 @@
     return commits;
   }
 
-  private RevisionResource onBehalfOf(RevisionResource rsrc, SubmitInput in)
-      throws AuthException, UnprocessableEntityException, OrmException {
-    ChangeControl caller = rsrc.getControl();
-    if (!caller.canSubmit()) {
-      throw new AuthException("submit not permitted");
-    }
-    if (!caller.canSubmitAs()) {
-      throw new AuthException("submit on behalf of not permitted");
-    }
-    ChangeControl target =
-        caller.forUser(accounts.parseOnBehalfOf(caller.getUser(), in.onBehalfOf));
-    if (!target.getRefControl().isVisible()) {
+  private IdentifiedUser onBehalfOf(RevisionResource rsrc, SubmitInput in)
+      throws AuthException, UnprocessableEntityException, OrmException, PermissionBackendException {
+    PermissionBackend.ForChange perm = rsrc.permissions().database(dbProvider);
+    perm.check(ChangePermission.SUBMIT);
+    perm.check(ChangePermission.SUBMIT_AS);
+
+    CurrentUser caller = rsrc.getUser();
+    IdentifiedUser submitter = accounts.parseOnBehalfOf(caller, in.onBehalfOf);
+    try {
+      perm.user(submitter).check(ChangePermission.READ);
+    } catch (AuthException e) {
       throw new UnprocessableEntityException(
-          String.format(
-              "on_behalf_of account %s cannot see destination ref",
-              target.getUser().getAccountId()));
+          String.format("on_behalf_of account %s cannot see change", submitter.getAccountId()));
     }
-    return new RevisionResource(changes.parse(target), rsrc.getPatchSet());
+    return submitter;
   }
 
   public static boolean wholeTopicEnabled(Config config) {
@@ -510,7 +523,8 @@
 
     @Override
     public ChangeInfo apply(ChangeResource rsrc, SubmitInput input)
-        throws RestApiException, RepositoryNotFoundException, IOException, OrmException {
+        throws RestApiException, RepositoryNotFoundException, IOException, OrmException,
+            PermissionBackendException {
       PatchSet ps = psUtil.current(dbProvider.get(), rsrc.getNotes());
       if (ps == null) {
         throw new ResourceConflictException("current revision is missing");
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 5260730..1daa7e3 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
@@ -27,6 +27,8 @@
 import com.google.gerrit.server.ReviewersUtil.VisibilityControl;
 import com.google.gerrit.server.account.AccountVisibility;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -45,6 +47,7 @@
   )
   boolean excludeGroups;
 
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> self;
 
   @Inject
@@ -52,10 +55,12 @@
       AccountVisibility av,
       GenericFactory identifiedUserFactory,
       Provider<ReviewDb> dbProvider,
+      PermissionBackend permissionBackend,
       Provider<CurrentUser> self,
       @GerritServerConfig Config cfg,
       ReviewersUtil reviewersUtil) {
     super(av, identifiedUserFactory, dbProvider, cfg, reviewersUtil);
+    this.permissionBackend = permissionBackend;
     this.self = self;
   }
 
@@ -73,7 +78,7 @@
         excludeGroups);
   }
 
-  private VisibilityControl getVisibility(final ChangeResource rsrc) {
+  private VisibilityControl getVisibility(ChangeResource rsrc) {
     if (rsrc.getControl().getRefControl().isVisibleByRegisteredUsers()) {
       return new VisibilityControl() {
         @Override
@@ -82,13 +87,15 @@
         }
       };
     }
+
+    // Use the destination reference, not the change, as drafts may deny
+    // anyone who is not already a reviewer.
+    PermissionBackend.ForRef perm = permissionBackend.user(self).ref(rsrc.getChange().getDest());
     return new VisibilityControl() {
       @Override
       public boolean isVisibleTo(Account.Id account) throws OrmException {
         IdentifiedUser who = identifiedUserFactory.create(account);
-        // we can't use changeControl directly as it won't suggest reviewers
-        // to drafts
-        return rsrc.getControl().forUser(who).isRefVisible();
+        return perm.user(who).testOrFalse(RefPermission.READ);
       }
     };
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Unignore.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Unignore.java
new file mode 100644
index 0000000..081fc22
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Unignore.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class Unignore
+    implements RestModifyView<ChangeResource, Unignore.Input>, UiAction<ChangeResource> {
+  private static final Logger log = LoggerFactory.getLogger(Unignore.class);
+
+  public static class Input {}
+
+  private final Provider<IdentifiedUser> self;
+  private final StarredChangesUtil stars;
+
+  @Inject
+  Unignore(Provider<IdentifiedUser> self, StarredChangesUtil stars) {
+    this.self = self;
+    this.stars = stars;
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Unignore")
+        .setTitle("Unignore the change")
+        .setVisible(!rsrc.isUserOwner() && isIgnored(rsrc));
+  }
+
+  @Override
+  public Response<String> apply(ChangeResource rsrc, Input input) throws RestApiException {
+    try {
+      if (rsrc.isUserOwner() || !isIgnored(rsrc)) {
+        // early exit for own changes and not ignored changes
+        return Response.ok("");
+      }
+      stars.unignore(self.get().getAccountId(), rsrc.getProject(), rsrc.getChange().getId());
+    } catch (OrmException e) {
+      throw new RestApiException("failed to unignore change", e);
+    }
+    return Response.ok("");
+  }
+
+  private boolean isIgnored(ChangeResource rsrc) {
+    try {
+      return stars.isIgnoredBy(rsrc.getChange().getId(), self.get().getAccountId());
+    } catch (OrmException e) {
+      log.error("failed to check ignored star", e);
+    }
+    return false;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Unmute.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Unmute.java
new file mode 100644
index 0000000..49b41cb
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Unmute.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class Unmute
+    implements RestModifyView<ChangeResource, Unmute.Input>, UiAction<ChangeResource> {
+  private static final Logger log = LoggerFactory.getLogger(Unmute.class);
+
+  public static class Input {}
+
+  private final Provider<IdentifiedUser> self;
+  private final StarredChangesUtil stars;
+
+  @Inject
+  Unmute(Provider<IdentifiedUser> self, StarredChangesUtil stars) {
+    this.self = self;
+    this.stars = stars;
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Unmute")
+        .setTitle("Unmute the change")
+        .setVisible(!rsrc.isUserOwner() && isUnMuteable(rsrc.getChange()));
+  }
+
+  @Override
+  public Response<String> apply(ChangeResource rsrc, Input input) throws RestApiException {
+    try {
+      if (rsrc.isUserOwner() || !isMuted(rsrc.getChange())) {
+        // early exit for own changes and not muted changes
+        return Response.ok("");
+      }
+      stars.unmute(self.get().getAccountId(), rsrc.getProject(), rsrc.getChange());
+    } catch (OrmException e) {
+      throw new RestApiException("failed to unmute change", e);
+    }
+    return Response.ok("");
+  }
+
+  private boolean isMuted(Change change) {
+    try {
+      return stars.isMutedBy(change, self.get().getAccountId());
+    } catch (OrmException e) {
+      log.error("failed to check muted star", e);
+    }
+    return false;
+  }
+
+  private boolean isUnMuteable(Change change) {
+    try {
+      return isMuted(change) && !stars.isIgnoredBy(change.getId(), self.get().getAccountId());
+    } catch (OrmException e) {
+      log.error("failed to check ignored star", e);
+    }
+    return false;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/WorkInProgressOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/WorkInProgressOp.java
new file mode 100644
index 0000000..21e5dfa
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2017 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.common.base.Strings;
+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.notedb.ChangeUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gwtorm.server.OrmException;
+
+/* Set work in progress or ready for review state on a change */
+public class WorkInProgressOp implements BatchUpdateOp {
+  public static class Input {
+    String message;
+
+    public Input() {}
+
+    public Input(String message) {
+      this.message = message;
+    }
+  }
+
+  private final ChangeMessagesUtil cmUtil;
+  private final boolean workInProgress;
+  private final Input in;
+
+  WorkInProgressOp(ChangeMessagesUtil cmUtil, boolean workInProgress, Input in) {
+    this.cmUtil = cmUtil;
+    this.workInProgress = workInProgress;
+    this.in = in;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws OrmException {
+    Change change = ctx.getChange();
+    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+    change.setWorkInProgress(workInProgress);
+    change.setLastUpdatedOn(ctx.getWhen());
+    update.setWorkInProgress(workInProgress);
+    addMessage(ctx, update);
+    return true;
+  }
+
+  private void addMessage(ChangeContext ctx, ChangeUpdate update) throws OrmException {
+    Change c = ctx.getChange();
+    StringBuilder buf =
+        new StringBuilder(c.isWorkInProgress() ? "Set Work In Progress" : "Set Ready For Review");
+
+    String m = Strings.nullToEmpty(in == null ? null : in.message).trim();
+    if (!m.isEmpty()) {
+      buf.append("\n\n");
+      buf.append(m);
+    }
+
+    ChangeMessage cmsg =
+        ChangeMessagesUtil.newMessage(
+            ctx,
+            buf.toString(),
+            c.isWorkInProgress()
+                ? ChangeMessagesUtil.TAG_SET_WIP
+                : ChangeMessagesUtil.TAG_SET_READY);
+
+    cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
index 6cdb5e56..7b93277 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.server.config;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_MAILTO;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_UUID;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_UUID;
 
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.auth.openid.OpenIdProviderPattern;
 import com.google.gwtjsonrpc.server.SignedToken;
 import com.google.gwtjsonrpc.server.XsrfException;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CachesCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/CachesCollection.java
index f002f8d..1e88842 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/CachesCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/CachesCollection.java
@@ -27,8 +27,10 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -40,6 +42,7 @@
 
   private final DynamicMap<RestView<CacheResource>> views;
   private final Provider<ListCaches> list;
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> self;
   private final DynamicMap<Cache<?, ?>> cacheMap;
   private final PostCaches postCaches;
@@ -48,11 +51,13 @@
   CachesCollection(
       DynamicMap<RestView<CacheResource>> views,
       Provider<ListCaches> list,
+      PermissionBackend permissionBackend,
       Provider<CurrentUser> self,
       DynamicMap<Cache<?, ?>> cacheMap,
       PostCaches postCaches) {
     this.views = views;
     this.list = list;
+    this.permissionBackend = permissionBackend;
     this.self = self;
     this.cacheMap = cacheMap;
     this.postCaches = postCaches;
@@ -65,15 +70,8 @@
 
   @Override
   public CacheResource parse(ConfigResource parent, IdString id)
-      throws AuthException, ResourceNotFoundException {
-    CurrentUser user = self.get();
-    if (user instanceof AnonymousUser) {
-      throw new AuthException("Authentication required");
-    } else if (!user.isIdentifiedUser()) {
-      throw new ResourceNotFoundException();
-    } else if (!user.getCapabilities().canViewCaches()) {
-      throw new AuthException("not allowed to view caches");
-    }
+      throws AuthException, ResourceNotFoundException, PermissionBackendException {
+    permissionBackend.user(self).check(GlobalPermission.VIEW_CACHES);
 
     String cacheName = id.get();
     String pluginName = "gerrit";
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CheckAccess.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/CheckAccess.java
new file mode 100644
index 0000000..84db266
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/CheckAccess.java
@@ -0,0 +1,128 @@
+// Copyright (C) 2017 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.config;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.api.config.AccessCheckInfo;
+import com.google.gerrit.extensions.api.config.AccessCheckInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import javax.servlet.http.HttpServletResponse;
+
+@Singleton
+public class CheckAccess implements RestModifyView<ConfigResource, AccessCheckInput> {
+  private final Provider<IdentifiedUser> currentUser;
+  private final AccountResolver accountResolver;
+  private final Provider<ReviewDb> db;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final ProjectCache projectCache;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  CheckAccess(
+      Provider<IdentifiedUser> currentUser,
+      AccountResolver resolver,
+      Provider<ReviewDb> db,
+      IdentifiedUser.GenericFactory userFactory,
+      ProjectCache projectCache,
+      PermissionBackend permissionBackend) {
+    this.currentUser = currentUser;
+    this.accountResolver = resolver;
+    this.db = db;
+    this.userFactory = userFactory;
+    this.projectCache = projectCache;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  public AccessCheckInfo apply(ConfigResource unused, AccessCheckInput input)
+      throws OrmException, PermissionBackendException, RestApiException, IOException {
+    permissionBackend.user(currentUser.get()).check(GlobalPermission.ADMINISTRATE_SERVER);
+
+    if (input == null) {
+      throw new BadRequestException("input is required");
+    }
+    if (Strings.isNullOrEmpty(input.account)) {
+      throw new BadRequestException("input requires 'account'");
+    }
+    if (Strings.isNullOrEmpty(input.project)) {
+      throw new BadRequestException("input requires 'project'");
+    }
+
+    Account match = accountResolver.find(db.get(), input.account);
+    if (match == null) {
+      throw new BadRequestException(String.format("cannot find account %s", input.account));
+    }
+
+    AccessCheckInfo info = new AccessCheckInfo();
+
+    Project.NameKey key = new Project.NameKey(input.project);
+    if (projectCache.get(key) == null) {
+      info.message = String.format("project %s does not exist", key);
+      info.status = HttpServletResponse.SC_NOT_FOUND;
+      return info;
+    }
+
+    IdentifiedUser user = userFactory.create(match.getId());
+    try {
+      permissionBackend.user(user).project(key).check(ProjectPermission.ACCESS);
+    } catch (AuthException | PermissionBackendException e) {
+      info.message =
+          String.format(
+              "user %s (%s) cannot see project %s",
+              user.getNameEmail(), user.getAccount().getId(), key);
+      info.status = HttpServletResponse.SC_FORBIDDEN;
+      return info;
+    }
+
+    if (!Strings.isNullOrEmpty(input.ref)) {
+      try {
+        permissionBackend
+            .user(user)
+            .ref(new Branch.NameKey(key, input.ref))
+            .check(RefPermission.READ);
+      } catch (AuthException | PermissionBackendException e) {
+        info.status = HttpServletResponse.SC_FORBIDDEN;
+        info.message =
+            String.format(
+                "user %s (%s) cannot see ref %s in project %s",
+                user.getNameEmail(), user.getAccount().getId(), input.ref, key);
+        return info;
+      }
+    }
+
+    info.status = HttpServletResponse.SC_OK;
+    return info;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CheckConsistency.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/CheckConsistency.java
new file mode 100644
index 0000000..f424995
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/CheckConsistency.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2017 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.config;
+
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.CheckAccountExternalIdsResultInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.externalids.ExternalIdsConsistencyChecker;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class CheckConsistency implements RestModifyView<ConfigResource, ConsistencyCheckInput> {
+  private final Provider<IdentifiedUser> userProvider;
+  private final ExternalIdsConsistencyChecker externalIdsConsistencyChecker;
+
+  @Inject
+  CheckConsistency(
+      Provider<IdentifiedUser> currentUser,
+      ExternalIdsConsistencyChecker externalIdsConsistencyChecker) {
+    this.userProvider = currentUser;
+    this.externalIdsConsistencyChecker = externalIdsConsistencyChecker;
+  }
+
+  @Override
+  public ConsistencyCheckInfo apply(ConfigResource resource, ConsistencyCheckInput input)
+      throws RestApiException, IOException {
+    IdentifiedUser user = userProvider.get();
+    if (!user.isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+    if (!user.getCapabilities().canAccessDatabase()) {
+      throw new AuthException("not allowed to run consistency checks");
+    }
+
+    if (input == null || input.checkAccountExternalIds == null) {
+      throw new BadRequestException("input required");
+    }
+
+    ConsistencyCheckInfo consistencyCheckInfo = new ConsistencyCheckInfo();
+    if (input.checkAccountExternalIds != null) {
+      consistencyCheckInfo.checkAccountExternalIdsResult =
+          new CheckAccountExternalIdsResultInfo(externalIdsConsistencyChecker.check());
+    }
+
+    return consistencyCheckInfo;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/FlushCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/FlushCache.java
index 5e19091..366dae1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/FlushCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/FlushCache.java
@@ -23,6 +23,9 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.FlushCache.Input;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -34,17 +37,20 @@
 
   public static final String WEB_SESSIONS = "web_sessions";
 
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> self;
 
   @Inject
-  public FlushCache(Provider<CurrentUser> self) {
+  public FlushCache(PermissionBackend permissionBackend, Provider<CurrentUser> self) {
+    this.permissionBackend = permissionBackend;
     this.self = self;
   }
 
   @Override
-  public Response<String> apply(CacheResource rsrc, Input input) throws AuthException {
-    if (WEB_SESSIONS.equals(rsrc.getName()) && !self.get().getCapabilities().canMaintainServer()) {
-      throw new AuthException(String.format("only site maintainers can flush %s", WEB_SESSIONS));
+  public Response<String> apply(CacheResource rsrc, Input input)
+      throws AuthException, PermissionBackendException {
+    if (WEB_SESSIONS.equals(rsrc.getName())) {
+      permissionBackend.user(self).check(GlobalPermission.MAINTAIN_SERVER);
     }
 
     rsrc.getCache().invalidateAll();
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 4f948d7..f39099c 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
@@ -94,6 +94,7 @@
 import com.google.gerrit.server.account.GroupIncludeCacheImpl;
 import com.google.gerrit.server.account.GroupMembers;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.account.externalids.ExternalIdModule;
 import com.google.gerrit.server.api.accounts.AccountExternalIdCreator;
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.UniversalAuthBackend;
@@ -108,6 +109,7 @@
 import com.google.gerrit.server.events.EventFactory;
 import com.google.gerrit.server.events.EventsMetrics;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.extensions.webui.UiActions;
 import com.google.gerrit.server.git.AbandonOp;
 import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.git.EmailMerge;
@@ -118,7 +120,6 @@
 import com.google.gerrit.server.git.NotesBranchUtil;
 import com.google.gerrit.server.git.ReceivePackInitializer;
 import com.google.gerrit.server.git.ReplaceOp;
-import com.google.gerrit.server.git.SubmoduleOp;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.git.strategy.SubmitStrategy;
@@ -133,7 +134,7 @@
 import com.google.gerrit.server.git.validators.UploadValidationListener;
 import com.google.gerrit.server.git.validators.UploadValidators;
 import com.google.gerrit.server.group.GroupModule;
-import com.google.gerrit.server.index.change.ReindexAfterUpdate;
+import com.google.gerrit.server.index.change.ReindexAfterRefUpdate;
 import com.google.gerrit.server.mail.EmailModule;
 import com.google.gerrit.server.mail.ListMailFilter;
 import com.google.gerrit.server.mail.MailFilter;
@@ -158,16 +159,15 @@
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.plugins.ReloadPluginListener;
 import com.google.gerrit.server.project.AccessControlModule;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.CommentLinkProvider;
 import com.google.gerrit.server.project.PermissionCollection;
 import com.google.gerrit.server.project.ProjectCacheImpl;
-import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.ProjectNode;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SectionSortCache;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.ChangeQueryProcessor;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
 import com.google.gerrit.server.ssh.SshAddressesModule;
 import com.google.gerrit.server.tools.ToolsCatalog;
@@ -213,7 +213,7 @@
     bind(Sequences.class);
     install(authModule);
     install(AccountByEmailCacheImpl.module());
-    install(AccountCacheImpl.module(true));
+    install(AccountCacheImpl.module());
     install(BatchUpdate.module());
     install(ChangeKindCacheImpl.module());
     install(ConflictsCacheImpl.module());
@@ -230,6 +230,7 @@
     install(new AccessControlModule());
     install(new CmdLineParserModule());
     install(new EmailModule());
+    install(new ExternalIdModule());
     install(new GitModule());
     install(new GroupModule());
     install(new NoteDbModule(cfg));
@@ -289,11 +290,10 @@
 
     bind(PatchSetInfoFactory.class);
     bind(IdentifiedUser.GenericFactory.class).in(SINGLETON);
-    bind(ChangeControl.GenericFactory.class);
-    bind(ProjectControl.GenericFactory.class);
     bind(AccountControl.Factory.class);
 
     install(new AuditModule());
+    bind(UiActions.class);
     install(new com.google.gerrit.server.access.Module());
     install(new com.google.gerrit.server.account.Module());
     install(new com.google.gerrit.server.api.Module());
@@ -334,7 +334,7 @@
     DynamicSet.setOf(binder(), GarbageCollectorListener.class);
     DynamicSet.setOf(binder(), HeadUpdatedListener.class);
     DynamicSet.setOf(binder(), UsageDataPublishedListener.class);
-    DynamicSet.bind(binder(), GitReferenceUpdatedListener.class).to(ReindexAfterUpdate.class);
+    DynamicSet.bind(binder(), GitReferenceUpdatedListener.class).to(ReindexAfterRefUpdate.class);
     DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
         .to(ProjectConfigEntry.UpdateChecker.class);
     DynamicSet.setOf(binder(), EventListener.class);
@@ -382,6 +382,8 @@
 
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeHasOperandFactory.class);
+    DynamicMap.mapOf(binder(), ChangeQueryProcessor.ChangeAttributeFactory.class);
+
     install(new GitwebConfig.LegacyModule(cfg));
 
     bind(AnonymousUser.class);
@@ -396,7 +398,6 @@
     factory(MergedByPushOp.Factory.class);
     factory(GitModules.Factory.class);
     factory(VersionedAuthorizedKeys.Factory.class);
-    factory(SubmoduleOp.Factory.class);
 
     bind(AccountManager.class);
     factory(ChangeUserName.Factory.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java
index c0da3f3..7450b32 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java
@@ -216,7 +216,7 @@
     info.replyLabel =
         Optional.ofNullable(cfg.getString("change", null, "replyLabel")).orElse("Reply") + "\u2026";
     info.updateDelay =
-        (int) ConfigUtil.getTimeUnit(cfg, "change", null, "updateDelay", 30, TimeUnit.SECONDS);
+        (int) ConfigUtil.getTimeUnit(cfg, "change", null, "updateDelay", 300, TimeUnit.SECONDS);
     info.submitWholeTopic = Submit.wholeTopicEnabled(cfg);
     return info;
   }
@@ -310,9 +310,15 @@
     PluginConfigInfo info = new PluginConfigInfo();
     info.hasAvatars = toBoolean(avatar.get() != null);
     info.jsResourcePaths = new ArrayList<>();
+    info.htmlResourcePaths = new ArrayList<>();
     for (WebUiPlugin u : plugins) {
-      info.jsResourcePaths.add(
-          String.format("plugins/%s/%s", u.getPluginName(), u.getJavaScriptResourcePath()));
+      String path =
+          String.format("plugins/%s/%s", u.getPluginName(), u.getJavaScriptResourcePath());
+      if (path.endsWith(".html")) {
+        info.htmlResourcePaths.add(path);
+      } else {
+        info.jsResourcePaths.add(path);
+      }
     }
     return info;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTasks.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTasks.java
index 7e9bd71..bbda9eb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTasks.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTasks.java
@@ -19,13 +19,14 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.TaskInfoFactory;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.WorkQueue.ProjectTask;
 import com.google.gerrit.server.git.WorkQueue.Task;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.util.IdGenerator;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -41,37 +42,49 @@
 
 @Singleton
 public class ListTasks implements RestReadView<ConfigResource> {
+  private final PermissionBackend permissionBackend;
   private final WorkQueue workQueue;
-  private final ProjectCache projectCache;
-  private final Provider<IdentifiedUser> self;
+  private final Provider<CurrentUser> self;
 
   @Inject
-  public ListTasks(WorkQueue workQueue, ProjectCache projectCache, Provider<IdentifiedUser> self) {
+  public ListTasks(
+      PermissionBackend permissionBackend, WorkQueue workQueue, Provider<CurrentUser> self) {
+    this.permissionBackend = permissionBackend;
     this.workQueue = workQueue;
-    this.projectCache = projectCache;
     this.self = self;
   }
 
   @Override
-  public List<TaskInfo> apply(ConfigResource resource) throws AuthException {
+  public List<TaskInfo> apply(ConfigResource resource)
+      throws AuthException, PermissionBackendException {
     CurrentUser user = self.get();
     if (!user.isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
 
     List<TaskInfo> allTasks = getTasks();
-    if (user.getCapabilities().canViewQueue()) {
+    try {
+      permissionBackend.user(user).check(GlobalPermission.VIEW_QUEUE);
       return allTasks;
+    } catch (AuthException e) {
+      // Fall through to filter tasks.
     }
-    Map<String, Boolean> visibilityCache = new HashMap<>();
 
+    Map<String, Boolean> visibilityCache = new HashMap<>();
     List<TaskInfo> visibleTasks = new ArrayList<>();
     for (TaskInfo task : allTasks) {
       if (task.projectName != null) {
         Boolean visible = visibilityCache.get(task.projectName);
         if (visible == null) {
-          ProjectState e = projectCache.get(new Project.NameKey(task.projectName));
-          visible = e != null ? e.controlFor(user).isVisible() : false;
+          try {
+            permissionBackend
+                .user(user)
+                .project(new Project.NameKey(task.projectName))
+                .check(ProjectPermission.ACCESS);
+            visible = true;
+          } catch (AuthException e) {
+            visible = false;
+          }
           visibilityCache.put(task.projectName, visible);
         }
         if (visible) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java
index a05058e..4f93a1a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java
@@ -36,6 +36,8 @@
     child(CONFIG_KIND, "top-menus").to(TopMenuCollection.class);
     get(CONFIG_KIND, "version").to(GetVersion.class);
     get(CONFIG_KIND, "info").to(GetServerInfo.class);
+    post(CONFIG_KIND, "check.consistency").to(CheckConsistency.class);
+    post(CONFIG_KIND, "check.access").to(CheckAccess.class);
     get(CONFIG_KIND, "preferences").to(GetPreferences.class);
     put(CONFIG_KIND, "preferences").to(SetPreferences.class);
     get(CONFIG_KIND, "preferences.diff").to(GetDiffPreferences.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java
index 3cfa2b9..d08f0a9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.config.PostCaches.Input;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
@@ -66,7 +67,8 @@
 
   @Override
   public Response<String> apply(ConfigResource rsrc, Input input)
-      throws AuthException, BadRequestException, UnprocessableEntityException {
+      throws AuthException, BadRequestException, UnprocessableEntityException,
+          PermissionBackendException {
     if (input == null || input.operation == null) {
       throw new BadRequestException("operation must be specified");
     }
@@ -90,7 +92,7 @@
     }
   }
 
-  private void flushAll() throws AuthException {
+  private void flushAll() throws AuthException, PermissionBackendException {
     for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
       CacheResource cacheResource =
           new CacheResource(e.getPluginName(), e.getExportName(), e.getProvider());
@@ -101,7 +103,8 @@
     }
   }
 
-  private void flush(List<String> cacheNames) throws UnprocessableEntityException, AuthException {
+  private void flush(List<String> cacheNames)
+      throws UnprocessableEntityException, AuthException, PermissionBackendException {
     List<CacheResource> cacheResources = new ArrayList<>(cacheNames.size());
 
     for (String n : cacheNames) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java
index 33e68d3..a2e0356 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java
@@ -18,8 +18,8 @@
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.util.ServerRequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 
 /**
  * Provider of the group(s) which should become owners of a newly created project. The only matching
@@ -40,7 +40,7 @@
     ProjectOwnerGroupsProvider create(Project.NameKey project);
   }
 
-  @AssistedInject
+  @Inject
   public ProjectOwnerGroupsProvider(
       GroupBackend gb,
       ThreadLocalRequestContext context,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/RequestScopedReviewDbProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/RequestScopedReviewDbProvider.java
index 3987aed..4358186 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/RequestScopedReviewDbProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/RequestScopedReviewDbProvider.java
@@ -41,7 +41,7 @@
   @Override
   public ReviewDb get() {
     if (db == null) {
-      final ReviewDb c;
+      ReviewDb c;
       try {
         c = schema.open();
       } catch (OrmException e) {
@@ -51,12 +51,9 @@
         cleanup
             .get()
             .add(
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    c.close();
-                    db = null;
-                  }
+                () -> {
+                  c.close();
+                  db = null;
                 });
       } catch (Throwable e) {
         c.close();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/TasksCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/TasksCollection.java
index b239856..fcaee8e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/TasksCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/TasksCollection.java
@@ -21,12 +21,13 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.WorkQueue.ProjectTask;
 import com.google.gerrit.server.git.WorkQueue.Task;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -36,21 +37,21 @@
   private final DynamicMap<RestView<TaskResource>> views;
   private final ListTasks list;
   private final WorkQueue workQueue;
-  private final Provider<IdentifiedUser> self;
-  private final ProjectCache projectCache;
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
 
   @Inject
   TasksCollection(
       DynamicMap<RestView<TaskResource>> views,
       ListTasks list,
       WorkQueue workQueue,
-      Provider<IdentifiedUser> self,
-      ProjectCache projectCache) {
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend) {
     this.views = views;
     this.list = list;
     this.workQueue = workQueue;
     this.self = self;
-    this.projectCache = projectCache;
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
@@ -60,30 +61,42 @@
 
   @Override
   public TaskResource parse(ConfigResource parent, IdString id)
-      throws ResourceNotFoundException, AuthException {
+      throws ResourceNotFoundException, AuthException, PermissionBackendException {
     CurrentUser user = self.get();
     if (!user.isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
 
+    int taskId;
     try {
-      int taskId = (int) Long.parseLong(id.get(), 16);
-      Task<?> task = workQueue.getTask(taskId);
-      if (task != null) {
-        if (self.get().getCapabilities().canViewQueue()) {
-          return new TaskResource(task);
-        } else if (task instanceof ProjectTask) {
-          ProjectTask<?> projectTask = ((ProjectTask<?>) task);
-          ProjectState e = projectCache.get(projectTask.getProjectNameKey());
-          if (e != null && e.controlFor(user).isVisible()) {
-            return new TaskResource(task);
-          }
-        }
-      }
-      throw new ResourceNotFoundException(id);
+      taskId = (int) Long.parseLong(id.get(), 16);
     } catch (NumberFormatException e) {
       throw new ResourceNotFoundException(id);
     }
+
+    Task<?> task = workQueue.getTask(taskId);
+    if (task instanceof ProjectTask) {
+      try {
+        permissionBackend
+            .user(user)
+            .project(((ProjectTask<?>) task).getProjectNameKey())
+            .check(ProjectPermission.ACCESS);
+        return new TaskResource(task);
+      } catch (AuthException e) {
+        // Fall through and try view queue permission.
+      }
+    }
+
+    if (task != null) {
+      try {
+        permissionBackend.user(user).check(GlobalPermission.VIEW_QUEUE);
+        return new TaskResource(task);
+      } catch (AuthException e) {
+        // Fall through and return not found.
+      }
+    }
+
+    throw new ResourceNotFoundException(id);
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/ChangeAttribute.java b/gerrit-server/src/main/java/com/google/gerrit/server/data/ChangeAttribute.java
index 1a8a788..0467c92 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/data/ChangeAttribute.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/data/ChangeAttribute.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.data;
 
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.reviewdb.client.Change;
 import java.util.List;
 
@@ -43,4 +44,5 @@
   public List<DependencyAttribute> neededBy;
   public List<SubmitRecordAttribute> submitRecords;
   public List<AccountAttribute> allReviewers;
+  public List<PluginDefinedInfo> plugins;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEdit.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEdit.java
index a6464a7..e641abc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEdit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEdit.java
@@ -18,11 +18,6 @@
 
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.server.IdentifiedUser;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.revwalk.RevCommit;
 
 /**
@@ -33,44 +28,25 @@
  * change number and P is the patch set number it is based on.
  */
 public class ChangeEdit {
-  private final IdentifiedUser user;
   private final Change change;
-  private final Ref ref;
+  private final String editRefName;
   private final RevCommit editCommit;
   private final PatchSet basePatchSet;
 
   public ChangeEdit(
-      IdentifiedUser user, Change change, Ref ref, RevCommit editCommit, PatchSet basePatchSet) {
-    checkNotNull(user);
-    checkNotNull(change);
-    checkNotNull(ref);
-    checkNotNull(editCommit);
-    checkNotNull(basePatchSet);
-    this.user = user;
-    this.change = change;
-    this.ref = ref;
-    this.editCommit = editCommit;
-    this.basePatchSet = basePatchSet;
+      Change change, String editRefName, RevCommit editCommit, PatchSet basePatchSet) {
+    this.change = checkNotNull(change);
+    this.editRefName = checkNotNull(editRefName);
+    this.editCommit = checkNotNull(editCommit);
+    this.basePatchSet = checkNotNull(basePatchSet);
   }
 
   public Change getChange() {
     return change;
   }
 
-  public IdentifiedUser getUser() {
-    return user;
-  }
-
-  public Ref getRef() {
-    return ref;
-  }
-
-  public RevId getRevision() {
-    return new RevId(ObjectId.toString(ref.getObjectId()));
-  }
-
   public String getRefName() {
-    return RefNames.refsEdit(user.getAccountId(), change.getId(), basePatchSet.getId());
+    return editRefName;
   }
 
   public RevCommit getEditCommit() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index 3d1fb79..8d3cbae 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
@@ -36,6 +37,9 @@
 import com.google.gerrit.server.edit.tree.TreeCreator;
 import com.google.gerrit.server.edit.tree.TreeModification;
 import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gwtorm.server.OrmException;
@@ -44,6 +48,7 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.sql.Timestamp;
+import java.util.List;
 import java.util.Optional;
 import java.util.TimeZone;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -76,6 +81,7 @@
   private final ChangeIndexer indexer;
   private final Provider<ReviewDb> reviewDb;
   private final Provider<CurrentUser> currentUser;
+  private final PermissionBackend permissionBackend;
   private final ChangeEditUtil changeEditUtil;
   private final PatchSetUtil patchSetUtil;
 
@@ -85,11 +91,13 @@
       ChangeIndexer indexer,
       Provider<ReviewDb> reviewDb,
       Provider<CurrentUser> currentUser,
+      PermissionBackend permissionBackend,
       ChangeEditUtil changeEditUtil,
       PatchSetUtil patchSetUtil) {
     this.indexer = indexer;
     this.reviewDb = reviewDb;
     this.currentUser = currentUser;
+    this.permissionBackend = permissionBackend;
     this.tz = gerritIdent.getTimeZone();
     this.changeEditUtil = changeEditUtil;
     this.patchSetUtil = patchSetUtil;
@@ -103,10 +111,12 @@
    *     be created
    * @throws AuthException if the user isn't authenticated or not allowed to use change edits
    * @throws InvalidChangeOperationException if a change edit already existed for the change
+   * @throws PermissionBackendException
    */
   public void createEdit(Repository repository, ChangeControl changeControl)
-      throws AuthException, IOException, InvalidChangeOperationException, OrmException {
-    ensureAuthenticatedAndPermitted(changeControl);
+      throws AuthException, IOException, InvalidChangeOperationException, OrmException,
+          PermissionBackendException {
+    assertCanEdit(changeControl);
 
     Optional<ChangeEdit> changeEdit = lookupChangeEdit(changeControl);
     if (changeEdit.isPresent()) {
@@ -116,8 +126,7 @@
 
     PatchSet currentPatchSet = lookupCurrentPatchSet(changeControl);
     ObjectId patchSetCommitId = getPatchSetCommitId(currentPatchSet);
-    createEditReference(
-        repository, changeControl, currentPatchSet, patchSetCommitId, TimeUtil.nowTs());
+    createEdit(repository, changeControl, currentPatchSet, patchSetCommitId, TimeUtil.nowTs());
   }
 
   /**
@@ -131,11 +140,12 @@
    *     change, the change edit is already based on the latest patch set, or the change represents
    *     the root commit
    * @throws MergeConflictException if rebase fails due to merge conflicts
+   * @throws PermissionBackendException
    */
   public void rebaseEdit(Repository repository, ChangeControl changeControl)
       throws AuthException, InvalidChangeOperationException, IOException, OrmException,
-          MergeConflictException {
-    ensureAuthenticatedAndPermitted(changeControl);
+          MergeConflictException, PermissionBackendException {
+    assertCanEdit(changeControl);
 
     Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(changeControl);
     if (!optionalChangeEdit.isPresent()) {
@@ -194,11 +204,13 @@
    * @param newCommitMessage the new commit message
    * @throws AuthException if the user isn't authenticated or not allowed to use change edits
    * @throws UnchangedCommitMessageException if the commit message is the same as before
+   * @throws PermissionBackendException
    */
   public void modifyMessage(
       Repository repository, ChangeControl changeControl, String newCommitMessage)
-      throws AuthException, IOException, UnchangedCommitMessageException, OrmException {
-    ensureAuthenticatedAndPermitted(changeControl);
+      throws AuthException, IOException, UnchangedCommitMessageException, OrmException,
+          PermissionBackendException {
+    assertCanEdit(changeControl);
     newCommitMessage = getWellFormedCommitMessage(newCommitMessage);
 
     Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(changeControl);
@@ -218,9 +230,9 @@
         createCommit(repository, basePatchSetCommit, baseTree, newCommitMessage, nowTimestamp);
 
     if (optionalChangeEdit.isPresent()) {
-      updateEditReference(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
+      updateEdit(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
     } else {
-      createEditReference(repository, changeControl, basePatchSet, newEditCommit, nowTimestamp);
+      createEdit(repository, changeControl, basePatchSet, newEditCommit, nowTimestamp);
     }
   }
 
@@ -235,10 +247,12 @@
    * @param newContent the new file content
    * @throws AuthException if the user isn't authenticated or not allowed to use change edits
    * @throws InvalidChangeOperationException if the file already had the specified content
+   * @throws PermissionBackendException
    */
   public void modifyFile(
       Repository repository, ChangeControl changeControl, String filePath, RawInput newContent)
-      throws AuthException, InvalidChangeOperationException, IOException, OrmException {
+      throws AuthException, InvalidChangeOperationException, IOException, OrmException,
+          PermissionBackendException {
     modifyTree(repository, changeControl, new ChangeFileContentModification(filePath, newContent));
   }
 
@@ -252,9 +266,11 @@
    * @param file path of the file which should be deleted
    * @throws AuthException if the user isn't authenticated or not allowed to use change edits
    * @throws InvalidChangeOperationException if the file does not exist
+   * @throws PermissionBackendException
    */
   public void deleteFile(Repository repository, ChangeControl changeControl, String file)
-      throws AuthException, InvalidChangeOperationException, IOException, OrmException {
+      throws AuthException, InvalidChangeOperationException, IOException, OrmException,
+          PermissionBackendException {
     modifyTree(repository, changeControl, new DeleteFileModification(file));
   }
 
@@ -270,13 +286,15 @@
    * @throws AuthException if the user isn't authenticated or not allowed to use change edits
    * @throws InvalidChangeOperationException if the file was already renamed to the specified new
    *     name
+   * @throws PermissionBackendException
    */
   public void renameFile(
       Repository repository,
       ChangeControl changeControl,
       String currentFilePath,
       String newFilePath)
-      throws AuthException, InvalidChangeOperationException, IOException, OrmException {
+      throws AuthException, InvalidChangeOperationException, IOException, OrmException,
+          PermissionBackendException {
     modifyTree(repository, changeControl, new RenameFileModification(currentFilePath, newFilePath));
   }
 
@@ -291,16 +309,19 @@
    * @param file the path of the file which should be restored
    * @throws AuthException if the user isn't authenticated or not allowed to use change edits
    * @throws InvalidChangeOperationException if the file was already restored
+   * @throws PermissionBackendException
    */
   public void restoreFile(Repository repository, ChangeControl changeControl, String file)
-      throws AuthException, InvalidChangeOperationException, IOException, OrmException {
+      throws AuthException, InvalidChangeOperationException, IOException, OrmException,
+          PermissionBackendException {
     modifyTree(repository, changeControl, new RestoreFileModification(file));
   }
 
   private void modifyTree(
       Repository repository, ChangeControl changeControl, TreeModification treeModification)
-      throws AuthException, IOException, OrmException, InvalidChangeOperationException {
-    ensureAuthenticatedAndPermitted(changeControl);
+      throws AuthException, IOException, OrmException, InvalidChangeOperationException,
+          PermissionBackendException {
+    assertCanEdit(changeControl);
 
     Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(changeControl);
     PatchSet basePatchSet = getBasePatchSet(optionalChangeEdit, changeControl);
@@ -308,7 +329,7 @@
     RevCommit baseCommit =
         optionalChangeEdit.map(ChangeEdit::getEditCommit).orElse(basePatchSetCommit);
 
-    ObjectId newTreeId = createNewTree(repository, baseCommit, treeModification);
+    ObjectId newTreeId = createNewTree(repository, baseCommit, ImmutableList.of(treeModification));
 
     String commitMessage = baseCommit.getFullMessage();
     Timestamp nowTimestamp = TimeUtil.nowTs();
@@ -316,31 +337,105 @@
         createCommit(repository, basePatchSetCommit, newTreeId, commitMessage, nowTimestamp);
 
     if (optionalChangeEdit.isPresent()) {
-      updateEditReference(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
+      updateEdit(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
     } else {
-      createEditReference(repository, changeControl, basePatchSet, newEditCommit, nowTimestamp);
+      createEdit(repository, changeControl, basePatchSet, newEditCommit, nowTimestamp);
     }
   }
 
-  private void ensureAuthenticatedAndPermitted(ChangeControl changeControl)
-      throws AuthException, OrmException {
-    ensureAuthenticated();
-    ensurePermitted(changeControl);
+  /**
+   * Applies the indicated modifications to the specified patch set. If a change edit exists and is
+   * based on the same patch set, the modified patch set tree is merged with the change edit. If the
+   * change edit doesn't exist, a new one will be created.
+   *
+   * @param repository the affected Git repository
+   * @param changeControl the {@code ChangeControl} of the change to which the patch set belongs
+   * @param patchSet the {@code PatchSet} which should be modified
+   * @param treeModifications the modifications which should be applied
+   * @return the resulting {@code ChangeEdit}
+   * @throws AuthException if the user isn't authenticated or not allowed to use change edits
+   * @throws InvalidChangeOperationException if the existing change edit is based on another patch
+   *     set or no change edit exists but the specified patch set isn't the current one
+   * @throws MergeConflictException if the modified patch set tree can't be merged with an existing
+   *     change edit
+   */
+  public ChangeEdit combineWithModifiedPatchSetTree(
+      Repository repository,
+      ChangeControl changeControl,
+      PatchSet patchSet,
+      List<TreeModification> treeModifications)
+      throws AuthException, IOException, InvalidChangeOperationException, MergeConflictException,
+          OrmException, PermissionBackendException {
+    assertCanEdit(changeControl);
+
+    Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(changeControl);
+    ensureAllowedPatchSet(changeControl, optionalChangeEdit, patchSet);
+
+    RevCommit patchSetCommit = lookupCommit(repository, patchSet);
+    ObjectId newTreeId = createNewTree(repository, patchSetCommit, treeModifications);
+
+    if (optionalChangeEdit.isPresent()) {
+      ChangeEdit changeEdit = optionalChangeEdit.get();
+      newTreeId = merge(repository, changeEdit, newTreeId);
+      if (ObjectId.equals(newTreeId, changeEdit.getEditCommit().getTree())) {
+        // Modifications are already contained in the change edit.
+        return changeEdit;
+      }
+    }
+
+    String commitMessage =
+        optionalChangeEdit.map(ChangeEdit::getEditCommit).orElse(patchSetCommit).getFullMessage();
+    Timestamp nowTimestamp = TimeUtil.nowTs();
+    ObjectId newEditCommit =
+        createCommit(repository, patchSetCommit, newTreeId, commitMessage, nowTimestamp);
+
+    if (optionalChangeEdit.isPresent()) {
+      return updateEdit(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
+    }
+    return createEdit(repository, changeControl, patchSet, newEditCommit, nowTimestamp);
   }
 
-  private void ensureAuthenticated() throws AuthException {
+  private void assertCanEdit(ChangeControl changeControl)
+      throws AuthException, PermissionBackendException {
     if (!currentUser.get().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
-  }
-
-  private void ensurePermitted(ChangeControl changeControl) throws OrmException, AuthException {
-    if (!changeControl.canAddPatchSet(reviewDb.get())) {
-      throw new AuthException("Not allowed to edit a change.");
+    try {
+      permissionBackend
+          .user(currentUser)
+          .database(reviewDb)
+          .change(changeControl.getNotes())
+          .check(ChangePermission.ADD_PATCH_SET);
+    } catch (AuthException denied) {
+      throw new AuthException("edit not permitted", denied);
     }
   }
 
-  private String getWellFormedCommitMessage(String commitMessage) {
+  private static void ensureAllowedPatchSet(
+      ChangeControl changeControl, Optional<ChangeEdit> optionalChangeEdit, PatchSet patchSet)
+      throws InvalidChangeOperationException {
+    if (optionalChangeEdit.isPresent()) {
+      ChangeEdit changeEdit = optionalChangeEdit.get();
+      if (!isBasedOn(changeEdit, patchSet)) {
+        throw new InvalidChangeOperationException(
+            String.format(
+                "Only the patch set %s on which the existing change edit is based may be modified "
+                    + "(specified patch set: %s)",
+                changeEdit.getBasePatchSet().getId(), patchSet.getId()));
+      }
+    } else {
+      PatchSet.Id patchSetId = patchSet.getId();
+      PatchSet.Id currentPatchSetId = changeControl.getChange().currentPatchSetId();
+      if (!patchSetId.equals(currentPatchSetId)) {
+        throw new InvalidChangeOperationException(
+            String.format(
+                "A change edit may only be created for the current patch set %s (and not for %s)",
+                currentPatchSetId, patchSetId));
+      }
+    }
+  }
+
+  private static String getWellFormedCommitMessage(String commitMessage) {
     String wellFormedMessage = Strings.nullToEmpty(commitMessage).trim();
     checkState(!wellFormedMessage.isEmpty(), "Commit message cannot be null or empty");
     wellFormedMessage = wellFormedMessage + "\n";
@@ -372,16 +467,21 @@
   private static RevCommit lookupCommit(Repository repository, PatchSet patchSet)
       throws IOException {
     ObjectId patchSetCommitId = getPatchSetCommitId(patchSet);
+    return lookupCommit(repository, patchSetCommitId);
+  }
+
+  private static RevCommit lookupCommit(Repository repository, ObjectId commitId)
+      throws IOException {
     try (RevWalk revWalk = new RevWalk(repository)) {
-      return revWalk.parseCommit(patchSetCommitId);
+      return revWalk.parseCommit(commitId);
     }
   }
 
   private static ObjectId createNewTree(
-      Repository repository, RevCommit baseCommit, TreeModification treeModification)
+      Repository repository, RevCommit baseCommit, List<TreeModification> treeModifications)
       throws IOException, InvalidChangeOperationException {
     TreeCreator treeCreator = new TreeCreator(baseCommit);
-    treeCreator.addTreeModification(treeModification);
+    treeCreator.addTreeModifications(treeModifications);
     ObjectId newTreeId = treeCreator.createNewTreeAndGetId(repository);
 
     if (ObjectId.equals(newTreeId, baseCommit.getTree())) {
@@ -390,7 +490,7 @@
     return newTreeId;
   }
 
-  private ObjectId merge(Repository repository, ChangeEdit changeEdit, ObjectId newTreeId)
+  private static ObjectId merge(Repository repository, ChangeEdit changeEdit, ObjectId newTreeId)
       throws IOException, MergeConflictException {
     PatchSet basePatchSet = changeEdit.getBasePatchSet();
     ObjectId basePatchSetCommitId = getPatchSetCommitId(basePatchSet);
@@ -436,17 +536,20 @@
     return ObjectId.fromString(patchSet.getRevision().get());
   }
 
-  private void createEditReference(
+  private ChangeEdit createEdit(
       Repository repository,
       ChangeControl changeControl,
       PatchSet basePatchSet,
-      ObjectId newEditCommit,
+      ObjectId newEditCommitId,
       Timestamp timestamp)
       throws IOException, OrmException {
     Change change = changeControl.getChange();
     String editRefName = getEditRefName(change, basePatchSet);
-    updateReference(repository, editRefName, ObjectId.zeroId(), newEditCommit, timestamp);
+    updateReference(repository, editRefName, ObjectId.zeroId(), newEditCommitId, timestamp);
     reindex(change);
+
+    RevCommit newEditCommit = lookupCommit(repository, newEditCommitId);
+    return new ChangeEdit(change, editRefName, newEditCommit, basePatchSet);
   }
 
   private String getEditRefName(Change change, PatchSet basePatchSet) {
@@ -454,13 +557,17 @@
     return RefNames.refsEdit(me.getAccountId(), change.getId(), basePatchSet.getId());
   }
 
-  private void updateEditReference(
-      Repository repository, ChangeEdit changeEdit, ObjectId newEditCommit, Timestamp timestamp)
+  private ChangeEdit updateEdit(
+      Repository repository, ChangeEdit changeEdit, ObjectId newEditCommitId, Timestamp timestamp)
       throws IOException, OrmException {
     String editRefName = changeEdit.getRefName();
     RevCommit currentEditCommit = changeEdit.getEditCommit();
-    updateReference(repository, editRefName, currentEditCommit, newEditCommit, timestamp);
+    updateReference(repository, editRefName, currentEditCommit, newEditCommitId, timestamp);
     reindex(changeEdit.getChange());
+
+    RevCommit newEditCommit = lookupCommit(repository, newEditCommitId);
+    return new ChangeEdit(
+        changeEdit.getChange(), editRefName, newEditCommit, changeEdit.getBasePatchSet());
   }
 
   private void updateReference(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
index 6509ecc..9160932 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
@@ -53,6 +53,7 @@
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
@@ -74,7 +75,6 @@
   private final Provider<ReviewDb> db;
   private final Provider<CurrentUser> user;
   private final ChangeKindCache changeKindCache;
-  private final BatchUpdate.Factory updateFactory;
   private final PatchSetUtil psUtil;
 
   @Inject
@@ -86,7 +86,6 @@
       Provider<ReviewDb> db,
       Provider<CurrentUser> user,
       ChangeKindCache changeKindCache,
-      BatchUpdate.Factory updateFactory,
       PatchSetUtil psUtil) {
     this.gitManager = gitManager;
     this.patchSetInserterFactory = patchSetInserterFactory;
@@ -95,7 +94,6 @@
     this.db = db;
     this.user = user;
     this.changeKindCache = changeKindCache;
-    this.updateFactory = updateFactory;
     this.psUtil = psUtil;
   }
 
@@ -149,7 +147,7 @@
       try (RevWalk rw = new RevWalk(repo)) {
         RevCommit commit = rw.parseCommit(ref.getObjectId());
         PatchSet basePs = getBasePatchSet(ctl, ref);
-        return Optional.of(new ChangeEdit(u, change, ref, commit, basePs));
+        return Optional.of(new ChangeEdit(change, ref.getName(), commit, basePs));
       }
     }
   }
@@ -157,6 +155,8 @@
   /**
    * Promote change edit to patch set, by squashing the edit into its parent.
    *
+   * @param updateFactory factory for creating updates.
+   * @param ctl the {@code ChangeControl} of the change to which the change edit belongs
    * @param edit change edit to publish
    * @param notify Notify handling that defines to whom email notifications should be sent after the
    *     change edit is published.
@@ -167,21 +167,23 @@
    * @throws RestApiException
    */
   public void publish(
+      BatchUpdate.Factory updateFactory,
+      ChangeControl ctl,
       final ChangeEdit edit,
       NotifyHandling notify,
       ListMultimap<RecipientType, Account.Id> accountsToNotify)
       throws IOException, OrmException, RestApiException, UpdateException {
     Change change = edit.getChange();
     try (Repository repo = gitManager.openRepository(change.getProject());
-        RevWalk rw = new RevWalk(repo);
-        ObjectInserter oi = repo.newObjectInserter()) {
+        ObjectInserter oi = repo.newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        RevWalk rw = new RevWalk(reader)) {
       PatchSet basePatchSet = edit.getBasePatchSet();
       if (!basePatchSet.getId().equals(change.currentPatchSetId())) {
         throw new ResourceConflictException("only edit for current patch set can be published");
       }
 
       RevCommit squashed = squashEdit(rw, oi, edit.getEditCommit(), basePatchSet);
-      ChangeControl ctl = changeControlFactory.controlFor(db.get(), change, edit.getUser());
       PatchSet.Id psId = ChangeUtil.nextPatchSetId(repo, change.currentPatchSetId());
       PatchSetInserter inserter =
           patchSetInserterFactory
@@ -194,7 +196,8 @@
 
       // Previously checked that the base patch set is the current patch set.
       ObjectId prior = ObjectId.fromString(basePatchSet.getRevision().get());
-      ChangeKind kind = changeKindCache.getChangeKind(change.getProject(), repo, prior, squashed);
+      ChangeKind kind =
+          changeKindCache.getChangeKind(change.getProject(), rw, repo.getConfig(), prior, squashed);
       if (kind == ChangeKind.NO_CODE_CHANGE) {
         message.append("Commit message was updated.");
         inserter.setDescription("Edit commit message");
@@ -218,21 +221,10 @@
             new BatchUpdateOp() {
               @Override
               public void updateRepo(RepoContext ctx) throws Exception {
-                deleteRef(ctx.getRepository(), edit);
+                ctx.addRefUpdate(edit.getEditCommit().copy(), ObjectId.zeroId(), edit.getRefName());
               }
             });
         bu.execute();
-      } catch (UpdateException e) {
-        if (e.getCause() instanceof IOException
-            && e.getMessage()
-                .equals(
-                    String.format(
-                        "%s: Failed to delete ref %s: %s",
-                        IOException.class.getName(),
-                        edit.getRefName(),
-                        RefUpdate.Result.LOCK_FAILURE.name()))) {
-          throw new ResourceConflictException("edit is already published");
-        }
       }
 
       indexer.index(db.get(), inserter.getChange());
@@ -280,7 +272,7 @@
   private static void deleteRef(Repository repo, ChangeEdit edit) throws IOException {
     String refName = edit.getRefName();
     RefUpdate ru = repo.updateRef(refName, true);
-    ru.setExpectedOldObjectId(edit.getRef().getObjectId());
+    ru.setExpectedOldObjectId(edit.getEditCommit());
     ru.setForceUpdate(true);
     RefUpdate.Result result = ru.delete();
     switch (result) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
index dc35309..3d75e6a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.io.ByteStreams;
 import com.google.gerrit.extensions.restapi.RawInput;
 import java.io.IOException;
@@ -53,6 +54,16 @@
     return Collections.singletonList(changeContentEdit);
   }
 
+  @Override
+  public String getFilePath() {
+    return filePath;
+  }
+
+  @VisibleForTesting
+  RawInput getNewContent() {
+    return newContent;
+  }
+
   /** A {@code PathEdit} which changes the contents of a file. */
   private static class ChangeContent extends DirCacheEditor.PathEdit {
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java
index 62da19a..feffb70 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java
@@ -34,4 +34,9 @@
     DirCacheEditor.DeletePath deletePathEdit = new DirCacheEditor.DeletePath(filePath);
     return Collections.singletonList(deletePathEdit);
   }
+
+  @Override
+  public String getFilePath() {
+    return filePath;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RenameFileModification.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RenameFileModification.java
index aeacd23..b847599 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RenameFileModification.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RenameFileModification.java
@@ -52,4 +52,9 @@
       }
     }
   }
+
+  @Override
+  public String getFilePath() {
+    return newFilePath;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java
index 1bd55f6..393a866 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java
@@ -58,4 +58,9 @@
       }
     }
   }
+
+  @Override
+  public String getFilePath() {
+    return filePath;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeCreator.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeCreator.java
index 7e9a96a..e867e76 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeCreator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeCreator.java
@@ -36,8 +36,6 @@
 public class TreeCreator {
 
   private final RevCommit baseCommit;
-  // At the moment, a list wouldn't be necessary as only one modification is
-  // applied per created tree. This is going to change in the near future.
   private final List<TreeModification> treeModifications = new ArrayList<>();
 
   public TreeCreator(RevCommit baseCommit) {
@@ -45,14 +43,14 @@
   }
 
   /**
-   * Apply a modification to the tree which is taken as a basis. If this method is called multiple
+   * Apply modifications to the tree which is taken as a basis. If this method is called multiple
    * times, the modifications are applied subsequently in exactly the order they were provided.
    *
-   * @param treeModification a modification which should be applied to the base tree
+   * @param treeModifications modifications which should be applied to the base tree
    */
-  public void addTreeModification(TreeModification treeModification) {
-    checkNotNull(treeModification, "treeModification must not be null");
-    treeModifications.add(treeModification);
+  public void addTreeModifications(List<TreeModification> treeModifications) {
+    checkNotNull(treeModifications, "treeModifications must not be null");
+    this.treeModifications.addAll(treeModifications);
   }
 
   /**
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeModification.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeModification.java
index 217a309..2656707 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeModification.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeModification.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.edit.tree;
 
+import com.google.common.annotations.VisibleForTesting;
 import java.io.IOException;
 import java.util.List;
 import org.eclipse.jgit.dircache.DirCacheEditor;
@@ -35,4 +36,14 @@
    */
   List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit)
       throws IOException;
+
+  /**
+   * Indicates a file path which is affected by this {@code TreeModification}. If the modification
+   * refers to several file paths (e.g. renaming a file), returning either of them is appropriate as
+   * long as the returned value is deterministic.
+   *
+   * @return an affected file path
+   */
+  @VisibleForTesting
+  String getFilePath();
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/CommitReceivedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/CommitReceivedEvent.java
index 17fc52b..c0f9c29 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/CommitReceivedEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/CommitReceivedEvent.java
@@ -16,14 +16,19 @@
 
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
+import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
-public class CommitReceivedEvent extends RefEvent {
+public class CommitReceivedEvent extends RefEvent implements AutoCloseable {
   static final String TYPE = "commit-received";
   public ReceiveCommand command;
   public Project project;
   public String refName;
+  public RevWalk revWalk;
   public RevCommit commit;
   public IdentifiedUser user;
 
@@ -35,14 +40,18 @@
       ReceiveCommand command,
       Project project,
       String refName,
-      RevCommit commit,
-      IdentifiedUser user) {
+      ObjectReader reader,
+      ObjectId commitId,
+      IdentifiedUser user)
+      throws IOException {
     this();
     this.command = command;
     this.project = project;
     this.refName = refName;
-    this.commit = commit;
+    this.revWalk = new RevWalk(reader);
+    this.commit = revWalk.parseCommit(commitId);
     this.user = user;
+    revWalk.parseBody(commit);
   }
 
   @Override
@@ -54,4 +63,9 @@
   public String getRefName() {
     return refName;
   }
+
+  @Override
+  public void close() {
+    revWalk.close();
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
index 80dcb78..ce04f26 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
@@ -311,7 +311,7 @@
     // set whose parent matches this patch set's revision.
     for (ChangeData cd :
         queryProvider.get().byProjectGroups(change.getProject(), currentPs.getGroups())) {
-      patchSets:
+      PATCH_SETS:
       for (PatchSet ps : cd.patchSets()) {
         RevCommit commit = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
         for (RevCommit p : commit.getParents()) {
@@ -319,7 +319,7 @@
             continue;
           }
           ca.neededBy.add(newNeededBy(checkNotNull(cd.change()), ps));
-          continue patchSets;
+          continue PATCH_SETS;
         }
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/webui/UiActions.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/webui/UiActions.java
index 85ee4f9..ae15cfd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/webui/UiActions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/webui/UiActions.java
@@ -16,20 +16,27 @@
 
 import com.google.common.base.Predicate;
 import com.google.common.collect.FluentIterable;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestCollection;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.webui.PrivateInternals_UiActionDescription;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.CapabilityUtils;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import java.util.Objects;
+import java.util.Set;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+@Singleton
 public class UiActions {
   private static final Logger log = LoggerFactory.getLogger(UiActions.class);
 
@@ -37,57 +44,70 @@
     return UiAction.Description::isEnabled;
   }
 
-  public static <R extends RestResource> FluentIterable<UiAction.Description> from(
-      RestCollection<?, R> collection, R resource, Provider<CurrentUser> userProvider) {
-    return from(collection.views(), resource, userProvider);
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> userProvider;
+
+  @Inject
+  UiActions(PermissionBackend permissionBackend, Provider<CurrentUser> userProvider) {
+    this.permissionBackend = permissionBackend;
+    this.userProvider = userProvider;
   }
 
-  public static <R extends RestResource> FluentIterable<UiAction.Description> from(
-      DynamicMap<RestView<R>> views, R resource, Provider<CurrentUser> userProvider) {
+  public <R extends RestResource> FluentIterable<UiAction.Description> from(
+      RestCollection<?, R> collection, R resource) {
+    return from(collection.views(), resource);
+  }
+
+  public <R extends RestResource> FluentIterable<UiAction.Description> from(
+      DynamicMap<RestView<R>> views, R resource) {
     return FluentIterable.from(views)
-        .transform(
-            (DynamicMap.Entry<RestView<R>> e) -> {
-              int d = e.getExportName().indexOf('.');
-              if (d < 0) {
-                return null;
-              }
-
-              RestView<R> view;
-              try {
-                view = e.getProvider().get();
-              } catch (RuntimeException err) {
-                log.error(
-                    String.format(
-                        "error creating view %s.%s", e.getPluginName(), e.getExportName()),
-                    err);
-                return null;
-              }
-
-              if (!(view instanceof UiAction)) {
-                return null;
-              }
-
-              try {
-                CapabilityUtils.checkRequiresCapability(
-                    userProvider, e.getPluginName(), view.getClass());
-              } catch (AuthException exc) {
-                return null;
-              }
-
-              UiAction.Description dsc = ((UiAction<R>) view).getDescription(resource);
-              if (dsc == null || !dsc.isVisible()) {
-                return null;
-              }
-
-              String name = e.getExportName().substring(d + 1);
-              PrivateInternals_UiActionDescription.setMethod(
-                  dsc, e.getExportName().substring(0, d));
-              PrivateInternals_UiActionDescription.setId(
-                  dsc, "gerrit".equals(e.getPluginName()) ? name : e.getPluginName() + '~' + name);
-              return dsc;
-            })
+        .transform((e) -> describe(e, resource))
         .filter(Objects::nonNull);
   }
 
-  private UiActions() {}
+  @Nullable
+  private <R extends RestResource> UiAction.Description describe(
+      DynamicMap.Entry<RestView<R>> e, R resource) {
+    int d = e.getExportName().indexOf('.');
+    if (d < 0) {
+      return null;
+    }
+
+    RestView<R> view;
+    try {
+      view = e.getProvider().get();
+    } catch (RuntimeException err) {
+      log.error(
+          String.format("error creating view %s.%s", e.getPluginName(), e.getExportName()), err);
+      return null;
+    }
+
+    if (!(view instanceof UiAction)) {
+      return null;
+    }
+
+    try {
+      Set<GlobalOrPluginPermission> need =
+          GlobalPermission.fromAnnotation(e.getPluginName(), view.getClass());
+      if (!need.isEmpty() && permissionBackend.user(userProvider).test(need).isEmpty()) {
+        // A permission is required, but test returned no candidates.
+        return null;
+      }
+    } catch (PermissionBackendException err) {
+      log.error(
+          String.format("exception testing view %s.%s", e.getPluginName(), e.getExportName()), err);
+      return null;
+    }
+
+    UiAction.Description dsc = ((UiAction<R>) view).getDescription(resource);
+    if (dsc == null || !dsc.isVisible()) {
+      return null;
+    }
+
+    String name = e.getExportName().substring(d + 1);
+    PrivateInternals_UiActionDescription.setMethod(dsc, e.getExportName().substring(0, d));
+    PrivateInternals_UiActionDescription.setId(
+        dsc, "gerrit".equals(e.getPluginName()) ? name : e.getPluginName() + '~' + name);
+    return dsc;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java b/gerrit-server/src/main/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java
new file mode 100644
index 0000000..3fc786b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java
@@ -0,0 +1,154 @@
+// Copyright (C) 2017 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.fixes;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.FixReplacement;
+import com.google.gerrit.server.change.FileContentUtil;
+import com.google.gerrit.server.edit.tree.ChangeFileContentModification;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+
+/** An interpreter for {@code FixReplacement}s. */
+@Singleton
+public class FixReplacementInterpreter {
+
+  private static final Comparator<FixReplacement> ASC_RANGE_FIX_REPLACEMENT_COMPARATOR =
+      Comparator.comparing(fixReplacement -> fixReplacement.range);
+
+  private final FileContentUtil fileContentUtil;
+
+  @Inject
+  public FixReplacementInterpreter(FileContentUtil fileContentUtil) {
+    this.fileContentUtil = fileContentUtil;
+  }
+
+  /**
+   * Transforms the given {@code FixReplacement}s into {@code TreeModification}s.
+   *
+   * @param repository the affected Git repository
+   * @param projectState the affected project
+   * @param patchSetCommitId the patch set which should be modified
+   * @param fixReplacements the replacements which should be applied
+   * @return a list of {@code TreeModification}s representing the given replacements
+   * @throws ResourceNotFoundException if a file to which one of the replacements refers doesn't
+   *     exist
+   * @throws ResourceConflictException if the replacements can't be transformed into {@code
+   *     TreeModification}s
+   */
+  public List<TreeModification> toTreeModifications(
+      Repository repository,
+      ProjectState projectState,
+      ObjectId patchSetCommitId,
+      List<FixReplacement> fixReplacements)
+      throws ResourceNotFoundException, IOException, ResourceConflictException {
+    checkNotNull(fixReplacements, "Fix replacements must not be null");
+
+    Map<String, List<FixReplacement>> fixReplacementsPerFilePath =
+        fixReplacements
+            .stream()
+            .collect(Collectors.groupingBy(fixReplacement -> fixReplacement.path));
+
+    List<TreeModification> treeModifications = new ArrayList<>();
+    for (Map.Entry<String, List<FixReplacement>> entry : fixReplacementsPerFilePath.entrySet()) {
+      TreeModification treeModification =
+          toTreeModification(
+              repository, projectState, patchSetCommitId, entry.getKey(), entry.getValue());
+      treeModifications.add(treeModification);
+    }
+    return treeModifications;
+  }
+
+  private TreeModification toTreeModification(
+      Repository repository,
+      ProjectState projectState,
+      ObjectId patchSetCommitId,
+      String filePath,
+      List<FixReplacement> fixReplacements)
+      throws ResourceNotFoundException, IOException, ResourceConflictException {
+    String fileContent = getFileContent(repository, projectState, patchSetCommitId, filePath);
+    String newFileContent = getNewFileContent(fileContent, fixReplacements);
+    return new ChangeFileContentModification(filePath, RawInputUtil.create(newFileContent));
+  }
+
+  private String getFileContent(
+      Repository repository, ProjectState projectState, ObjectId patchSetCommitId, String filePath)
+      throws ResourceNotFoundException, IOException {
+    try (BinaryResult fileContent =
+        fileContentUtil.getContent(repository, projectState, patchSetCommitId, filePath)) {
+      return fileContent.asString();
+    }
+  }
+
+  private static String getNewFileContent(String fileContent, List<FixReplacement> fixReplacements)
+      throws ResourceConflictException {
+    List<FixReplacement> sortedReplacements = new ArrayList<>(fixReplacements);
+    sortedReplacements.sort(ASC_RANGE_FIX_REPLACEMENT_COMPARATOR);
+
+    LineIdentifier lineIdentifier = new LineIdentifier(fileContent);
+    StringModifier fileContentModifier = new StringModifier(fileContent);
+    for (FixReplacement fixReplacement : sortedReplacements) {
+      Comment.Range range = fixReplacement.range;
+      try {
+        int startLineIndex = lineIdentifier.getStartIndexOfLine(range.startLine);
+        int startLineLength = lineIdentifier.getLengthOfLine(range.startLine);
+
+        int endLineIndex = lineIdentifier.getStartIndexOfLine(range.endLine);
+        int endLineLength = lineIdentifier.getLengthOfLine(range.endLine);
+
+        if (range.startChar > startLineLength || range.endChar > endLineLength) {
+          throw new ResourceConflictException(
+              String.format(
+                  "Range %s refers to a non-existent offset (start line length: %s,"
+                      + " end line length: %s)",
+                  toString(range), startLineLength, endLineLength));
+        }
+
+        int startIndex = startLineIndex + range.startChar;
+        int endIndex = endLineIndex + range.endChar;
+        fileContentModifier.replace(startIndex, endIndex, fixReplacement.replacement);
+      } catch (StringIndexOutOfBoundsException e) {
+        // Most of the StringIndexOutOfBoundsException should never occur because we reject fix
+        // replacements for invalid ranges. However, we can't cover all cases for efficiency
+        // reasons. For instance, we don't determine the number of lines in a file. That's why we
+        // need to map this exception and thus provide a meaningful error.
+        throw new ResourceConflictException(
+            String.format("Cannot apply fix replacement for range %s", toString(range)), e);
+      }
+    }
+    return fileContentModifier.getResult();
+  }
+
+  private static String toString(Comment.Range range) {
+    return String.format(
+        "(%s:%s - %s:%s)", range.startLine, range.startChar, range.endLine, range.endChar);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/fixes/LineIdentifier.java b/gerrit-server/src/main/java/com/google/gerrit/server/fixes/LineIdentifier.java
new file mode 100644
index 0000000..c32d822
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/fixes/LineIdentifier.java
@@ -0,0 +1,110 @@
+// Copyright (C) 2017 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.fixes;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * An identifier of lines in a string. Lines are sequences of characters which are separated by any
+ * Unicode linebreak sequence as defined by the regular expression {@code \R}. If data for several
+ * lines is requested, calls which are ordered according to ascending line numbers are the most
+ * efficient.
+ */
+class LineIdentifier {
+
+  private static final Pattern LINE_SEPARATOR_PATTERN = Pattern.compile("\\R");
+  private final Matcher lineSeparatorMatcher;
+
+  private int nextLineNumber;
+  private int nextLineStartIndex;
+  private int currentLineStartIndex;
+  private int currentLineEndIndex;
+
+  LineIdentifier(String string) {
+    checkNotNull(string);
+    lineSeparatorMatcher = LINE_SEPARATOR_PATTERN.matcher(string);
+    reset();
+  }
+
+  /**
+   * Returns the start index of the indicated line within the given string. Start indices are
+   * zero-based while line numbers are one-based.
+   *
+   * <p><b>Note:</b> Requesting data for several lines is more efficient if those calls occur with
+   * increasing line number.
+   *
+   * @param lineNumber the line whose start index should be determined
+   * @return the start index of the line
+   * @throws StringIndexOutOfBoundsException if the line number is negative, zero or greater than
+   *     the identified number of lines
+   */
+  public int getStartIndexOfLine(int lineNumber) {
+    findLine(lineNumber);
+    return currentLineStartIndex;
+  }
+
+  /**
+   * Returns the length of the indicated line in the given string. The character(s) used to separate
+   * lines aren't included in the count. Line numbers are one-based.
+   *
+   * <p><b>Note:</b> Requesting data for several lines is more efficient if those calls occur with
+   * increasing line number.
+   *
+   * @param lineNumber the line whose length should be determined
+   * @return the length of the line
+   * @throws StringIndexOutOfBoundsException if the line number is negative, zero or greater than
+   *     the identified number of lines
+   */
+  public int getLengthOfLine(int lineNumber) {
+    findLine(lineNumber);
+    return currentLineEndIndex - currentLineStartIndex;
+  }
+
+  private void findLine(int targetLineNumber) {
+    if (targetLineNumber <= 0) {
+      throw new StringIndexOutOfBoundsException("Line number must be positive");
+    }
+    if (targetLineNumber < nextLineNumber) {
+      reset();
+    }
+    while (nextLineNumber < targetLineNumber + 1 && lineSeparatorMatcher.find()) {
+      currentLineStartIndex = nextLineStartIndex;
+      currentLineEndIndex = lineSeparatorMatcher.start();
+      nextLineStartIndex = lineSeparatorMatcher.end();
+      nextLineNumber++;
+    }
+
+    // End of string
+    if (nextLineNumber == targetLineNumber) {
+      currentLineStartIndex = nextLineStartIndex;
+      currentLineEndIndex = lineSeparatorMatcher.regionEnd();
+    }
+    if (nextLineNumber < targetLineNumber) {
+      throw new StringIndexOutOfBoundsException(
+          String.format("Line %d isn't available", targetLineNumber));
+    }
+  }
+
+  private void reset() {
+    nextLineNumber = 1;
+    nextLineStartIndex = 0;
+    currentLineStartIndex = 0;
+    currentLineEndIndex = 0;
+    lineSeparatorMatcher.reset();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/fixes/StringModifier.java b/gerrit-server/src/main/java/com/google/gerrit/server/fixes/StringModifier.java
new file mode 100644
index 0000000..ccd40b3
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/fixes/StringModifier.java
@@ -0,0 +1,77 @@
+// Copyright (C) 2017 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.fixes;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+/**
+ * A modifier of a string. It allows to replace multiple parts of a string by indicating those parts
+ * with indices based on the unmodified string. There is one limitation though: Replacements which
+ * affect lower indices of the string must be specified before replacements for higher indices.
+ */
+class StringModifier {
+
+  private final StringBuilder stringBuilder;
+
+  private int characterShift = 0;
+  private int previousEndOffset = Integer.MIN_VALUE;
+
+  StringModifier(String string) {
+    checkNotNull(string, "string must not be null");
+    stringBuilder = new StringBuilder(string);
+  }
+
+  /**
+   * Replaces part of the string with another content. When called multiple times, the calls must be
+   * ordered according to increasing start indices. Overlapping replacement regions aren't
+   * supported.
+   *
+   * @param startIndex the beginning index in the unmodified string (inclusive)
+   * @param endIndex the ending index in the unmodified string (exclusive)
+   * @param replacement the string which should be used instead of the original content
+   * @throws StringIndexOutOfBoundsException if the start index is smaller than the end index of a
+   *     previous call of this method
+   */
+  public void replace(int startIndex, int endIndex, String replacement) {
+    checkNotNull(replacement, "replacement string must not be null");
+    if (previousEndOffset > startIndex) {
+      throw new StringIndexOutOfBoundsException(
+          String.format(
+              "Not supported to replace the content starting at index %s after previous "
+                  + "replacement which ended at index %s",
+              startIndex, previousEndOffset));
+    }
+    int shiftedStartIndex = startIndex + characterShift;
+    int shiftedEndIndex = endIndex + characterShift;
+    if (shiftedEndIndex > stringBuilder.length()) {
+      throw new StringIndexOutOfBoundsException(
+          String.format("end %s > length %s", shiftedEndIndex, stringBuilder.length()));
+    }
+    stringBuilder.replace(shiftedStartIndex, shiftedEndIndex, replacement);
+
+    int replacedContentLength = endIndex - startIndex;
+    characterShift += replacement.length() - replacedContentLength;
+    previousEndOffset = endIndex;
+  }
+
+  /**
+   * Returns the modified string including all specified replacements.
+   *
+   * @return the modified string
+   */
+  public String getResult() {
+    return stringBuilder.toString();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/AbandonOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/AbandonOp.java
index 99b647a..f4185c9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/AbandonOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/AbandonOp.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
 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.ChangeAbandoned;
 import com.google.gerrit.server.mail.send.AbandonedSender;
@@ -34,8 +35,8 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -64,7 +65,7 @@
         @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify);
   }
 
-  @AssistedInject
+  @Inject
   AbandonOp(
       AbandonedSender.Factory abandonedSenderFactory,
       ChangeMessagesUtil cmUtil,
@@ -96,7 +97,7 @@
     PatchSet.Id psId = change.currentPatchSetId();
     ChangeUpdate update = ctx.getUpdate(psId);
     if (!change.getStatus().isOpen()) {
-      throw new ResourceConflictException("change is " + status(change));
+      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
     } else if (change.getStatus() == Change.Status.DRAFT) {
       throw new ResourceConflictException("draft changes cannot be abandoned");
     }
@@ -137,8 +138,4 @@
     }
     changeAbandoned.fire(change, patchSet, account, msgTxt, ctx.getWhen(), notifyHandling);
   }
-
-  private static String status(Change change) {
-    return change != null ? change.getStatus().name().toLowerCase() : "deleted";
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java
index e680ea7..46916c8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java
@@ -23,8 +23,8 @@
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.util.RequestId;
 import com.google.gerrit.server.util.SubmoduleSectionParser;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -55,7 +55,7 @@
   private final RequestId submissionId;
   Set<SubmoduleSubscription> subscriptions;
 
-  @AssistedInject
+  @Inject
   GitModules(
       @CanonicalWebUrl @Nullable String canonicalWebUrl,
       @Assisted Branch.NameKey branch,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java
index 6a05d22..323f352 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java
@@ -142,16 +142,6 @@
     return Result.create(unchanged, updated, deleted);
   }
 
-  /**
-   * @param ctl change control (for any user).
-   * @param lt label type.
-   * @param id account ID.
-   * @return whether the given account ID has any permissions to vote on this label for this change.
-   */
-  public boolean canVote(ChangeControl ctl, LabelType lt, Account.Id id) {
-    return !getRange(ctl, lt, id).isEmpty();
-  }
-
   private PatchSetApproval copy(PatchSetApproval src) {
     return new PatchSetApproval(src.getPatchSetId(), src);
   }
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 1511da0..f837839 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
@@ -343,6 +343,8 @@
           commitStatus.problem(
               cd.getId(),
               "Change " + cd.getId() + " is " + cd.change().getStatus().toString().toLowerCase());
+        } else if (cd.change().isWorkInProgress()) {
+          commitStatus.problem(cd.getId(), "Change " + cd.getId() + " is work in progress");
         } else {
           checkSubmitRule(cd);
         }
@@ -458,8 +460,8 @@
     }
     // Done checks that don't involve running submit strategies.
     commitStatus.maybeFailVerbose();
-    SubmoduleOp submoduleOp = subOpFactory.create(branches, orm);
     try {
+      SubmoduleOp submoduleOp = subOpFactory.create(branches, orm);
       List<SubmitStrategy> strategies = getSubmitStrategies(toSubmit, submoduleOp, dryrun);
       this.allProjects = submoduleOp.getProjectsInOrder();
       batchUpdateFactory.execute(
@@ -519,9 +521,7 @@
             submitStrategyFactory.create(
                 submitting.submitType(),
                 db,
-                or.repo,
                 or.rw,
-                or.ins,
                 or.canMergeFlag,
                 getAlreadyAccepted(or, ob.oldTip),
                 allCommits,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSorter.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSorter.java
index 78fc495..733bf49 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSorter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSorter.java
@@ -29,20 +29,20 @@
   private final CodeReviewRevWalk rw;
   private final RevFlag canMergeFlag;
   private final Set<RevCommit> accepted;
+  private final Set<CodeReviewCommit> incoming;
 
-  public MergeSorter(CodeReviewRevWalk rw, Set<RevCommit> alreadyAccepted, RevFlag canMergeFlag) {
+  public MergeSorter(
+      CodeReviewRevWalk rw,
+      Set<RevCommit> alreadyAccepted,
+      RevFlag canMergeFlag,
+      Set<CodeReviewCommit> incoming) {
     this.rw = rw;
     this.canMergeFlag = canMergeFlag;
     this.accepted = alreadyAccepted;
+    this.incoming = incoming;
   }
 
   Collection<CodeReviewCommit> sort(final Collection<CodeReviewCommit> toMerge) throws IOException {
-    return sort(toMerge, toMerge);
-  }
-
-  Collection<CodeReviewCommit> sort(
-      final Collection<CodeReviewCommit> toMerge, final Collection<CodeReviewCommit> incoming)
-      throws IOException {
     final Set<CodeReviewCommit> heads = new HashSet<>();
     final Set<CodeReviewCommit> sort = new HashSet<>(toMerge);
     while (!sort.isEmpty()) {
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 2526db194..11e3051 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
@@ -209,11 +209,10 @@
   }
 
   public List<CodeReviewCommit> reduceToMinimalMerge(
-      MergeSorter mergeSorter, Collection<CodeReviewCommit> toSort, Set<CodeReviewCommit> incoming)
-      throws IntegrationException {
+      MergeSorter mergeSorter, Collection<CodeReviewCommit> toSort) throws IntegrationException {
     List<CodeReviewCommit> result = new ArrayList<>();
     try {
-      result.addAll(mergeSorter.sort(toSort, incoming));
+      result.addAll(mergeSorter.sort(toSort));
     } catch (IOException e) {
       throw new IntegrationException("Branch head sorting failed", e);
     }
@@ -222,8 +221,8 @@
   }
 
   public CodeReviewCommit createCherryPickFromCommit(
-      Repository repo,
       ObjectInserter inserter,
+      Config repoConfig,
       RevCommit mergeTip,
       RevCommit originalCommit,
       PersonIdent cherryPickCommitterIdent,
@@ -234,7 +233,7 @@
       throws MissingObjectException, IncorrectObjectTypeException, IOException,
           MergeIdenticalTreeException, MergeConflictException {
 
-    final ThreeWayMerger m = newThreeWayMerger(repo, inserter);
+    final ThreeWayMerger m = newThreeWayMerger(inserter, repoConfig);
 
     m.setBase(originalCommit.getParent(parentIndex));
     if (m.merge(mergeTip, originalCommit)) {
@@ -255,8 +254,8 @@
   }
 
   public static RevCommit createMergeCommit(
-      Repository repo,
       ObjectInserter inserter,
+      Config repoConfig,
       RevCommit mergeTip,
       RevCommit originalCommit,
       String mergeStrategy,
@@ -271,7 +270,7 @@
           "'" + originalCommit.getName() + "' has already been merged");
     }
 
-    Merger m = newMerger(repo, inserter, mergeStrategy);
+    Merger m = newMerger(inserter, repoConfig, mergeStrategy);
     if (m.merge(false, mergeTip, originalCommit)) {
       ObjectId tree = m.getResultTreeId();
 
@@ -486,7 +485,7 @@
     }
 
     try (ObjectInserter ins = new InMemoryInserter(repo)) {
-      return newThreeWayMerger(repo, ins).merge(new AnyObjectId[] {mergeTip, toMerge});
+      return newThreeWayMerger(ins, repo.getConfig()).merge(new AnyObjectId[] {mergeTip, toMerge});
     } catch (LargeObjectException e) {
       log.warn("Cannot merge due to LargeObjectException: " + toMerge.name());
       return false;
@@ -542,7 +541,7 @@
       // that on the current merge tip.
       //
       try (ObjectInserter ins = new InMemoryInserter(repo)) {
-        ThreeWayMerger m = newThreeWayMerger(repo, ins);
+        ThreeWayMerger m = newThreeWayMerger(ins, repo.getConfig());
         m.setBase(toMerge.getParent(0));
         return m.merge(mergeTip, toMerge);
       } catch (IOException e) {
@@ -575,14 +574,14 @@
   public CodeReviewCommit mergeOneCommit(
       PersonIdent author,
       PersonIdent committer,
-      Repository repo,
       CodeReviewRevWalk rw,
       ObjectInserter inserter,
+      Config repoConfig,
       Branch.NameKey destBranch,
       CodeReviewCommit mergeTip,
       CodeReviewCommit n)
       throws IntegrationException {
-    final ThreeWayMerger m = newThreeWayMerger(repo, inserter);
+    ThreeWayMerger m = newThreeWayMerger(inserter, repoConfig);
     try {
       if (m.merge(new AnyObjectId[] {mergeTip, n})) {
         return writeMergeCommit(
@@ -706,8 +705,8 @@
     }
   }
 
-  public ThreeWayMerger newThreeWayMerger(final Repository repo, final ObjectInserter inserter) {
-    return newThreeWayMerger(repo, inserter, mergeStrategyName());
+  public ThreeWayMerger newThreeWayMerger(ObjectInserter inserter, Config repoConfig) {
+    return newThreeWayMerger(inserter, repoConfig, mergeStrategyName());
   }
 
   public String mergeStrategyName() {
@@ -730,8 +729,8 @@
   }
 
   public static ThreeWayMerger newThreeWayMerger(
-      Repository repo, final ObjectInserter inserter, String strategyName) {
-    Merger m = newMerger(repo, inserter, strategyName);
+      ObjectInserter inserter, Config repoConfig, String strategyName) {
+    Merger m = newMerger(inserter, repoConfig, strategyName);
     checkArgument(
         m instanceof ThreeWayMerger,
         "merge strategy %s does not support three-way merging",
@@ -739,12 +738,10 @@
     return (ThreeWayMerger) m;
   }
 
-  public static Merger newMerger(
-      Repository repo, final ObjectInserter inserter, String strategyName) {
+  public static Merger newMerger(ObjectInserter inserter, Config repoConfig, String strategyName) {
     MergeStrategy strategy = MergeStrategy.get(strategyName);
     checkArgument(strategy != null, "invalid merge strategy: %s", strategyName);
-    Merger m = strategy.newMerger(repo, true);
-    m.setObjectInserter(
+    return strategy.newMerger(
         new ObjectInserter.Filter() {
           @Override
           protected ObjectInserter delegate() {
@@ -756,8 +753,8 @@
 
           @Override
           public void close() {}
-        });
-    return m;
+        },
+        repoConfig);
   }
 
   public void markCleanMerges(
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 a7c8b53..9439a8b 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
@@ -34,9 +34,9 @@
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.Collections;
 import java.util.concurrent.ExecutorService;
@@ -74,7 +74,7 @@
   private PatchSet patchSet;
   private PatchSetInfo info;
 
-  @AssistedInject
+  @Inject
   MergedByPushOp(
       PatchSetInfoFactory patchSetInfoFactory,
       ChangeMessagesUtil cmUtil,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java
index e3b1ad6..b057c92 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java
@@ -22,7 +22,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -185,7 +184,7 @@
   private boolean closeRepository;
   private IdentifiedUser author;
 
-  @AssistedInject
+  @Inject
   public MetaDataUpdate(
       GitReferenceUpdated gitRefUpdated,
       @Assisted Project.NameKey projectName,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/PerThreadRequestScope.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/PerThreadRequestScope.java
index 20f053a..ac031eb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/PerThreadRequestScope.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/PerThreadRequestScope.java
@@ -64,18 +64,15 @@
     }
 
     public <T> Callable<T> scope(RequestContext requestContext, Callable<T> callable) {
-      final Context ctx = new Context();
-      final Callable<T> wrapped = context(requestContext, cleanup(callable));
-      return new Callable<T>() {
-        @Override
-        public T call() throws Exception {
-          Context old = current.get();
-          current.set(ctx);
-          try {
-            return wrapped.call();
-          } finally {
-            current.set(old);
-          }
+      Context ctx = new Context();
+      Callable<T> wrapped = context(requestContext, cleanup(callable));
+      return () -> {
+        Context old = current.get();
+        current.set(ctx);
+        try {
+          return wrapped.call();
+        } finally {
+          current.set(old);
         }
       };
     }
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 61d8cfe..f1a35d7 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
@@ -155,6 +155,9 @@
       ImmutableSet.of(
           "MaxWithBlock", "AnyWithBlock", "MaxNoBlock", "NoBlock", "NoOp", "PatchSetLock");
 
+  private static final String REVIEWER = "reviewer";
+  private static final String KEY_ENABLE_REVIEWER_BY_EMAIL = "enableByEmail";
+
   private static final String LEGACY_PERMISSION_PUSH_TAG = "pushTag";
   private static final String LEGACY_PERMISSION_PUSH_SIGNED_TAG = "pushSignedTag";
 
@@ -163,6 +166,9 @@
   private static final SubmitType DEFAULT_SUBMIT_ACTION = SubmitType.MERGE_IF_NECESSARY;
   private static final ProjectState DEFAULT_STATE_VALUE = ProjectState.ACTIVE;
 
+  private static final String EXTENSION_PANELS = "extension-panels";
+  private static final String KEY_PANEL = "panel";
+
   private Project.NameKey projectName;
   private Project project;
   private AccountsSection accountsSection;
@@ -182,6 +188,7 @@
   private boolean checkReceivedObjects;
   private Set<String> sectionsWithUnknownPermissions;
   private boolean hasLegacyPermissions;
+  private Map<String, List<String>> extensionPanelSections;
 
   public static ProjectConfig read(MetaDataUpdate update)
       throws IOException, ConfigInvalidException {
@@ -197,6 +204,10 @@
     return r;
   }
 
+  public Map<String, List<String>> getExtensionPanelSections() {
+    return extensionPanelSections;
+  }
+
   public static CommentLinkInfoImpl buildCommentLink(Config cfg, String name, boolean allowRaw)
       throws IllegalArgumentException {
     String match = cfg.getString(COMMENTLINK, name, KEY_MATCH);
@@ -507,6 +518,8 @@
     p.setMaxObjectSizeLimit(rc.getString(RECEIVE, null, KEY_MAX_OBJECT_SIZE_LIMIT));
     p.setRejectImplicitMerges(
         getEnum(rc, RECEIVE, null, KEY_REJECT_IMPLICIT_MERGES, InheritableBoolean.INHERIT));
+    p.setEnableReviewerByEmail(
+        getEnum(rc, REVIEWER, null, KEY_ENABLE_REVIEWER_BY_EMAIL, InheritableBoolean.INHERIT));
 
     p.setSubmitType(getEnum(rc, SUBMIT, null, KEY_ACTION, DEFAULT_SUBMIT_ACTION));
     p.setUseContentMerge(getEnum(rc, SUBMIT, null, KEY_MERGE_CONTENT, InheritableBoolean.INHERIT));
@@ -526,6 +539,7 @@
     mimeTypes = new ConfiguredMimeTypes(projectName.get(), rc);
     loadPluginSections(rc);
     loadReceiveSection(rc);
+    loadExtensionPanelSections(rc);
   }
 
   private void loadAccountsSection(Config rc, Map<String, GroupReference> groupsByName) {
@@ -534,6 +548,25 @@
         loadPermissionRules(rc, ACCOUNTS, null, KEY_SAME_GROUP_VISIBILITY, groupsByName, false));
   }
 
+  private void loadExtensionPanelSections(Config rc) {
+    Map<String, String> lowerNames = Maps.newHashMapWithExpectedSize(2);
+    extensionPanelSections = Maps.newLinkedHashMap();
+    for (String name : rc.getSubsections(EXTENSION_PANELS)) {
+      String lower = name.toLowerCase();
+      if (lowerNames.containsKey(lower)) {
+        error(
+            new ValidationError(
+                PROJECT_CONFIG,
+                String.format(
+                    "Extension Panels \"%s\" conflicts with \"%s\"", name, lowerNames.get(lower))));
+      }
+      lowerNames.put(lower, name);
+      extensionPanelSections.put(
+          name,
+          new ArrayList<>(Arrays.asList(rc.getStringList(EXTENSION_PANELS, name, KEY_PANEL))));
+    }
+  }
+
   private void loadContributorAgreements(Config rc, Map<String, GroupReference> groupsByName) {
     contributorAgreements = new HashMap<>();
     for (String name : rc.getSubsections(CONTRIBUTOR_AGREEMENT)) {
@@ -1052,6 +1085,13 @@
         KEY_REJECT_IMPLICIT_MERGES,
         p.getRejectImplicitMerges(),
         InheritableBoolean.INHERIT);
+    set(
+        rc,
+        REVIEWER,
+        null,
+        KEY_ENABLE_REVIEWER_BY_EMAIL,
+        p.getEnableReviewerByEmail(),
+        InheritableBoolean.INHERIT);
 
     set(rc, SUBMIT, null, KEY_ACTION, p.getSubmitType(), DEFAULT_SUBMIT_ACTION);
     set(rc, SUBMIT, null, KEY_MERGE_CONTENT, p.getUseContentMerge(), InheritableBoolean.INHERIT);
@@ -1288,40 +1328,55 @@
 
       setBooleanConfigKey(
           rc,
+          LABEL,
           name,
           KEY_ALLOW_POST_SUBMIT,
           label.allowPostSubmit(),
           LabelType.DEF_ALLOW_POST_SUBMIT);
       setBooleanConfigKey(
-          rc, name, KEY_COPY_MIN_SCORE, label.isCopyMinScore(), LabelType.DEF_COPY_MIN_SCORE);
-      setBooleanConfigKey(
-          rc, name, KEY_COPY_MAX_SCORE, label.isCopyMaxScore(), LabelType.DEF_COPY_MAX_SCORE);
+          rc,
+          LABEL,
+          name,
+          KEY_COPY_MIN_SCORE,
+          label.isCopyMinScore(),
+          LabelType.DEF_COPY_MIN_SCORE);
       setBooleanConfigKey(
           rc,
+          LABEL,
+          name,
+          KEY_COPY_MAX_SCORE,
+          label.isCopyMaxScore(),
+          LabelType.DEF_COPY_MAX_SCORE);
+      setBooleanConfigKey(
+          rc,
+          LABEL,
           name,
           KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
           label.isCopyAllScoresOnTrivialRebase(),
           LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
       setBooleanConfigKey(
           rc,
+          LABEL,
           name,
           KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
           label.isCopyAllScoresIfNoCodeChange(),
           LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
       setBooleanConfigKey(
           rc,
+          LABEL,
           name,
           KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
           label.isCopyAllScoresIfNoChange(),
           LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE);
       setBooleanConfigKey(
           rc,
+          LABEL,
           name,
           KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
           label.isCopyAllScoresOnMergeFirstParentUpdate(),
           LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
       setBooleanConfigKey(
-          rc, name, KEY_CAN_OVERRIDE, label.canOverride(), LabelType.DEF_CAN_OVERRIDE);
+          rc, LABEL, name, KEY_CAN_OVERRIDE, label.canOverride(), LabelType.DEF_CAN_OVERRIDE);
       List<String> values = Lists.newArrayListWithCapacity(label.getValues().size());
       for (LabelValue value : label.getValues()) {
         values.add(value.format());
@@ -1335,11 +1390,11 @@
   }
 
   private static void setBooleanConfigKey(
-      Config rc, String name, String key, boolean value, boolean defaultValue) {
+      Config rc, String section, String name, String key, boolean value, boolean defaultValue) {
     if (value == defaultValue) {
-      rc.unset(LABEL, name, key);
+      rc.unset(section, name, key);
     } else {
-      rc.setBoolean(LABEL, name, key, value);
+      rc.setBoolean(section, name, key, value);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/RebaseSorter.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/RebaseSorter.java
index 94e78f8..dbfb19c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/RebaseSorter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/RebaseSorter.java
@@ -42,23 +42,26 @@
   private final RevCommit initialTip;
   private final Set<RevCommit> alreadyAccepted;
   private final InternalChangeQuery internalChangeQuery;
+  private final Set<CodeReviewCommit> incoming;
 
   public RebaseSorter(
       CodeReviewRevWalk rw,
       RevCommit initialTip,
       Set<RevCommit> alreadyAccepted,
       RevFlag canMergeFlag,
-      InternalChangeQuery internalChangeQuery) {
+      InternalChangeQuery internalChangeQuery,
+      Set<CodeReviewCommit> incoming) {
     this.rw = rw;
     this.canMergeFlag = canMergeFlag;
     this.initialTip = initialTip;
     this.alreadyAccepted = alreadyAccepted;
     this.internalChangeQuery = internalChangeQuery;
+    this.incoming = incoming;
   }
 
-  public List<CodeReviewCommit> sort(Collection<CodeReviewCommit> incoming) throws IOException {
+  public List<CodeReviewCommit> sort(Collection<CodeReviewCommit> toSort) throws IOException {
     final List<CodeReviewCommit> sorted = new ArrayList<>();
-    final Set<CodeReviewCommit> sort = new HashSet<>(incoming);
+    final Set<CodeReviewCommit> sort = new HashSet<>(toSort);
     while (!sort.isEmpty()) {
       final CodeReviewCommit n = removeOne(sort);
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
index 399dfc7..ac504c4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.git;
 
+import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
@@ -92,7 +93,6 @@
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.MultiProgressMonitor.Task;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
@@ -105,6 +105,12 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -117,6 +123,9 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.update.RepoOnlyOp;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.MagicBranch;
@@ -147,7 +156,6 @@
 import java.util.regex.Pattern;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
@@ -292,8 +300,9 @@
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeNotes.Factory notesFactory;
   private final AccountResolver accountResolver;
+  private final PermissionBackend permissionBackend;
+  private final PermissionBackend.ForProject permissions;
   private final CmdLineParser.Factory optionParserFactory;
-  private final GitReferenceUpdated gitRefUpdated;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final PatchSetUtil psUtil;
   private final ProjectCache projectCache;
@@ -341,6 +350,15 @@
   private final ChangeEditUtil editUtil;
   private final ChangeIndexer indexer;
 
+  /**
+   * Actual commands to be executed, as opposed to the mix of actual and magic commands that were
+   * provided over the wire.
+   *
+   * <p>Excludes commands executed implicitly as part of other {@link BatchUpdateOp}s, such as
+   * creating patch set refs.
+   */
+  private final List<ReceiveCommand> actualCommands = new ArrayList<>();
+
   private final List<ValidationMessage> messages = new ArrayList<>();
   private ListMultimap<Error, String> errors = LinkedListMultimap.create();
   private Task newProgress;
@@ -348,7 +366,6 @@
   private Task closeProgress;
   private Task commandProgress;
   private MessageSender messageSender;
-  private BatchRefUpdate batch;
 
   @Inject
   ReceiveCommits(
@@ -357,8 +374,8 @@
       Provider<InternalChangeQuery> queryProvider,
       ChangeNotes.Factory notesFactory,
       AccountResolver accountResolver,
+      PermissionBackend permissionBackend,
       CmdLineParser.Factory optionParserFactory,
-      GitReferenceUpdated gitRefUpdated,
       PatchSetInfoFactory patchSetInfoFactory,
       PatchSetUtil psUtil,
       ProjectCache projectCache,
@@ -389,15 +406,15 @@
       SetHashtagsOp.Factory hashtagsFactory,
       ReplaceOp.Factory replaceOpFactory,
       MergedByPushOp.Factory mergedByPushOpFactory)
-      throws IOException {
+      throws IOException, PermissionBackendException {
     this.user = projectControl.getUser().asIdentifiedUser();
     this.db = db;
     this.seq = seq;
     this.queryProvider = queryProvider;
     this.notesFactory = notesFactory;
     this.accountResolver = accountResolver;
+    this.permissionBackend = permissionBackend;
     this.optionParserFactory = optionParserFactory;
-    this.gitRefUpdated = gitRefUpdated;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.psUtil = psUtil;
     this.projectCache = projectCache;
@@ -463,9 +480,15 @@
           }
         });
 
-    if (!projectControl.allRefsAreVisible()) {
+    permissions = permissionBackend.user(user).project(project.getNameKey());
+    // If the user lacks READ permission, some references may be filtered and hidden from view.
+    // Check objects mentioned inside the incoming pack file are reachable from visible refs.
+    try {
+      permissionBackend.user(user).project(project.getNameKey()).check(ProjectPermission.READ);
+    } catch (AuthException e) {
       rp.setCheckReferencedObjectsAreReachable(receiveConfig.checkReferencedObjectsAreReachable);
     }
+
     rp.setAdvertiseRefsHook(
         new VisibleRefFilter(tagCache, notesFactory, changeCache, repo, projectControl, db, false));
     List<AdvertiseRefsHook> advHooks = new ArrayList<>(3);
@@ -574,37 +597,20 @@
     closeProgress = progress.beginSubTask("closed", UNKNOWN);
     commandProgress = progress.beginSubTask("refs", UNKNOWN);
 
-    batch = repo.getRefDatabase().newBatchUpdate();
-    batch.setPushCertificate(rp.getPushCertificate());
-    batch.setRefLogIdent(rp.getRefLogIdent());
-    batch.setRefLogMessage("push", true);
-
-    parseCommands(commands);
+    try {
+      parseCommands(commands);
+    } catch (PermissionBackendException err) {
+      for (ReceiveCommand cmd : actualCommands) {
+        if (cmd.getResult() == NOT_ATTEMPTED) {
+          cmd.setResult(REJECTED_OTHER_REASON, "internal server error");
+        }
+      }
+      logError(String.format("Failed to process refs in %s", project.getName()), err);
+    }
     if (magicBranch != null && magicBranch.cmd.getResult() == NOT_ATTEMPTED) {
       selectNewAndReplacedChangesFromMagicBranch();
     }
     preparePatchSetsForReplace();
-
-    logDebug("Executing batch with {} commands", batch.getCommands().size());
-    if (!batch.getCommands().isEmpty()) {
-      try {
-        if (!batch.isAllowNonFastForwards() && magicBranch != null && magicBranch.edit) {
-          logDebug("Allowing non-fast-forward for edit ref");
-          batch.setAllowNonFastForwards(true);
-        }
-        batch.execute(rp.getRevWalk(), commandProgress);
-      } catch (IOException err) {
-        int cnt = 0;
-        for (ReceiveCommand cmd : batch.getCommands()) {
-          if (cmd.getResult() == NOT_ATTEMPTED) {
-            cmd.setResult(REJECTED_OTHER_REASON, "internal server error");
-            cnt++;
-          }
-        }
-        logError(String.format("Failed to store %d refs in %s", cnt, project.getName()), err);
-      }
-    }
-
     insertChangesAndPatchSets();
     newProgress.end();
     replaceProgress.end();
@@ -619,44 +625,24 @@
     }
 
     Set<Branch.NameKey> branches = new HashSet<>();
-    for (ReceiveCommand c : batch.getCommands()) {
-      if (c.getResult() == OK) {
-        String refName = c.getRefName();
-        if (c.getType() == ReceiveCommand.Type.UPDATE) { // aka fast-forward
-          logDebug("Updating tag cache on fast-forward of {}", c.getRefName());
-          tagCache.updateFastForward(project.getNameKey(), refName, c.getOldId(), c.getNewId());
-        }
+    for (ReceiveCommand c : actualCommands) {
+      // Most post-update steps should happen in UpdateOneRefOp#postUpdate. The only steps that
+      // should happen in this loop are things that can't happen within one BatchUpdate because they
+      // involve kicking off an additional BatchUpdate.
+      if (c.getResult() != OK) {
+        continue;
+      }
+      if (isHead(c) || isConfig(c)) {
+        switch (c.getType()) {
+          case CREATE:
+          case UPDATE:
+          case UPDATE_NONFASTFORWARD:
+            autoCloseChanges(c);
+            branches.add(new Branch.NameKey(project.getNameKey(), c.getRefName()));
+            break;
 
-        if (isHead(c) || isConfig(c)) {
-          switch (c.getType()) {
-            case CREATE:
-            case UPDATE:
-            case UPDATE_NONFASTFORWARD:
-              autoCloseChanges(c);
-              branches.add(new Branch.NameKey(project.getNameKey(), refName));
-              break;
-
-            case DELETE:
-              break;
-          }
-        }
-
-        if (isConfig(c)) {
-          logDebug("Reloading project in cache");
-          projectCache.evict(project);
-          ProjectState ps = projectCache.get(project.getNameKey());
-          try {
-            repo.setGitwebDescription(ps.getProject().getDescription());
-          } catch (IOException e) {
-            log.warn("cannot update description of " + project.getName(), e);
-          }
-        }
-
-        if (!MagicBranch.isMagicBranch(refName)) {
-          logDebug("Firing ref update for {}", c.getRefName());
-          gitRefUpdated.fire(project.getNameKey(), c, user.getAccount());
-        } else {
-          logDebug("Assuming ref update event for {} has fired", c.getRefName());
+          case DELETE:
+            break;
         }
       }
     }
@@ -751,115 +737,42 @@
   }
 
   private void insertChangesAndPatchSets() {
-    int replaceCount = 0;
-    int okToInsert = 0;
-
-    for (Map.Entry<Change.Id, ReplaceRequest> e : replaceByChange.entrySet()) {
-      ReplaceRequest replace = e.getValue();
-      if (magicBranch != null && replace.inputCommand == magicBranch.cmd) {
-        replaceCount++;
-
-        if (replace.cmd != null && replace.cmd.getResult() == OK) {
-          okToInsert++;
-        }
-      } else if (replace.cmd != null && replace.cmd.getResult() == OK) {
-        String refName = replace.inputCommand.getRefName();
-        checkState(
-            NEW_PATCHSET.matcher(refName).matches(),
-            "expected a new patch set command as input when creating %s; got %s",
-            replace.cmd.getRefName(),
-            refName);
-        try {
-          logDebug("One-off insertion of patch set for {}", refName);
-          replace.insertPatchSetWithoutBatchUpdate();
-          replace.inputCommand.setResult(OK);
-        } catch (IOException | UpdateException | RestApiException err) {
-          reject(replace.inputCommand, "internal server error");
-          logError(
-              String.format(
-                  "Cannot add patch set to change %d in project %s",
-                  e.getKey().get(), project.getName()),
-              err);
-        }
-      } else if (replace.inputCommand.getResult() == NOT_ATTEMPTED) {
-        reject(replace.inputCommand, "internal server error");
-        logError(String.format("Replacement for project %s was not attempted", project.getName()));
-      }
-    }
-
-    // refs/for/ or refs/drafts/ not used, or it already failed earlier.
-    // No need to continue.
-    if (magicBranch == null) {
-      logDebug("No magic branch, nothing more to do");
-      return;
-    } else if (magicBranch.cmd.getResult() != NOT_ATTEMPTED) {
+    ReceiveCommand magicBranchCmd = magicBranch != null ? magicBranch.cmd : null;
+    if (magicBranchCmd != null && magicBranchCmd.getResult() != NOT_ATTEMPTED) {
       logWarn(
           String.format(
               "Skipping change updates on %s because ref update failed: %s %s",
               project.getName(),
-              magicBranch.cmd.getResult(),
-              Strings.nullToEmpty(magicBranch.cmd.getMessage())));
-      return;
-    }
-
-    List<String> lastCreateChangeErrors = new ArrayList<>();
-    for (CreateRequest create : newChanges) {
-      if (create.cmd.getResult() == OK) {
-        okToInsert++;
-      } else {
-        String createChangeResult =
-            String.format(
-                    "%s %s", create.cmd.getResult(), Strings.nullToEmpty(create.cmd.getMessage()))
-                .trim();
-        lastCreateChangeErrors.add(createChangeResult);
-        logError(
-            String.format(
-                "Command %s on %s:%s not completed: %s",
-                create.cmd.getType(),
-                project.getName(),
-                create.cmd.getRefName(),
-                createChangeResult));
-      }
-    }
-
-    logDebug(
-        "Counted {} ok to insert, out of {} to replace and {} new",
-        okToInsert,
-        replaceCount,
-        newChanges.size());
-
-    if (okToInsert != replaceCount + newChanges.size()) {
-      // One or more new references failed to create. Assume the
-      // system isn't working correctly anymore and abort.
-      reject(
-          magicBranch.cmd,
-          "Unable to create changes: " + lastCreateChangeErrors.stream().collect(joining(" ")));
-      logError(
-          String.format(
-              "Only %d of %d new change refs created in %s; aborting",
-              okToInsert, replaceCount + newChanges.size(), project.getName()));
+              magicBranchCmd.getResult(),
+              Strings.nullToEmpty(magicBranchCmd.getMessage())));
       return;
     }
 
     try (BatchUpdate bu =
             batchUpdateFactory.create(
-                db, magicBranch.dest.getParentKey(), user.materializedCopy(), TimeUtil.nowTs());
-        ObjectInserter ins = repo.newObjectInserter()) {
-      bu.setRepository(repo, rp.getRevWalk(), ins).updateChangesInParallel();
+                db, project.getNameKey(), user.materializedCopy(), TimeUtil.nowTs());
+        ObjectInserter ins = repo.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      bu.setRepository(repo, rw, ins).updateChangesInParallel();
       bu.setRequestId(receiveId);
+      bu.setRefLogMessage("push");
+
+      logDebug("Adding {} replace requests", newChanges.size());
       for (ReplaceRequest replace : replaceByChange.values()) {
-        if (replace.inputCommand == magicBranch.cmd) {
-          replace.addOps(bu, replaceProgress);
-        }
+        replace.addOps(bu, replaceProgress);
       }
 
+      logDebug("Adding {} create requests", newChanges.size());
       for (CreateRequest create : newChanges) {
         create.addOps(bu);
       }
 
-      for (UpdateGroupsRequest update : updateGroups) {
-        update.addOps(bu);
-      }
+      logDebug("Adding {} group update requests", newChanges.size());
+      updateGroups.forEach(r -> r.addOps(bu));
+
+      logDebug("Adding {} additional ref updates", actualCommands.size());
+      actualCommands.forEach(c -> bu.addRepoOnlyOp(new UpdateOneRefOp(c)));
 
       logDebug("Executing batch");
       try {
@@ -867,10 +780,17 @@
       } catch (UpdateException e) {
         throw INSERT_EXCEPTION.apply(e);
       }
-      magicBranch.cmd.setResult(OK);
+      if (magicBranchCmd != null) {
+        magicBranchCmd.setResult(OK);
+      }
       for (ReplaceRequest replace : replaceByChange.values()) {
         String rejectMessage = replace.getRejectMessage();
-        if (rejectMessage != null) {
+        if (rejectMessage == null) {
+          if (replace.inputCommand.getResult() == NOT_ATTEMPTED) {
+            // Not necessarily the magic branch, so need to set OK on the original value.
+            replace.inputCommand.setResult(OK);
+          }
+        } else {
           logDebug("Rejecting due to message from ReplaceOp");
           reject(replace.inputCommand, rejectMessage);
         }
@@ -878,10 +798,10 @@
 
     } catch (ResourceConflictException e) {
       addMessage(e.getMessage());
-      reject(magicBranch.cmd, "conflict");
+      reject(magicBranchCmd, "conflict");
     } catch (RestApiException | IOException err) {
       logError("Can't insert change/patch set for " + project.getName(), err);
-      reject(magicBranch.cmd, "internal server error: " + err.getMessage());
+      reject(magicBranchCmd, "internal server error: " + err.getMessage());
     }
 
     if (magicBranch != null && magicBranch.submit) {
@@ -889,10 +809,10 @@
         submit(newChanges, replaceByChange.values());
       } catch (ResourceConflictException e) {
         addMessage(e.getMessage());
-        reject(magicBranch.cmd, "conflict");
+        reject(magicBranchCmd, "conflict");
       } catch (RestApiException | OrmException e) {
         logError("Error submitting changes to " + project.getName(), e);
-        reject(magicBranch.cmd, "error during submit");
+        reject(magicBranchCmd, "error during submit");
       }
     }
   }
@@ -921,7 +841,8 @@
     return displayName;
   }
 
-  private void parseCommands(Collection<ReceiveCommand> commands) {
+  private void parseCommands(Collection<ReceiveCommand> commands)
+      throws PermissionBackendException {
     List<String> optionList = rp.getPushOptions();
     if (optionList != null) {
       for (String option : optionList) {
@@ -1040,10 +961,13 @@
                   continue;
                 }
               } else {
-                if (!oldParent.equals(newParent)
-                    && !user.getCapabilities().canAdministrateServer()) {
-                  reject(cmd, "invalid project configuration: only Gerrit admin can set parent");
-                  continue;
+                if (!oldParent.equals(newParent)) {
+                  try {
+                    permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+                  } catch (AuthException e) {
+                    reject(cmd, "invalid project configuration: only Gerrit admin can set parent");
+                    continue;
+                  }
                 }
 
                 if (projectCache.get(newParent) == null) {
@@ -1146,30 +1070,35 @@
         return;
       }
       validateNewCommits(ctl, cmd);
-      batch.addCommand(cmd);
+      actualCommands.add(cmd);
     } else {
       reject(cmd, "prohibited by Gerrit: create access denied for " + cmd.getRefName());
     }
   }
 
-  private void parseUpdate(ReceiveCommand cmd) {
+  private void parseUpdate(ReceiveCommand cmd) throws PermissionBackendException {
     logDebug("Updating {}", cmd);
-    RefControl ctl = projectControl.controlForRef(cmd.getRefName());
-    if (ctl.canUpdate()) {
+    boolean ok;
+    try {
+      permissions.ref(cmd.getRefName()).check(RefPermission.UPDATE);
+      ok = true;
+    } catch (AuthException err) {
+      ok = false;
+    }
+    if (ok) {
       if (isHead(cmd) && !isCommit(cmd)) {
         return;
       }
-
       if (!validRefOperation(cmd)) {
         return;
       }
-      validateNewCommits(ctl, cmd);
-      batch.addCommand(cmd);
+      validateNewCommits(projectControl.controlForRef(cmd.getRefName()), cmd);
+      actualCommands.add(cmd);
     } else {
-      if (RefNames.REFS_CONFIG.equals(ctl.getRefName())) {
+      if (RefNames.REFS_CONFIG.equals(cmd.getRefName())) {
         errors.put(Error.CONFIG_UPDATE, RefNames.REFS_CONFIG);
       } else {
-        errors.put(Error.UPDATE, ctl.getRefName());
+        errors.put(Error.UPDATE, cmd.getRefName());
       }
       reject(cmd, "prohibited by Gerrit: ref update access denied");
     }
@@ -1192,28 +1121,34 @@
     return false;
   }
 
-  private void parseDelete(ReceiveCommand cmd) {
+  private void parseDelete(ReceiveCommand cmd) throws PermissionBackendException {
     logDebug("Deleting {}", cmd);
-    RefControl ctl = projectControl.controlForRef(cmd.getRefName());
-    if (ctl.getRefName().startsWith(REFS_CHANGES)) {
-      errors.put(Error.DELETE_CHANGES, ctl.getRefName());
+    if (cmd.getRefName().startsWith(REFS_CHANGES)) {
+      errors.put(Error.DELETE_CHANGES, cmd.getRefName());
       reject(cmd, "cannot delete changes");
-    } else if (ctl.canDelete()) {
+    } else if (canDelete(cmd)) {
       if (!validRefOperation(cmd)) {
         return;
       }
-      batch.addCommand(cmd);
+      actualCommands.add(cmd);
+    } else if (RefNames.REFS_CONFIG.equals(cmd.getRefName())) {
+      reject(cmd, "cannot delete project configuration");
     } else {
-      if (RefNames.REFS_CONFIG.equals(ctl.getRefName())) {
-        reject(cmd, "cannot delete project configuration");
-      } else {
-        errors.put(Error.DELETE, ctl.getRefName());
-        reject(cmd, "cannot delete references");
-      }
+      errors.put(Error.DELETE, cmd.getRefName());
+      reject(cmd, "cannot delete references");
     }
   }
 
-  private void parseRewind(ReceiveCommand cmd) {
+  private boolean canDelete(ReceiveCommand cmd) throws PermissionBackendException {
+    try {
+      permissions.ref(cmd.getRefName()).check(RefPermission.DELETE);
+      return true;
+    } catch (AuthException e) {
+      return false;
+    }
+  }
+
+  private void parseRewind(ReceiveCommand cmd) throws PermissionBackendException {
     RevCommit newObject;
     try {
       newObject = rp.getRevWalk().parseCommit(cmd.getNewId());
@@ -1236,11 +1171,18 @@
       }
     }
 
-    if (ctl.canForceUpdate()) {
+    boolean ok;
+    try {
+      permissions.ref(cmd.getRefName()).check(RefPermission.FORCE_UPDATE);
+      ok = true;
+    } catch (AuthException err) {
+      ok = false;
+    }
+    if (ok) {
       if (!validRefOperation(cmd)) {
         return;
       }
-      batch.setAllowNonFastForwards(true).addCommand(cmd);
+      actualCommands.add(cmd);
     } else {
       cmd.setResult(
           REJECTED_NONFASTFORWARD, " need '" + PermissionRule.FORCE_PUSH + "' privilege.");
@@ -1251,6 +1193,9 @@
     private static final Splitter COMMAS = Splitter.on(',').omitEmptyStrings();
 
     final ReceiveCommand cmd;
+    final LabelTypes labelTypes;
+    final NotesMigration notesMigration;
+    private final boolean defaultPublishComments;
     Branch.NameKey dest;
     RefControl ctl;
     Set<Account.Id> reviewer = Sets.newLinkedHashSet();
@@ -1258,10 +1203,8 @@
     Map<String, Short> labels = new HashMap<>();
     String message;
     List<RevCommit> baseCommit;
-    LabelTypes labelTypes;
     CmdLineParser clp;
     Set<String> hashtags = new HashSet<>();
-    NotesMigration notesMigration;
 
     @Option(name = "--base", metaVar = "BASE", usage = "merge base of changes")
     List<ObjectId> base;
@@ -1272,6 +1215,22 @@
     @Option(name = "--draft", usage = "mark new/updated changes as draft")
     boolean draft;
 
+    @Option(name = "--private", usage = "mark new/updated change as private")
+    boolean isPrivate;
+
+    @Option(name = "--remove-private", usage = "remove privacy flag from updated change")
+    boolean removePrivate;
+
+    @Option(
+      name = "--wip",
+      aliases = {"-work-in-progress"},
+      usage = "mark change as work in progress"
+    )
+    boolean workInProgress;
+
+    @Option(name = "--ready", usage = "mark change as ready")
+    boolean ready;
+
     @Option(
       name = "--edit",
       aliases = {"-e"},
@@ -1285,6 +1244,16 @@
     @Option(name = "--merged", usage = "create single change for a merged commit")
     boolean merged;
 
+    @Option(name = "--publish-comments", usage = "publish all draft comments on updated changes")
+    private boolean publishComments;
+
+    @Option(
+      name = "--no-publish-comments",
+      aliases = {"--np"},
+      usage = "do not publish draft comments"
+    )
+    private boolean noPublishComments;
+
     @Option(
       name = "--notify",
       usage =
@@ -1368,11 +1337,17 @@
       //TODO(dpursehouse): validate hashtags
     }
 
-    MagicBranchInput(ReceiveCommand cmd, LabelTypes labelTypes, NotesMigration notesMigration) {
+    MagicBranchInput(
+        IdentifiedUser user,
+        ReceiveCommand cmd,
+        LabelTypes labelTypes,
+        NotesMigration notesMigration) {
       this.cmd = cmd;
       this.draft = cmd.getRefName().startsWith(MagicBranch.NEW_DRAFT_CHANGE);
       this.labelTypes = labelTypes;
       this.notesMigration = notesMigration;
+      this.defaultPublishComments =
+          firstNonNull(user.getAccount().getGeneralPreferencesInfo().publishCommentsOnPush, false);
     }
 
     MailRecipients getMailRecipients() {
@@ -1388,6 +1363,15 @@
       return accountsToNotify;
     }
 
+    boolean shouldPublishComments() {
+      if (publishComments) {
+        return true;
+      } else if (noPublishComments) {
+        return false;
+      }
+      return defaultPublishComments;
+    }
+
     String parse(
         CmdLineParser clp,
         Repository repo,
@@ -1457,7 +1441,7 @@
     }
 
     logDebug("Found magic branch {}", cmd.getRefName());
-    magicBranch = new MagicBranchInput(cmd, labelTypes, notesMigration);
+    magicBranch = new MagicBranchInput(user, cmd, labelTypes, notesMigration);
     magicBranch.reviewer.addAll(reviewersFromCommandLine);
     magicBranch.cc.addAll(ccFromCommandLine);
 
@@ -1500,7 +1484,8 @@
 
     magicBranch.dest = new Branch.NameKey(project.getNameKey(), ref);
     magicBranch.ctl = projectControl.controlForRef(ref);
-    if (!magicBranch.ctl.canWrite()) {
+    if (projectControl.getProject().getState()
+        != com.google.gerrit.extensions.client.ProjectState.ACTIVE) {
       reject(cmd, "project is read only");
       return;
     }
@@ -1525,6 +1510,21 @@
       return;
     }
 
+    if (magicBranch.isPrivate && magicBranch.removePrivate) {
+      reject(cmd, "the options 'private' and 'remove-private' are mutually exclusive");
+      return;
+    }
+
+    if (magicBranch.workInProgress && magicBranch.ready) {
+      reject(cmd, "the options 'wip' and 'ready' are mutually exclusive");
+      return;
+    }
+    if (magicBranch.publishComments && magicBranch.noPublishComments) {
+      reject(
+          cmd, "the options 'publish-comments' and 'no-publish-comments' are mutually exclusive");
+      return;
+    }
+
     if (magicBranch.draft && magicBranch.submit) {
       reject(cmd, "cannot submit draft");
       return;
@@ -1971,7 +1971,6 @@
       for (int i = 0; i < newChanges.size(); i++) {
         CreateRequest create = newChanges.get(i);
         create.setChangeId(newIds.get(i));
-        batch.addCommand(create.cmd);
         create.groups = ImmutableList.copyOf(groups.get(create.commit));
       }
       for (ReplaceRequest replace : replaceByChange.values()) {
@@ -2134,8 +2133,10 @@
           changeInserterFactory
               .create(changeId, commit, refName)
               .setTopic(magicBranch.topic)
+              .setPrivate(magicBranch.isPrivate)
+              .setWorkInProgress(magicBranch.workInProgress)
               // Changes already validated in validateNewCommits.
-              .setValidatePolicy(CommitValidators.Policy.NONE);
+              .setValidate(false);
 
       if (magicBranch.draft) {
         ins.setDraft(magicBranch.draft);
@@ -2182,9 +2183,9 @@
                 .setAccountsToNotify(magicBranch.getAccountsToNotify())
                 .setRequestScopePropagator(requestScopePropagator)
                 .setSendMail(true)
-                .setUpdateRef(false)
                 .setPatchSetDescription(magicBranch.message));
         if (!magicBranch.hashtags.isEmpty()) {
+          // Any change owner is allowed to add hashtags when creating a change.
           bu.addOp(
               changeId,
               hashtagsFactory.create(new HashtagsInput(magicBranch.hashtags)).setFireEvent(false));
@@ -2258,7 +2259,7 @@
           req.inputCommand.setResult(REJECTED_OTHER_REASON, "internal server error");
         }
       }
-    } catch (IOException err) {
+    } catch (IOException | PermissionBackendException err) {
       logError(
           String.format(
               "Cannot read repository before replacement for project %s", project.getName()),
@@ -2271,15 +2272,6 @@
     }
     logDebug("Read {} changes to replace", replaceByChange.size());
 
-    for (ReplaceRequest req : replaceByChange.values()) {
-      if (req.inputCommand.getResult() == NOT_ATTEMPTED && req.cmd != null) {
-        if (req.prev != null) {
-          batch.addCommand(req.prev);
-        }
-        batch.addCommand(req.cmd);
-      }
-    }
-
     if (magicBranch != null && magicBranch.cmd.getResult() != NOT_ATTEMPTED) {
       // Cancel creations tied to refs/for/ or refs/drafts/ command.
       for (ReplaceRequest req : replaceByChange.values()) {
@@ -2308,7 +2300,6 @@
     final ReceiveCommand inputCommand;
     final boolean checkMergedInto;
     ChangeNotes notes;
-    ChangeControl changeCtl;
     BiMap<RevCommit, PatchSet.Id> revisions;
     PatchSet.Id psId;
     ReceiveCommand prev;
@@ -2323,7 +2314,7 @@
         Change.Id toChange, RevCommit newCommit, ReceiveCommand cmd, boolean checkMergedInto) {
       this.ontoChange = toChange;
       this.newCommitId = newCommit.copy();
-      this.inputCommand = cmd;
+      this.inputCommand = checkNotNull(cmd);
       this.checkMergedInto = checkMergedInto;
 
       revisions = HashBiMap.create();
@@ -2356,8 +2347,10 @@
      * @return whether the new commit is valid
      * @throws IOException
      * @throws OrmException
+     * @throws PermissionBackendException
      */
-    boolean validate(boolean autoClose) throws IOException, OrmException {
+    boolean validate(boolean autoClose)
+        throws IOException, OrmException, PermissionBackendException {
       if (!autoClose && inputCommand.getResult() != NOT_ATTEMPTED) {
         return false;
       } else if (notes == null) {
@@ -2365,7 +2358,8 @@
         return false;
       }
 
-      priorPatchSet = notes.getChange().currentPatchSetId();
+      Change change = notes.getChange();
+      priorPatchSet = change.currentPatchSetId();
       if (!revisions.containsValue(priorPatchSet)) {
         reject(inputCommand, "change " + ontoChange + " missing revisions");
         return false;
@@ -2373,16 +2367,18 @@
 
       RevCommit newCommit = rp.getRevWalk().parseCommit(newCommitId);
       RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
-
-      changeCtl = projectControl.controlFor(notes);
-      if (!changeCtl.canAddPatchSet(db)) {
+      try {
+        permissions.change(notes).database(db).check(ChangePermission.ADD_PATCH_SET);
+      } catch (AuthException no) {
         String locked = ".";
-        if (changeCtl.isPatchSetLocked(db)) {
+        if (projectControl.controlFor(notes).isPatchSetLocked(db)) {
           locked = ". Change is patch set locked.";
         }
         reject(inputCommand, "cannot add patch set to " + ontoChange + locked);
         return false;
-      } else if (notes.getChange().getStatus().isClosed()) {
+      }
+
+      if (change.getStatus().isClosed()) {
         reject(inputCommand, "change " + ontoChange + " closed");
         return false;
       } else if (revisions.containsKey(newCommit)) {
@@ -2407,6 +2403,7 @@
         }
       }
 
+      ChangeControl changeCtl = projectControl.controlFor(notes);
       if (!validCommit(rp.getRevWalk(), changeCtl.getRefControl(), inputCommand, newCommit)) {
         return false;
       }
@@ -2458,7 +2455,7 @@
       Optional<ChangeEdit> edit = null;
 
       try {
-        edit = editUtil.byChange(changeCtl);
+        edit = editUtil.byChange(projectControl.controlFor(notes));
       } catch (AuthException | IOException e) {
         logError("Cannot retrieve edit", e);
         return false;
@@ -2468,13 +2465,12 @@
         if (edit.get().getBasePatchSet().getId().equals(psId)) {
           // replace edit
           cmd =
-              new ReceiveCommand(
-                  edit.get().getRef().getObjectId(), newCommitId, edit.get().getRefName());
+              new ReceiveCommand(edit.get().getEditCommit(), newCommitId, edit.get().getRefName());
         } else {
           // delete old edit ref on rebase
           prev =
               new ReceiveCommand(
-                  edit.get().getRef().getObjectId(), ObjectId.zeroId(), edit.get().getRefName());
+                  edit.get().getEditCommit(), ObjectId.zeroId(), edit.get().getRefName());
           createEditCommand();
         }
       } else {
@@ -2495,27 +2491,19 @@
 
     private void newPatchSet() throws IOException {
       RevCommit newCommit = rp.getRevWalk().parseCommit(newCommitId);
-      psId = ChangeUtil.nextPatchSetId(allRefs, notes.getChange().currentPatchSetId());
+      psId =
+          ChangeUtil.nextPatchSetIdFromAllRefsMap(allRefs, notes.getChange().currentPatchSetId());
       info = patchSetInfoFactory.get(rp.getRevWalk(), newCommit, psId);
       cmd = new ReceiveCommand(ObjectId.zeroId(), newCommitId, psId.toRefName());
     }
 
     void addOps(BatchUpdate bu, @Nullable Task progress) throws IOException {
-      if (cmd.getResult() == NOT_ATTEMPTED) {
-        // TODO(dborowitz): When does this happen? Only when an edit ref is
-        // involved?
-        cmd.execute(rp);
-      }
       if (magicBranch != null && magicBranch.edit) {
-        bu.addOp(
-            notes.getChangeId(),
-            new BatchUpdateOp() {
-              @Override
-              public boolean updateChange(ChangeContext ctx) throws Exception {
-                // return pseudo dirty state to trigger reindexing
-                return true;
-              }
-            });
+        bu.addOp(notes.getChangeId(), new ReindexOnlyOp());
+        if (prev != null) {
+          bu.addRepoOnlyOp(new UpdateOneRefOp(prev));
+        }
+        bu.addRepoOnlyOp(new UpdateOneRefOp(cmd));
         return;
       }
       RevWalk rw = rp.getRevWalk();
@@ -2538,26 +2526,13 @@
                   groups,
                   magicBranch,
                   rp.getPushCertificate())
-              .setRequestScopePropagator(requestScopePropagator)
-              .setUpdateRef(false);
+              .setRequestScopePropagator(requestScopePropagator);
       bu.addOp(notes.getChangeId(), replaceOp);
       if (progress != null) {
         bu.addOp(notes.getChangeId(), new ChangeProgressOp(progress));
       }
     }
 
-    void insertPatchSetWithoutBatchUpdate() throws IOException, UpdateException, RestApiException {
-      try (BatchUpdate bu =
-              batchUpdateFactory.create(
-                  db, projectControl.getProject().getNameKey(), user, TimeUtil.nowTs());
-          ObjectInserter ins = repo.newObjectInserter()) {
-        bu.setRepository(repo, rp.getRevWalk(), ins);
-        bu.setRequestId(receiveId);
-        addOps(bu, replaceProgress);
-        bu.execute();
-      }
-    }
-
     String getRejectMessage() {
       return replaceOp != null ? replaceOp.getRejectMessage() : null;
     }
@@ -2599,6 +2574,47 @@
     }
   }
 
+  private class UpdateOneRefOp implements RepoOnlyOp {
+    private final ReceiveCommand cmd;
+
+    private UpdateOneRefOp(ReceiveCommand cmd) {
+      this.cmd = checkNotNull(cmd);
+    }
+
+    @Override
+    public void updateRepo(RepoContext ctx) throws IOException {
+      ctx.addRefUpdate(cmd);
+    }
+
+    @Override
+    public void postUpdate(Context ctx) {
+      String refName = cmd.getRefName();
+      if (cmd.getType() == ReceiveCommand.Type.UPDATE) { // aka fast-forward
+        logDebug("Updating tag cache on fast-forward of {}", cmd.getRefName());
+        tagCache.updateFastForward(project.getNameKey(), refName, cmd.getOldId(), cmd.getNewId());
+      }
+      if (isConfig(cmd)) {
+        logDebug("Reloading project in cache");
+        projectCache.evict(project);
+        ProjectState ps = projectCache.get(project.getNameKey());
+        try {
+          logDebug("Updating project description");
+          repo.setGitwebDescription(ps.getProject().getDescription());
+        } catch (IOException e) {
+          log.warn("cannot update description of " + project.getName(), e);
+        }
+      }
+    }
+  }
+
+  private static class ReindexOnlyOp implements BatchUpdateOp {
+    @Override
+    public boolean updateChange(ChangeContext ctx) {
+      // Trigger reindexing even though change isn't actually updated.
+      return true;
+    }
+  }
+
   private List<Ref> refs(Change.Id changeId) {
     return refsByChange().get(changeId);
   }
@@ -2751,21 +2767,18 @@
 
     RevCommit c = rw.parseCommit(id);
     rw.parseBody(c);
-    CommitReceivedEvent receiveEvent =
-        new CommitReceivedEvent(cmd, project, ctl.getRefName(), c, user);
 
-    CommitValidators.Policy policy;
-    if (magicBranch != null
-        && cmd.getRefName().equals(magicBranch.cmd.getRefName())
-        && magicBranch.merged) {
-      policy = CommitValidators.Policy.MERGED;
-    } else {
-      policy = CommitValidators.Policy.RECEIVE_COMMITS;
-    }
-
-    try {
-      messages.addAll(
-          commitValidatorsFactory.create(policy, ctl, sshInfo, repo).validate(receiveEvent));
+    try (CommitReceivedEvent receiveEvent =
+        new CommitReceivedEvent(cmd, project, ctl.getRefName(), rw.getObjectReader(), c, user)) {
+      boolean isMerged =
+          magicBranch != null
+              && cmd.getRefName().equals(magicBranch.cmd.getRefName())
+              && magicBranch.merged;
+      CommitValidators validators =
+          isMerged
+              ? commitValidatorsFactory.forMergedCommits(ctl)
+              : commitValidatorsFactory.forReceiveCommits(ctl, sshInfo, repo, rw);
+      messages.addAll(validators.validate(receiveEvent));
     } catch (CommitValidationException e) {
       logDebug("Commit validation failed on {}", c.name());
       messages.addAll(e.getMessages());
@@ -2783,14 +2796,15 @@
         !MagicBranch.isMagicBranch(refName),
         "shouldn't be auto-closing changes on magic branch %s",
         refName);
-    RevWalk rw = rp.getRevWalk();
     // TODO(dborowitz): Combine this BatchUpdate with the main one in
     // insertChangesAndPatchSets.
     try (BatchUpdate bu =
             batchUpdateFactory.create(
                 db, projectControl.getProject().getNameKey(), user, TimeUtil.nowTs());
-        ObjectInserter ins = repo.newObjectInserter()) {
-      bu.setRepository(repo, rp.getRevWalk(), ins).updateChangesInParallel();
+        ObjectInserter ins = repo.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      bu.setRepository(repo, rw, ins).updateChangesInParallel();
       bu.setRequestId(receiveId);
       // TODO(dborowitz): Teach BatchUpdate to ignore missing changes.
 
@@ -2868,7 +2882,7 @@
       bu.execute();
     } catch (RestApiException e) {
       logError("Can't insert patchset", e);
-    } catch (IOException | OrmException | UpdateException e) {
+    } catch (IOException | OrmException | UpdateException | PermissionBackendException e) {
       logError("Can't scan for changes to close", e);
     }
   }
@@ -2882,9 +2896,11 @@
     return r;
   }
 
-  private void reject(ReceiveCommand cmd, String why) {
-    cmd.setResult(REJECTED_OTHER_REASON, why);
-    commandProgress.update(1);
+  private void reject(@Nullable ReceiveCommand cmd, String why) {
+    if (cmd != null) {
+      cmd.setResult(REJECTED_OTHER_REASON, why);
+      commandProgress.update(1);
+    }
   }
 
   private static boolean isHead(ReceiveCommand cmd) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveConfig.java
index 063f395..723fb6f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveConfig.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.common.data.GlobalCapability.BATCH_CHANGES_LIMIT;
 
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -39,8 +40,9 @@
   }
 
   public int getEffectiveMaxBatchChangesLimit(CurrentUser user) {
-    if (user.getCapabilities().canPerform(BATCH_CHANGES_LIMIT)) {
-      return user.getCapabilities().getRange(BATCH_CHANGES_LIMIT).getMax();
+    CapabilityControl cap = user.getCapabilities();
+    if (cap.hasExplicitRange(BATCH_CHANGES_LIMIT)) {
+      return cap.getRange(BATCH_CHANGES_LIMIT).getMax();
     }
     return systemMaxBatchChanges;
   }
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 6ac5da1..382af51 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
@@ -15,36 +15,41 @@
 package com.google.gerrit.server.git;
 
 import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
+import static com.google.gerrit.server.ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromReviewers;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 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.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.server.ApprovalCopier;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.ChangeKindCache;
+import com.google.gerrit.server.change.EmailReviewComments;
 import com.google.gerrit.server.extensions.events.CommentAdded;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.git.ReceiveCommits.MagicBranchInput;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectControl;
@@ -55,20 +60,18 @@
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import com.google.inject.util.Providers;
 import java.io.IOException;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushCertificate;
@@ -83,9 +86,9 @@
         Branch.NameKey dest,
         boolean checkMergedInto,
         @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
-        @Assisted("priorCommit") RevCommit priorCommit,
+        @Assisted("priorCommitId") ObjectId priorCommit,
         @Assisted("patchSetId") PatchSet.Id patchSetId,
-        @Assisted("commit") RevCommit commit,
+        @Assisted("commitId") ObjectId commitId,
         PatchSetInfo info,
         List<String> groups,
         @Nullable MagicBranchInput magicBranch,
@@ -103,8 +106,9 @@
   private final ChangeData.Factory changeDataFactory;
   private final ChangeKindCache changeKindCache;
   private final ChangeMessagesUtil cmUtil;
+  private final CommentsUtil commentsUtil;
+  private final EmailReviewComments.Factory emailCommentsFactory;
   private final ExecutorService sendEmailExecutor;
-  private final GitReferenceUpdated gitRefUpdated;
   private final RevisionCreated revisionCreated;
   private final CommentAdded commentAdded;
   private final MergedByPushOp.Factory mergedByPushOpFactory;
@@ -115,9 +119,9 @@
   private final Branch.NameKey dest;
   private final boolean checkMergedInto;
   private final PatchSet.Id priorPatchSetId;
-  private final RevCommit priorCommit;
+  private final ObjectId priorCommitId;
   private final PatchSet.Id patchSetId;
-  private final RevCommit commit;
+  private final ObjectId commitId;
   private final PatchSetInfo info;
   private final MagicBranchInput magicBranch;
   private final PushCertificate pushCertificate;
@@ -125,16 +129,18 @@
 
   private final Map<String, Short> approvals = new HashMap<>();
   private final MailRecipients recipients = new MailRecipients();
-  private Change change;
+  private RevCommit commit;
+  private ReceiveCommand cmd;
+  private ChangeNotes notes;
   private PatchSet newPatchSet;
   private ChangeKind changeKind;
   private ChangeMessage msg;
+  private List<Comment> comments = ImmutableList.of();
   private String rejectMessage;
   private MergedByPushOp mergedByPushOp;
   private RequestScopePropagator requestScopePropagator;
-  private boolean updateRef;
 
-  @AssistedInject
+  @Inject
   ReplaceOp(
       AccountResolver accountResolver,
       ApprovalCopier approvalCopier,
@@ -143,7 +149,8 @@
       ChangeData.Factory changeDataFactory,
       ChangeKindCache changeKindCache,
       ChangeMessagesUtil cmUtil,
-      GitReferenceUpdated gitRefUpdated,
+      CommentsUtil commentsUtil,
+      EmailReviewComments.Factory emailCommentsFactory,
       RevisionCreated revisionCreated,
       CommentAdded commentAdded,
       MergedByPushOp.Factory mergedByPushOpFactory,
@@ -154,9 +161,9 @@
       @Assisted Branch.NameKey dest,
       @Assisted boolean checkMergedInto,
       @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
-      @Assisted("priorCommit") RevCommit priorCommit,
+      @Assisted("priorCommitId") ObjectId priorCommitId,
       @Assisted("patchSetId") PatchSet.Id patchSetId,
-      @Assisted("commit") RevCommit commit,
+      @Assisted("commitId") ObjectId commitId,
       @Assisted PatchSetInfo info,
       @Assisted List<String> groups,
       @Assisted @Nullable MagicBranchInput magicBranch,
@@ -168,7 +175,8 @@
     this.changeDataFactory = changeDataFactory;
     this.changeKindCache = changeKindCache;
     this.cmUtil = cmUtil;
-    this.gitRefUpdated = gitRefUpdated;
+    this.commentsUtil = commentsUtil;
+    this.emailCommentsFactory = emailCommentsFactory;
     this.revisionCreated = revisionCreated;
     this.commentAdded = commentAdded;
     this.mergedByPushOpFactory = mergedByPushOpFactory;
@@ -180,45 +188,50 @@
     this.dest = dest;
     this.checkMergedInto = checkMergedInto;
     this.priorPatchSetId = priorPatchSetId;
-    this.priorCommit = priorCommit;
+    this.priorCommitId = priorCommitId.copy();
     this.patchSetId = patchSetId;
-    this.commit = commit;
+    this.commitId = commitId.copy();
     this.info = info;
     this.groups = groups;
     this.magicBranch = magicBranch;
     this.pushCertificate = pushCertificate;
-    this.updateRef = true;
   }
 
   @Override
   public void updateRepo(RepoContext ctx) throws Exception {
+    commit = ctx.getRevWalk().parseCommit(commitId);
+    ctx.getRevWalk().parseBody(commit);
     changeKind =
         changeKindCache.getChangeKind(
-            projectControl.getProject().getNameKey(), ctx.getRepository(), priorCommit, commit);
+            projectControl.getProject().getNameKey(),
+            ctx.getRevWalk(),
+            ctx.getRepoView().getConfig(),
+            priorCommitId,
+            commitId);
 
     if (checkMergedInto) {
-      Ref mergedInto = findMergedInto(ctx, dest.get(), commit);
+      String mergedInto = findMergedInto(ctx, dest.get(), commit);
       if (mergedInto != null) {
         mergedByPushOp =
-            mergedByPushOpFactory.create(requestScopePropagator, patchSetId, mergedInto.getName());
+            mergedByPushOpFactory.create(requestScopePropagator, patchSetId, mergedInto);
       }
     }
 
-    if (updateRef) {
-      ctx.addRefUpdate(new ReceiveCommand(ObjectId.zeroId(), commit, patchSetId.toRefName()));
-    }
+    cmd = new ReceiveCommand(ObjectId.zeroId(), commitId, patchSetId.toRefName());
+    ctx.addRefUpdate(cmd);
   }
 
   @Override
   public boolean updateChange(ChangeContext ctx)
       throws RestApiException, OrmException, IOException {
-    change = ctx.getChange();
+    notes = ctx.getNotes();
+    Change change = notes.getChange();
     if (change == null || change.getStatus().isClosed()) {
       rejectMessage = CHANGE_IS_CLOSED;
       return false;
     }
     if (groups.isEmpty()) {
-      PatchSet prevPs = psUtil.current(ctx.getDb(), ctx.getNotes());
+      PatchSet prevPs = psUtil.current(ctx.getDb(), notes);
       groups = prevPs != null ? prevPs.getGroups() : ImmutableList.<String>of();
     }
 
@@ -234,12 +247,29 @@
       approvals.putAll(magicBranch.labels);
       Set<String> hashtags = magicBranch.hashtags;
       if (hashtags != null && !hashtags.isEmpty()) {
-        hashtags.addAll(ctx.getNotes().getHashtags());
+        hashtags.addAll(notes.getHashtags());
         update.setHashtags(hashtags);
       }
       if (magicBranch.topic != null && !magicBranch.topic.equals(ctx.getChange().getTopic())) {
         update.setTopic(magicBranch.topic);
       }
+      if (magicBranch.removePrivate) {
+        change.setPrivate(false);
+        update.setPrivate(false);
+      } else if (magicBranch.isPrivate) {
+        change.setPrivate(true);
+        update.setPrivate(true);
+      }
+      if (magicBranch.ready) {
+        change.setWorkInProgress(false);
+        update.setWorkInProgress(false);
+      } else if (magicBranch.workInProgress) {
+        change.setWorkInProgress(true);
+        update.setWorkInProgress(true);
+      }
+      if (shouldPublishComments()) {
+        comments = publishComments(ctx);
+      }
     }
 
     boolean draft = magicBranch != null && magicBranch.draft;
@@ -252,7 +282,7 @@
             ctx.getRevWalk(),
             update,
             patchSetId,
-            commit,
+            commitId,
             draft,
             groups,
             pushCertificate != null ? pushCertificate.toTextWithSignature() : null,
@@ -292,6 +322,20 @@
 
     recipients.add(oldRecipients);
 
+    msg = createChangeMessage(ctx, reviewMessage);
+    cmUtil.addChangeMessage(ctx.getDb(), update, msg);
+
+    if (mergedByPushOp == null) {
+      resetChange(ctx);
+    } else {
+      mergedByPushOp.setPatchSetProvider(Providers.of(newPatchSet)).updateChange(ctx);
+    }
+
+    return true;
+  }
+
+  private ChangeMessage createChangeMessage(ChangeContext ctx, String reviewMessage)
+      throws OrmException {
     String approvalMessage =
         ApprovalsUtil.renderMessageWithApprovals(
             patchSetId.get(), approvals, scanLabels(ctx, approvals));
@@ -302,25 +346,21 @@
     } else {
       message.append('.');
     }
+    if (comments.size() == 1) {
+      message.append("\n\n(1 comment)");
+    } else if (comments.size() > 1) {
+      message.append(String.format("\n\n(%d comments)", comments.size()));
+    }
     if (!Strings.isNullOrEmpty(reviewMessage)) {
-      message.append("\n").append(reviewMessage);
+      message.append("\n\n").append(reviewMessage);
     }
-    msg =
-        ChangeMessagesUtil.newMessage(
-            patchSetId,
-            ctx.getUser(),
-            ctx.getWhen(),
-            message.toString(),
-            ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
-    cmUtil.addChangeMessage(ctx.getDb(), update, msg);
-
-    if (mergedByPushOp == null) {
-      resetChange(ctx);
-    } else {
-      mergedByPushOp.setPatchSetProvider(Providers.of(newPatchSet)).updateChange(ctx);
-    }
-
-    return true;
+    boolean workInProgress = magicBranch != null && magicBranch.workInProgress;
+    return ChangeMessagesUtil.newMessage(
+        patchSetId,
+        ctx.getUser(),
+        ctx.getWhen(),
+        message.toString(),
+        ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
   }
 
   private String changeKindMessage(ChangeKind changeKind) {
@@ -376,66 +416,51 @@
 
     List<String> idList = commit.getFooterLines(CHANGE_ID);
     if (idList.isEmpty()) {
-      change.setKey(new Change.Key("I" + commit.name()));
+      change.setKey(new Change.Key("I" + commitId.name()));
     } else {
       change.setKey(new Change.Key(idList.get(idList.size() - 1).trim()));
     }
   }
 
+  private List<Comment> publishComments(ChangeContext ctx) throws OrmException {
+    List<Comment> comments =
+        commentsUtil.draftByChangeAuthor(ctx.getDb(), ctx.getNotes(), ctx.getUser().getAccountId());
+    commentsUtil.publish(ctx, patchSetId, comments, TAG_UPLOADED_PATCH_SET);
+    return comments;
+  }
+
   @Override
-  public void postUpdate(final Context ctx) throws Exception {
-    // Normally the ref updated hook is fired by BatchUpdate, but ReplaceOp is
-    // special because its ref is actually updated by ReceiveCommits, so from
-    // BatchUpdate's perspective there is no ref update. Thus we have to fire it
-    // manually.
-    final Account account = ctx.getAccount();
-    if (!updateRef) {
-      gitRefUpdated.fire(
-          ctx.getProject(), newPatchSet.getRefName(), ObjectId.zeroId(), commit, account);
-    }
-
+  public void postUpdate(Context ctx) throws Exception {
     if (changeKind != ChangeKind.TRIVIAL_REBASE) {
-      Runnable sender =
-          new Runnable() {
-            @Override
-            public void run() {
-              try {
-                ReplacePatchSetSender cm =
-                    replacePatchSetFactory.create(
-                        projectControl.getProject().getNameKey(), change.getId());
-                cm.setFrom(account.getId());
-                cm.setPatchSet(newPatchSet, info);
-                cm.setChangeMessage(msg.getMessage(), ctx.getWhen());
-                if (magicBranch != null) {
-                  cm.setNotify(magicBranch.notify);
-                  cm.setAccountsToNotify(magicBranch.getAccountsToNotify());
-                }
-                cm.addReviewers(recipients.getReviewers());
-                cm.addExtraCC(recipients.getCcOnly());
-                cm.send();
-              } catch (Exception e) {
-                log.error("Cannot send email for new patch set " + newPatchSet.getId(), e);
-              }
-            }
-
-            @Override
-            public String toString() {
-              return "send-email newpatchset";
-            }
-          };
-
+      // TODO(dborowitz): Merge email templates so we only have to send one.
+      Runnable e = new ReplaceEmailTask(ctx);
       if (requestScopePropagator != null) {
         @SuppressWarnings("unused")
-        Future<?> possiblyIgnoredError =
-            sendEmailExecutor.submit(requestScopePropagator.wrap(sender));
+        Future<?> possiblyIgnoredError = sendEmailExecutor.submit(requestScopePropagator.wrap(e));
       } else {
-        sender.run();
+        e.run();
       }
     }
 
     NotifyHandling notify =
         magicBranch != null && magicBranch.notify != null ? magicBranch.notify : NotifyHandling.ALL;
-    revisionCreated.fire(change, newPatchSet, ctx.getAccount(), ctx.getWhen(), notify);
+
+    if (shouldPublishComments()) {
+      emailCommentsFactory
+          .create(
+              notify,
+              magicBranch != null ? magicBranch.getAccountsToNotify() : ImmutableListMultimap.of(),
+              notes,
+              newPatchSet,
+              ctx.getUser().asIdentifiedUser(),
+              msg,
+              comments,
+              msg.getMessage(),
+              ImmutableList.of()) // TODO(dborowitz): Include labels.
+          .sendAsync();
+    }
+
+    revisionCreated.fire(notes.getChange(), newPatchSet, ctx.getAccount(), ctx.getWhen(), notify);
     try {
       fireCommentAddedEvent(ctx);
     } catch (Exception e) {
@@ -446,6 +471,40 @@
     }
   }
 
+  private class ReplaceEmailTask implements Runnable {
+    private final Context ctx;
+
+    private ReplaceEmailTask(Context ctx) {
+      this.ctx = ctx;
+    }
+
+    @Override
+    public void run() {
+      try {
+        ReplacePatchSetSender cm =
+            replacePatchSetFactory.create(
+                projectControl.getProject().getNameKey(), notes.getChangeId());
+        cm.setFrom(ctx.getAccount().getId());
+        cm.setPatchSet(newPatchSet, info);
+        cm.setChangeMessage(msg.getMessage(), ctx.getWhen());
+        if (magicBranch != null) {
+          cm.setNotify(magicBranch.notify);
+          cm.setAccountsToNotify(magicBranch.getAccountsToNotify());
+        }
+        cm.addReviewers(recipients.getReviewers());
+        cm.addExtraCC(recipients.getCcOnly());
+        cm.send();
+      } catch (Exception e) {
+        log.error("Cannot send email for new patch set " + newPatchSet.getId(), e);
+      }
+    }
+
+    @Override
+    public String toString() {
+      return "send-email newpatchset";
+    }
+  }
+
   private void fireCommentAddedEvent(Context ctx) throws OrmException {
     if (approvals.isEmpty()) {
       return;
@@ -457,7 +516,7 @@
      * show a transition from an oldValue of 0 to the new value.
      */
     ChangeControl changeControl =
-        changeControlFactory.controlFor(ctx.getDb(), change, ctx.getUser());
+        changeControlFactory.controlFor(ctx.getDb(), notes.getChange(), ctx.getUser());
     List<LabelType> labels = changeControl.getLabelTypes().getLabelTypes();
     Map<String, Short> allApprovals = new HashMap<>();
     Map<String, Short> oldApprovals = new HashMap<>();
@@ -473,7 +532,13 @@
     }
 
     commentAdded.fire(
-        change, newPatchSet, ctx.getAccount(), null, allApprovals, oldApprovals, ctx.getWhen());
+        notes.getChange(),
+        newPatchSet,
+        ctx.getAccount(),
+        null,
+        allApprovals,
+        oldApprovals,
+        ctx.getWhen());
   }
 
   public PatchSet getPatchSet() {
@@ -481,16 +546,15 @@
   }
 
   public Change getChange() {
-    return change;
+    return notes.getChange();
   }
 
   public String getRejectMessage() {
     return rejectMessage;
   }
 
-  public ReplaceOp setUpdateRef(boolean updateRef) {
-    this.updateRef = updateRef;
-    return this;
+  public ReceiveCommand getCommand() {
+    return cmd;
   }
 
   public ReplaceOp setRequestScopePropagator(RequestScopePropagator requestScopePropagator) {
@@ -498,18 +562,17 @@
     return this;
   }
 
-  private Ref findMergedInto(Context ctx, String first, RevCommit commit) {
+  private static String findMergedInto(Context ctx, String first, RevCommit commit) {
     try {
-      RefDatabase refDatabase = ctx.getRepository().getRefDatabase();
-
-      Ref firstRef = refDatabase.exactRef(first);
-      if (firstRef != null && isMergedInto(ctx.getRevWalk(), commit, firstRef)) {
-        return firstRef;
+      RevWalk rw = ctx.getRevWalk();
+      Optional<ObjectId> firstId = ctx.getRepoView().getRef(first);
+      if (firstId.isPresent() && rw.isMergedInto(commit, rw.parseCommit(firstId.get()))) {
+        return first;
       }
 
-      for (Ref ref : refDatabase.getRefs(Constants.R_HEADS).values()) {
-        if (isMergedInto(ctx.getRevWalk(), commit, ref)) {
-          return ref;
+      for (Map.Entry<String, ObjectId> e : ctx.getRepoView().getRefs(R_HEADS).entrySet()) {
+        if (rw.isMergedInto(commit, rw.parseCommit(e.getValue()))) {
+          return R_HEADS + e.getKey();
         }
       }
       return null;
@@ -519,7 +582,7 @@
     }
   }
 
-  private static boolean isMergedInto(RevWalk rw, RevCommit commit, Ref ref) throws IOException {
-    return rw.isMergedInto(commit, rw.parseCommit(ref.getObjectId()));
+  private boolean shouldPublishComments() {
+    return magicBranch != null && magicBranch.shouldPublishComments();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/RepoRefCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/RepoRefCache.java
index e7a86f1..6b2493a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/RepoRefCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/RepoRefCache.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.git;
 
 import java.io.IOException;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
@@ -44,4 +45,9 @@
     ids.put(refName, id);
     return id;
   }
+
+  /** @return an unmodifiable view of the refs that have been cached by this instance. */
+  public Map<String, Optional<ObjectId>> getCachedRefs() {
+    return Collections.unmodifiableMap(ids);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleException.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleException.java
index 5a3b4ff..a0422be 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleException.java
@@ -14,15 +14,19 @@
 
 package com.google.gerrit.server.git;
 
-/** Indicates the gitlink's update cannot be processed at this time. */
+/**
+ * Indicates the gitlink's update cannot be processed at this time.
+ *
+ * <p>Message should be considered user-visible.
+ */
 public class SubmoduleException extends Exception {
   private static final long serialVersionUID = 1L;
 
-  SubmoduleException(final String msg) {
+  SubmoduleException(String msg) {
     super(msg, null);
   }
 
-  SubmoduleException(final String msg, final Throwable why) {
+  SubmoduleException(String msg, Throwable why) {
     super(msg, why);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java
index 56c0c44..822a1cb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java
@@ -35,8 +35,8 @@
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.update.RepoOnlyOp;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayDeque;
 import java.util.ArrayList;
@@ -63,7 +63,6 @@
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
 import org.eclipse.jgit.transport.RefSpec;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -82,14 +81,49 @@
     public void updateRepo(RepoContext ctx) throws Exception {
       CodeReviewCommit c = composeGitlinksCommit(branch);
       if (c != null) {
-        ctx.addRefUpdate(new ReceiveCommand(c.getParent(0), c, branch.get()));
+        ctx.addRefUpdate(c.getParent(0), c, branch.get());
         addBranchTip(branch, c);
       }
     }
   }
 
-  public interface Factory {
-    SubmoduleOp create(Set<Branch.NameKey> updatedBranches, MergeOpRepoManager orm);
+  @Singleton
+  public static class Factory {
+    private final GitModules.Factory gitmodulesFactory;
+    private final PersonIdent myIdent;
+    private final Config cfg;
+    private final ProjectCache projectCache;
+    private final ProjectState.Factory projectStateFactory;
+    private final BatchUpdate.Factory batchUpdateFactory;
+
+    @Inject
+    Factory(
+        GitModules.Factory gitmodulesFactory,
+        @GerritPersonIdent PersonIdent myIdent,
+        @GerritServerConfig Config cfg,
+        ProjectCache projectCache,
+        ProjectState.Factory projectStateFactory,
+        BatchUpdate.Factory batchUpdateFactory) {
+      this.gitmodulesFactory = gitmodulesFactory;
+      this.myIdent = myIdent;
+      this.cfg = cfg;
+      this.projectCache = projectCache;
+      this.projectStateFactory = projectStateFactory;
+      this.batchUpdateFactory = batchUpdateFactory;
+    }
+
+    public SubmoduleOp create(Set<Branch.NameKey> updatedBranches, MergeOpRepoManager orm)
+        throws SubmoduleException {
+      return new SubmoduleOp(
+          gitmodulesFactory,
+          myIdent,
+          cfg,
+          projectCache,
+          projectStateFactory,
+          batchUpdateFactory,
+          updatedBranches,
+          orm);
+    }
   }
 
   private static final Logger log = LoggerFactory.getLogger(SubmoduleOp.class);
@@ -117,16 +151,15 @@
   // map of superproject and its branches which has submodule subscriptions
   private final SetMultimap<Project.NameKey, Branch.NameKey> branchesByProject;
 
-  @AssistedInject
-  public SubmoduleOp(
+  private SubmoduleOp(
       GitModules.Factory gitmodulesFactory,
-      @GerritPersonIdent PersonIdent myIdent,
-      @GerritServerConfig Config cfg,
+      PersonIdent myIdent,
+      Config cfg,
       ProjectCache projectCache,
       ProjectState.Factory projectStateFactory,
       BatchUpdate.Factory batchUpdateFactory,
-      @Assisted Set<Branch.NameKey> updatedBranches,
-      @Assisted MergeOpRepoManager orm)
+      Set<Branch.NameKey> updatedBranches,
+      MergeOpRepoManager orm)
       throws SubmoduleException {
     this.gitmodulesFactory = gitmodulesFactory;
     this.myIdent = myIdent;
@@ -435,7 +468,7 @@
     commit.setTreeId(newTreeId);
     commit.setParentIds(currentCommit.getParents());
     if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
-      //TODO:czhen handle cherrypick footer
+      // TODO(czhen): handle cherrypick footer
       commit.setMessage(currentCommit.getFullMessage() + "\n\n* submodules:\n" + msgbuf.toString());
     } else {
       commit.setMessage(currentCommit.getFullMessage());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java
index 47d416c..3756d73 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -35,10 +36,8 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Set;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefDatabase;
@@ -62,7 +61,7 @@
   private final ReviewDb reviewDb;
   private final boolean showMetadata;
   private String userEditPrefix;
-  private Set<Change.Id> visibleChanges;
+  private Map<Change.Id, Branch.NameKey> visibleChanges;
 
   public VisibleRefFilter(
       TagCache tagCache,
@@ -221,26 +220,30 @@
         visibleChanges = visibleChangesBySearch();
       }
     }
-    return visibleChanges.contains(changeId);
+    return visibleChanges.containsKey(changeId);
   }
 
   private boolean visibleEdit(String name) {
-    if (userEditPrefix != null && name.startsWith(userEditPrefix)) {
-      Change.Id id = Change.Id.fromEditRefPart(name);
-      if (id != null) {
-        return visible(id);
-      }
+    Change.Id id = Change.Id.fromEditRefPart(name);
+    // Initialize if it wasn't yet
+    if (visibleChanges == null) {
+      visible(id);
+    }
+    if (id != null) {
+      return (userEditPrefix != null && name.startsWith(userEditPrefix) && visible(id))
+          || (visibleChanges.containsKey(id)
+              && projectCtl.controlForRef(visibleChanges.get(id)).isEditVisible());
     }
     return false;
   }
 
-  private Set<Change.Id> visibleChangesBySearch() {
+  private Map<Change.Id, Branch.NameKey> visibleChangesBySearch() {
     Project project = projectCtl.getProject();
     try {
-      Set<Change.Id> visibleChanges = new HashSet<>();
+      Map<Change.Id, Branch.NameKey> visibleChanges = new HashMap<>();
       for (ChangeData cd : changeCache.getChangeData(reviewDb, project.getNameKey())) {
         if (projectCtl.controlForIndexedChange(cd.change()).isVisible(reviewDb, cd)) {
-          visibleChanges.add(cd.getId());
+          visibleChanges.put(cd.getId(), cd.change().getDest());
         }
       }
       return visibleChanges;
@@ -250,24 +253,24 @@
               + project.getName()
               + ", assuming no changes are visible",
           e);
-      return Collections.emptySet();
+      return Collections.emptyMap();
     }
   }
 
-  private Set<Change.Id> visibleChangesByScan() {
+  private Map<Change.Id, Branch.NameKey> visibleChangesByScan() {
     Project.NameKey project = projectCtl.getProject().getNameKey();
     try {
-      Set<Change.Id> visibleChanges = new HashSet<>();
+      Map<Change.Id, Branch.NameKey> visibleChanges = new HashMap<>();
       for (ChangeNotes cn : changeNotesFactory.scan(db, reviewDb, project)) {
         if (projectCtl.controlFor(cn).isVisible(reviewDb)) {
-          visibleChanges.add(cn.getChangeId());
+          visibleChanges.put(cn.getChangeId(), cn.getChange().getDest());
         }
       }
       return visibleChanges;
     } catch (IOException | OrmException e) {
       log.error(
           "Cannot load changes for project " + project + ", assuming no changes are visible", e);
-      return Collections.emptySet();
+      return Collections.emptyMap();
     }
   }
 
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 96b5b55..49399ef 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
@@ -37,7 +37,6 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.transport.ReceiveCommand;
 
 public class CherryPick extends SubmitStrategy {
 
@@ -95,7 +94,10 @@
       // delta relative to that one parent and redoing that on the current merge
       // tip.
       args.rw.parseBody(toMerge);
-      psId = ChangeUtil.nextPatchSetId(args.repo, toMerge.change().currentPatchSetId());
+      psId =
+          ChangeUtil.nextPatchSetIdFromChangeRefsMap(
+              ctx.getRepoView().getRefs(getId().toRefPrefix()),
+              toMerge.change().currentPatchSetId());
       RevCommit mergeTip = args.mergeTip.getCurrentTip();
       args.rw.parseBody(mergeTip);
       String cherryPickCmtMsg = args.mergeUtil.createCommitMessageOnSubmit(toMerge, mergeTip);
@@ -105,8 +107,8 @@
       try {
         newCommit =
             args.mergeUtil.createCherryPickFromCommit(
-                args.repo,
-                args.inserter,
+                ctx.getInserter(),
+                ctx.getRepoView().getConfig(),
                 args.mergeTip.getCurrentTip(),
                 toMerge,
                 committer,
@@ -132,7 +134,7 @@
       args.mergeTip.moveTipTo(newCommit, newCommit);
       args.commitStatus.put(newCommit);
 
-      ctx.addRefUpdate(new ReceiveCommand(ObjectId.zeroId(), newCommit, psId.toRefName()));
+      ctx.addRefUpdate(ObjectId.zeroId(), newCommit, psId.toRefName());
       patchSetInfo = args.patchSetInfoFactory.get(ctx.getRevWalk(), newCommit, psId);
     }
 
@@ -195,9 +197,9 @@
             args.mergeUtil.mergeOneCommit(
                 myIdent,
                 myIdent,
-                args.repo,
                 args.rw,
-                args.inserter,
+                ctx.getInserter(),
+                ctx.getRepoView().getConfig(),
                 args.destBranch,
                 mergeTip.getCurrentTip(),
                 toMerge);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOnly.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOnly.java
index 7151486..38a193d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOnly.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOnly.java
@@ -29,8 +29,7 @@
   @Override
   public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge)
       throws IntegrationException {
-    List<CodeReviewCommit> sorted =
-        args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge, args.incoming);
+    List<CodeReviewCommit> sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
     List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
     CodeReviewCommit newTipCommit =
         args.mergeUtil.getFirstFastForward(args.mergeTip.getInitialTip(), args.rw, sorted);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeAlways.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeAlways.java
index ce045f8..1664be4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeAlways.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeAlways.java
@@ -28,8 +28,7 @@
   @Override
   public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge)
       throws IntegrationException {
-    List<CodeReviewCommit> sorted =
-        args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge, args.incoming);
+    List<CodeReviewCommit> sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
     List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
     if (args.mergeTip.getInitialTip() == null && !sorted.isEmpty()) {
       // The branch is unborn. Take a fast-forward resolution to
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java
index e7db1a8..d30aab2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java
@@ -28,8 +28,7 @@
   @Override
   public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge)
       throws IntegrationException {
-    List<CodeReviewCommit> sorted =
-        args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge, args.incoming);
+    List<CodeReviewCommit> sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
     List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
 
     if (args.mergeTip.getInitialTip() == null
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeOneOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeOneOp.java
index 2a6680c..9fb75a8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeOneOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeOneOp.java
@@ -36,16 +36,13 @@
               + " onto a null tip; expected at least one fast-forward prior to"
               + " this operation");
     }
-    // TODO(dborowitz): args.rw is needed because it's a CodeReviewRevWalk.
-    // When hoisting BatchUpdate into MergeOp, we will need to teach
-    // BatchUpdate how to produce CodeReviewRevWalks.
     CodeReviewCommit merged =
         args.mergeUtil.mergeOneCommit(
             caller,
             args.serverIdent,
-            ctx.getRepository(),
             args.rw,
             ctx.getInserter(),
+            ctx.getRepoView().getConfig(),
             args.destBranch,
             args.mergeTip.getCurrentTip(),
             toMerge);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java
index 43ab01b..d4487b5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java
@@ -28,8 +28,7 @@
 import com.google.gerrit.server.git.IntegrationException;
 import com.google.gerrit.server.git.MergeIdenticalTreeException;
 import com.google.gerrit.server.git.MergeTip;
-import com.google.gerrit.server.git.RebaseSorter;
-import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.update.ChangeContext;
@@ -42,8 +41,8 @@
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.transport.ReceiveCommand;
 
 /** This strategy covers RebaseAlways and RebaseIfNecessary ones. */
 public class RebaseSubmitStrategy extends SubmitStrategy {
@@ -57,7 +56,12 @@
   @Override
   public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge)
       throws IntegrationException {
-    List<CodeReviewCommit> sorted = sort(toMerge);
+    List<CodeReviewCommit> sorted;
+    try {
+      sorted = args.rebaseSorter.sort(toMerge);
+    } catch (IOException e) {
+      throw new IntegrationException("Commit sorting failed", e);
+    }
     List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
     boolean first = true;
 
@@ -67,7 +71,7 @@
         // MERGE_IF_NECESSARY semantics to avoid creating duplicate
         // commits.
         //
-        sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, sorted, args.incoming);
+        sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, sorted);
         break;
       }
     }
@@ -115,10 +119,7 @@
     @Override
     public void updateRepoImpl(RepoContext ctx)
         throws IntegrationException, InvalidChangeOperationException, RestApiException, IOException,
-            OrmException {
-      // TODO(dborowitz): args.rw is needed because it's a CodeReviewRevWalk.
-      // When hoisting BatchUpdate into MergeOp, we will need to teach
-      // BatchUpdate how to produce CodeReviewRevWalks.
+            OrmException, PermissionBackendException {
       if (args.mergeUtil.canFastForward(
           args.mergeSorter, args.mergeTip.getCurrentTip(), args.rw, toMerge)) {
         if (!rebaseAlways) {
@@ -129,7 +130,10 @@
         }
         // RebaseAlways means we modify commit message.
         args.rw.parseBody(toMerge);
-        newPatchSetId = ChangeUtil.nextPatchSetId(args.repo, toMerge.change().currentPatchSetId());
+        newPatchSetId =
+            ChangeUtil.nextPatchSetIdFromChangeRefsMap(
+                ctx.getRepoView().getRefs(getId().toRefPrefix()),
+                toMerge.change().currentPatchSetId());
         RevCommit mergeTip = args.mergeTip.getCurrentTip();
         args.rw.parseBody(mergeTip);
         String cherryPickCmtMsg = args.mergeUtil.createCommitMessageOnSubmit(toMerge, mergeTip);
@@ -138,8 +142,8 @@
         try {
           newCommit =
               args.mergeUtil.createCherryPickFromCommit(
-                  args.repo,
-                  args.inserter,
+                  ctx.getInserter(),
+                  ctx.getRepoView().getConfig(),
                   args.mergeTip.getCurrentTip(),
                   toMerge,
                   committer,
@@ -156,20 +160,19 @@
           toMerge.setStatusCode(SKIPPED_IDENTICAL_TREE);
           return;
         }
-        ctx.addRefUpdate(
-            new ReceiveCommand(ObjectId.zeroId(), newCommit, newPatchSetId.toRefName()));
+        ctx.addRefUpdate(ObjectId.zeroId(), newCommit, newPatchSetId.toRefName());
       } else {
         // Stale read of patch set is ok; see comments in RebaseChangeOp.
         PatchSet origPs =
             args.psUtil.get(ctx.getDb(), toMerge.getControl().getNotes(), toMerge.getPatchsetId());
         rebaseOp =
             args.rebaseFactory
-                .create(toMerge.getControl(), origPs, args.mergeTip.getCurrentTip().name())
+                .create(toMerge.getControl(), origPs, args.mergeTip.getCurrentTip())
                 .setFireRevisionCreated(false)
                 // Bypass approval copier since SubmitStrategyOp copy all approvals
                 // later anyway.
                 .setCopyApprovals(false)
-                .setValidatePolicy(CommitValidators.Policy.NONE)
+                .setValidate(false)
                 .setCheckAddPatchSetPermission(false)
                 // RebaseAlways should set always modify commit message like
                 // Cherry-Pick strategy.
@@ -269,9 +272,9 @@
             args.mergeUtil.mergeOneCommit(
                 caller,
                 caller,
-                args.repo,
                 args.rw,
-                args.inserter,
+                ctx.getInserter(),
+                ctx.getRepoView().getConfig(),
                 args.destBranch,
                 mergeTip.getCurrentTip(),
                 toMerge);
@@ -287,27 +290,15 @@
     args.alreadyAccepted.add(mergeTip.getCurrentTip());
   }
 
-  private List<CodeReviewCommit> sort(Collection<CodeReviewCommit> toSort)
-      throws IntegrationException {
-    try {
-      return new RebaseSorter(
-              args.rw,
-              args.mergeTip.getInitialTip(),
-              args.alreadyAccepted,
-              args.canMergeFlag,
-              args.internalChangeQuery)
-          .sort(toSort);
-    } catch (IOException e) {
-      throw new IntegrationException("Commit sorting failed", e);
-    }
-  }
-
   static boolean dryRun(
-      SubmitDryRun.Arguments args, CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
+      SubmitDryRun.Arguments args,
+      Repository repo,
+      CodeReviewCommit mergeTip,
+      CodeReviewCommit toMerge)
       throws IntegrationException {
     // Test for merge instead of cherry pick to avoid false negatives
     // on commit chains.
     return !args.mergeUtil.hasMissingDependencies(args.mergeSorter, toMerge)
-        && args.mergeUtil.canMerge(args.mergeSorter, args.repo, mergeTip, toMerge);
+        && args.mergeUtil.canMerge(args.mergeSorter, repo, mergeTip, toMerge);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java
index d375b6e..0d012e5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.git.strategy;
 
 import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.server.git.CodeReviewCommit;
@@ -108,7 +109,7 @@
             repo,
             rw,
             mergeUtilFactory.create(getProject(destBranch)),
-            new MergeSorter(rw, alreadyAccepted, canMerge));
+            new MergeSorter(rw, alreadyAccepted, canMerge, ImmutableSet.of(toMergeCommit)));
 
     switch (submitType) {
       case CHERRY_PICK:
@@ -120,9 +121,9 @@
       case MERGE_IF_NECESSARY:
         return MergeIfNecessary.dryRun(args, tipCommit, toMergeCommit);
       case REBASE_IF_NECESSARY:
-        return RebaseIfNecessary.dryRun(args, tipCommit, toMergeCommit);
+        return RebaseIfNecessary.dryRun(args, repo, tipCommit, toMergeCommit);
       case REBASE_ALWAYS:
-        return RebaseAlways.dryRun(args, tipCommit, toMergeCommit);
+        return RebaseAlways.dryRun(args, repo, tipCommit, toMergeCommit);
       default:
         String errorMsg = "No submit strategy for: " + submitType;
         log.error(errorMsg);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
index f721978..95bdf33 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
@@ -43,6 +43,7 @@
 import com.google.gerrit.server.git.MergeSorter;
 import com.google.gerrit.server.git.MergeTip;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.RebaseSorter;
 import com.google.gerrit.server.git.SubmoduleOp;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
@@ -53,17 +54,15 @@
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.util.RequestId;
+import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Set;
-import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevFlag;
 
@@ -92,8 +91,6 @@
           CodeReviewRevWalk rw,
           IdentifiedUser caller,
           MergeTip mergeTip,
-          ObjectInserter inserter,
-          Repository repo,
           RevFlag canMergeFlag,
           ReviewDb db,
           Set<RevCommit> alreadyAccepted,
@@ -107,7 +104,6 @@
 
     final AccountCache accountCache;
     final ApprovalsUtil approvalsUtil;
-    final BatchUpdate.Factory batchUpdateFactory;
     final ChangeControl.GenericFactory changeControlFactory;
     final ChangeMerged changeMerged;
     final ChangeMessagesUtil cmUtil;
@@ -128,12 +124,9 @@
     final CommitStatus commitStatus;
     final IdentifiedUser caller;
     final MergeTip mergeTip;
-    final ObjectInserter inserter;
-    final Repository repo;
     final RevFlag canMergeFlag;
     final ReviewDb db;
     final Set<RevCommit> alreadyAccepted;
-    final Set<CodeReviewCommit> incoming;
     final RequestId submissionId;
     final SubmitType submitType;
     final NotifyHandling notifyHandling;
@@ -142,14 +135,14 @@
 
     final ProjectState project;
     final MergeSorter mergeSorter;
+    final RebaseSorter rebaseSorter;
     final MergeUtil mergeUtil;
     final boolean dryrun;
 
-    @AssistedInject
+    @Inject
     Arguments(
         AccountCache accountCache,
         ApprovalsUtil approvalsUtil,
-        BatchUpdate.Factory batchUpdateFactory,
         ChangeControl.GenericFactory changeControlFactory,
         ChangeMerged changeMerged,
         ChangeMessagesUtil cmUtil,
@@ -170,8 +163,6 @@
         @Assisted CodeReviewRevWalk rw,
         @Assisted IdentifiedUser caller,
         @Assisted MergeTip mergeTip,
-        @Assisted ObjectInserter inserter,
-        @Assisted Repository repo,
         @Assisted RevFlag canMergeFlag,
         @Assisted ReviewDb db,
         @Assisted Set<RevCommit> alreadyAccepted,
@@ -184,7 +175,6 @@
         @Assisted boolean dryrun) {
       this.accountCache = accountCache;
       this.approvalsUtil = approvalsUtil;
-      this.batchUpdateFactory = batchUpdateFactory;
       this.changeControlFactory = changeControlFactory;
       this.changeMerged = changeMerged;
       this.mergedSenderFactory = mergedSenderFactory;
@@ -204,12 +194,9 @@
       this.rw = rw;
       this.caller = caller;
       this.mergeTip = mergeTip;
-      this.inserter = inserter;
-      this.repo = repo;
       this.canMergeFlag = canMergeFlag;
       this.db = db;
       this.alreadyAccepted = alreadyAccepted;
-      this.incoming = incoming;
       this.submissionId = submissionId;
       this.submitType = submitType;
       this.notifyHandling = notifyHandling;
@@ -222,7 +209,15 @@
               projectCache.get(destBranch.getParentKey()),
               "project not found: %s",
               destBranch.getParentKey());
-      this.mergeSorter = new MergeSorter(rw, alreadyAccepted, canMergeFlag);
+      this.mergeSorter = new MergeSorter(rw, alreadyAccepted, canMergeFlag, incoming);
+      this.rebaseSorter =
+          new RebaseSorter(
+              rw,
+              mergeTip.getInitialTip(),
+              alreadyAccepted,
+              canMergeFlag,
+              internalChangeQuery,
+              incoming);
       this.mergeUtil = mergeUtilFactory.create(project);
       this.onSubmitValidatorsFactory = onSubmitValidatorsFactory;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java
index fc4817d..8f43a49 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java
@@ -32,8 +32,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Set;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevFlag;
 import org.slf4j.Logger;
@@ -54,9 +52,7 @@
   public SubmitStrategy create(
       SubmitType submitType,
       ReviewDb db,
-      Repository repo,
       CodeReviewRevWalk rw,
-      ObjectInserter inserter,
       RevFlag canMergeFlag,
       Set<RevCommit> alreadyAccepted,
       Set<CodeReviewCommit> incoming,
@@ -78,8 +74,6 @@
             rw,
             caller,
             mergeTip,
-            inserter,
-            repo,
             canMergeFlag,
             db,
             alreadyAccepted,
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 89bd560..6bf8b2e 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
@@ -53,7 +53,6 @@
 import com.google.gwtorm.server.OrmException;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
@@ -62,7 +61,6 @@
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.slf4j.Logger;
@@ -105,6 +103,12 @@
   @Override
   public final void updateRepo(RepoContext ctx) throws Exception {
     logDebug("{}#updateRepo for change {}", getClass().getSimpleName(), toMerge.change().getId());
+    checkState(
+        ctx.getRevWalk() == args.rw,
+        "SubmitStrategyOp requires callers to call BatchUpdate#setRepository with exactly the same"
+            + " CodeReviewRevWalk instance from the SubmitStrategy.Arguments: %s != %s",
+        ctx.getRevWalk(),
+        args.rw);
     // Run the submit strategy implementation and record the merge tip state so
     // we can create the ref update.
     CodeReviewCommit tipBefore = args.mergeTip.getCurrentTip();
@@ -162,19 +166,20 @@
     }
     CodeReviewRevWalk rw = (CodeReviewRevWalk) ctx.getRevWalk();
     Change.Id id = getId();
+    String refPrefix = id.toRefPrefix();
 
-    Collection<Ref> refs = ctx.getRepository().getRefDatabase().getRefs(id.toRefPrefix()).values();
+    Map<String, ObjectId> refs = ctx.getRepoView().getRefs(refPrefix);
     List<CodeReviewCommit> commits = new ArrayList<>(refs.size());
-    for (Ref ref : refs) {
-      PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
+    for (Map.Entry<String, ObjectId> e : refs.entrySet()) {
+      PatchSet.Id psId = PatchSet.Id.fromRef(refPrefix + e.getKey());
       if (psId == null) {
         continue;
       }
       try {
-        CodeReviewCommit c = rw.parseCommit(ref.getObjectId());
+        CodeReviewCommit c = rw.parseCommit(e.getValue());
         c.setPatchsetId(psId);
         commits.add(c);
-      } catch (MissingObjectException | IncorrectObjectTypeException e) {
+      } catch (MissingObjectException | IncorrectObjectTypeException ex) {
         continue; // Bogus ref, can't be merged into tip so we don't care.
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java
index 24ff379..07f3b21 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java
@@ -14,30 +14,30 @@
 
 package com.google.gerrit.server.git.validators;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.validators.ValidationException;
-import java.util.Collections;
 import java.util.List;
 
 public class CommitValidationException extends ValidationException {
   private static final long serialVersionUID = 1L;
-  private final List<CommitValidationMessage> messages;
+  private final ImmutableList<CommitValidationMessage> messages;
 
   public CommitValidationException(String reason, List<CommitValidationMessage> messages) {
     super(reason);
-    this.messages = messages;
+    this.messages = ImmutableList.copyOf(messages);
   }
 
   public CommitValidationException(String reason) {
     super(reason);
-    this.messages = Collections.emptyList();
+    this.messages = ImmutableList.of();
   }
 
   public CommitValidationException(String reason, Throwable why) {
     super(reason, why);
-    this.messages = Collections.emptyList();
+    this.messages = ImmutableList.of();
   }
 
-  public List<CommitValidationMessage> getMessages() {
+  public ImmutableList<CommitValidationMessage> getMessages() {
     return messages;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 630dd32..c086f1c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -17,18 +17,21 @@
 import static com.google.gerrit.reviewdb.client.Change.CHANGE_ID_PATTERN;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
 import static com.google.gerrit.server.git.ReceiveCommits.NEW_PATCHSET;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.WatchConfig;
+import com.google.gerrit.server.account.externalids.ExternalIdsConsistencyChecker;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -66,26 +69,13 @@
 public class CommitValidators {
   private static final Logger log = LoggerFactory.getLogger(CommitValidators.class);
 
-  public enum Policy {
-    /** Use {@link Factory#forGerritCommits}. */
-    GERRIT,
-
-    /** Use {@link Factory#forReceiveCommits}. */
-    RECEIVE_COMMITS,
-
-    /** Use {@link Factory#forMergedCommits}. */
-    MERGED,
-
-    /** Do not validate commits. */
-    NONE
-  }
-
   @Singleton
   public static class Factory {
     private final PersonIdent gerritIdent;
     private final String canonicalWebUrl;
     private final DynamicSet<CommitValidationListener> pluginValidators;
     private final AllUsersName allUsers;
+    private final ExternalIdsConsistencyChecker externalIdsConsistencyChecker;
     private final String installCommitMsgHookCommand;
 
     @Inject
@@ -94,53 +84,36 @@
         @CanonicalWebUrl @Nullable String canonicalWebUrl,
         @GerritServerConfig Config cfg,
         DynamicSet<CommitValidationListener> pluginValidators,
-        AllUsersName allUsers) {
+        AllUsersName allUsers,
+        ExternalIdsConsistencyChecker externalIdsConsistencyChecker) {
       this.gerritIdent = gerritIdent;
       this.canonicalWebUrl = canonicalWebUrl;
       this.pluginValidators = pluginValidators;
       this.allUsers = allUsers;
+      this.externalIdsConsistencyChecker = externalIdsConsistencyChecker;
       this.installCommitMsgHookCommand =
           cfg != null ? cfg.getString("gerrit", null, "installCommitMsgHookCommand") : null;
     }
 
-    public CommitValidators create(
-        Policy policy, RefControl refControl, SshInfo sshInfo, Repository repo) throws IOException {
-      switch (policy) {
-        case RECEIVE_COMMITS:
-          return forReceiveCommits(refControl, sshInfo, repo);
-        case GERRIT:
-          return forGerritCommits(refControl, sshInfo, repo);
-        case MERGED:
-          return forMergedCommits(refControl);
-        case NONE:
-          return none();
-        default:
-          throw new IllegalArgumentException("unspported policy: " + policy);
-      }
+    public CommitValidators forReceiveCommits(
+        RefControl refControl, SshInfo sshInfo, Repository repo, RevWalk rw) throws IOException {
+      NoteMap rejectCommits = BanCommit.loadRejectCommitsMap(repo, rw);
+      return new CommitValidators(
+          ImmutableList.of(
+              new UploadMergesPermissionValidator(refControl),
+              new AmendedGerritMergeCommitValidationListener(refControl, gerritIdent),
+              new AuthorUploaderValidator(refControl, canonicalWebUrl),
+              new CommitterUploaderValidator(refControl, canonicalWebUrl),
+              new SignedOffByValidator(refControl),
+              new ChangeIdValidator(
+                  refControl, canonicalWebUrl, installCommitMsgHookCommand, sshInfo),
+              new ConfigValidator(refControl, rw, allUsers),
+              new BannedCommitsValidator(rejectCommits),
+              new PluginCommitValidationListener(pluginValidators),
+              new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker)));
     }
 
-    private CommitValidators forReceiveCommits(
-        RefControl refControl, SshInfo sshInfo, Repository repo) throws IOException {
-      try (RevWalk rw = new RevWalk(repo)) {
-        NoteMap rejectCommits = BanCommit.loadRejectCommitsMap(repo, rw);
-        return new CommitValidators(
-            ImmutableList.of(
-                new UploadMergesPermissionValidator(refControl),
-                new AmendedGerritMergeCommitValidationListener(refControl, gerritIdent),
-                new AuthorUploaderValidator(refControl, canonicalWebUrl),
-                new CommitterUploaderValidator(refControl, canonicalWebUrl),
-                new SignedOffByValidator(refControl),
-                new ChangeIdValidator(
-                    refControl, canonicalWebUrl, installCommitMsgHookCommand, sshInfo),
-                new ConfigValidator(refControl, repo, allUsers),
-                new BannedCommitsValidator(rejectCommits),
-                new PluginCommitValidationListener(pluginValidators),
-                new BlockExternalIdUpdateListener(allUsers)));
-      }
-    }
-
-    private CommitValidators forGerritCommits(
-        RefControl refControl, SshInfo sshInfo, Repository repo) {
+    public CommitValidators forGerritCommits(RefControl refControl, SshInfo sshInfo, RevWalk rw) {
       return new CommitValidators(
           ImmutableList.of(
               new UploadMergesPermissionValidator(refControl),
@@ -149,12 +122,12 @@
               new SignedOffByValidator(refControl),
               new ChangeIdValidator(
                   refControl, canonicalWebUrl, installCommitMsgHookCommand, sshInfo),
-              new ConfigValidator(refControl, repo, allUsers),
+              new ConfigValidator(refControl, rw, allUsers),
               new PluginCommitValidationListener(pluginValidators),
-              new BlockExternalIdUpdateListener(allUsers)));
+              new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker)));
     }
 
-    private CommitValidators forMergedCommits(RefControl refControl) {
+    public CommitValidators forMergedCommits(RefControl refControl) {
       // Generally only include validators that are based on permissions of the
       // user creating a change for a merged commit; generally exclude
       // validators that would require amending the change in order to correct.
@@ -174,10 +147,6 @@
               new AuthorUploaderValidator(refControl, canonicalWebUrl),
               new CommitterUploaderValidator(refControl, canonicalWebUrl)));
     }
-
-    private CommitValidators none() {
-      return new CommitValidators(ImmutableList.<CommitValidationListener>of());
-    }
   }
 
   private final List<CommitValidationListener> validators;
@@ -354,12 +323,12 @@
   /** If this is the special project configuration branch, validate the config. */
   public static class ConfigValidator implements CommitValidationListener {
     private final RefControl refControl;
-    private final Repository repo;
+    private final RevWalk rw;
     private final AllUsersName allUsers;
 
-    public ConfigValidator(RefControl refControl, Repository repo, AllUsersName allUsers) {
+    public ConfigValidator(RefControl refControl, RevWalk rw, AllUsersName allUsers) {
       this.refControl = refControl;
-      this.repo = repo;
+      this.rw = rw;
       this.allUsers = allUsers;
     }
 
@@ -373,7 +342,7 @@
 
         try {
           ProjectConfig cfg = new ProjectConfig(receiveEvent.project.getNameKey());
-          cfg.load(repo, receiveEvent.command.getNewId());
+          cfg.load(rw, receiveEvent.command.getNewId());
           if (!cfg.getValidationErrors().isEmpty()) {
             addError("Invalid project configuration:", messages);
             for (ValidationError err : cfg.getValidationErrors()) {
@@ -401,7 +370,7 @@
         if (accountId != null) {
           try {
             WatchConfig wc = new WatchConfig(accountId);
-            wc.load(repo, receiveEvent.command.getNewId());
+            wc.load(rw, receiveEvent.command.getNewId());
             if (!wc.getValidationErrors().isEmpty()) {
               addError("Invalid project configuration:", messages);
               for (ValidationError err : wc.getValidationErrors()) {
@@ -619,11 +588,14 @@
     }
   }
 
-  /** Blocks any update to refs/meta/external-ids */
-  public static class BlockExternalIdUpdateListener implements CommitValidationListener {
+  /** Validates updates to refs/meta/external-ids. */
+  public static class ExternalIdUpdateListener implements CommitValidationListener {
     private final AllUsersName allUsers;
+    private final ExternalIdsConsistencyChecker externalIdsConsistencyChecker;
 
-    public BlockExternalIdUpdateListener(AllUsersName allUsers) {
+    public ExternalIdUpdateListener(
+        AllUsersName allUsers, ExternalIdsConsistencyChecker externalIdsConsistencyChecker) {
+      this.externalIdsConsistencyChecker = externalIdsConsistencyChecker;
       this.allUsers = allUsers;
     }
 
@@ -632,7 +604,26 @@
         throws CommitValidationException {
       if (allUsers.equals(receiveEvent.project.getNameKey())
           && RefNames.REFS_EXTERNAL_IDS.equals(receiveEvent.refName)) {
-        throw new CommitValidationException("not allowed to update " + RefNames.REFS_EXTERNAL_IDS);
+        try {
+          List<ConsistencyProblemInfo> problems =
+              externalIdsConsistencyChecker.check(receiveEvent.commit);
+          List<CommitValidationMessage> msgs =
+              problems
+                  .stream()
+                  .map(
+                      p ->
+                          new CommitValidationMessage(
+                              p.message, p.status == ConsistencyProblemInfo.Status.ERROR))
+                  .collect(toList());
+          if (msgs.stream().anyMatch(m -> m.isError())) {
+            throw new CommitValidationException("invalid external IDs", msgs);
+          }
+          return msgs;
+        } catch (IOException e) {
+          String m = "error validating external IDs";
+          log.warn(m, e);
+          throw new CommitValidationException(m, e);
+        }
       }
       return Collections.emptyList();
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java
index 150965c..298c650 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicMap.Entry;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
@@ -29,6 +30,9 @@
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
@@ -36,8 +40,12 @@
 import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class MergeValidators {
+  private static final Logger log = LoggerFactory.getLogger(MergeValidators.class);
+
   private final DynamicSet<MergeValidationListener> mergeValidationListeners;
   private final ProjectConfigValidator.Factory projectConfigValidatorFactory;
 
@@ -93,6 +101,7 @@
 
     private final AllProjectsName allProjectsName;
     private final ProjectCache projectCache;
+    private final PermissionBackend permissionBackend;
     private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
 
     public interface Factory {
@@ -103,9 +112,11 @@
     public ProjectConfigValidator(
         AllProjectsName allProjectsName,
         ProjectCache projectCache,
+        PermissionBackend permissionBackend,
         DynamicMap<ProjectConfigEntry> pluginConfigEntries) {
       this.allProjectsName = allProjectsName;
       this.projectCache = projectCache;
+      this.permissionBackend = permissionBackend;
       this.pluginConfigEntries = pluginConfigEntries;
     }
 
@@ -132,8 +143,13 @@
             }
           } else {
             if (!oldParent.equals(newParent)) {
-              if (!caller.getCapabilities().canAdministrateServer()) {
+              try {
+                permissionBackend.user(caller).check(GlobalPermission.ADMINISTRATE_SERVER);
+              } catch (AuthException e) {
                 throw new MergeValidationException(SET_BY_ADMIN);
+              } catch (PermissionBackendException e) {
+                log.warn("Cannot check ADMINISTRATE_SERVER", e);
+                throw new MergeValidationException("validation unavailable");
               }
 
               if (projectCache.get(newParent) == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java
index da3c123..a626998 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java
@@ -11,15 +11,20 @@
 // 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.git.validators;
 
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.Project.NameKey;
+import com.google.gerrit.server.git.RefCache;
+import com.google.gerrit.server.update.ChainedReceiveCommands;
 import com.google.gerrit.server.validators.ValidationException;
-import java.util.Map;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Repository;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
@@ -37,41 +42,58 @@
 public interface OnSubmitValidationListener {
   class Arguments {
     private Project.NameKey project;
-    private Repository repository;
-    private ObjectReader objectReader;
-    private Map<String, ReceiveCommand> commands;
+    private RevWalk rw;
+    private ImmutableMap<String, ReceiveCommand> commands;
+    private RefCache refs;
 
-    public Arguments(
-        NameKey project,
-        Repository repository,
-        ObjectReader objectReader,
-        Map<String, ReceiveCommand> commands) {
-      this.project = project;
-      this.repository = repository;
-      this.objectReader = objectReader;
-      this.commands = commands;
+    /**
+     * @param project project.
+     * @param rw revwalk that can read unflushed objects from {@code refs}.
+     * @param commands commands to be executed.
+     */
+    Arguments(Project.NameKey project, RevWalk rw, ChainedReceiveCommands commands) {
+      this.project = checkNotNull(project);
+      this.rw = checkNotNull(rw);
+      this.refs = checkNotNull(commands);
+      this.commands = ImmutableMap.copyOf(commands.getCommands());
     }
 
+    /** Get the project name for this operation. */
     public Project.NameKey getProject() {
       return project;
     }
 
-    /** @return a read only repository */
-    public Repository getRepository() {
-      return repository;
-    }
-
-    public RevWalk newRevWalk() {
-      return new RevWalk(objectReader);
+    /**
+     * Get a revwalk for this operation.
+     *
+     * <p>This instance is able to read all objects mentioned in {@link #getCommands()} and {@link
+     * #getRef(String)}.
+     *
+     * @return open revwalk.
+     */
+    public RevWalk getRevWalk() {
+      return rw;
     }
 
     /**
-     * @return a map from ref to op on it covering all ref ops to be performed on this repository as
-     *     part of ongoing submit operation.
+     * @return a map from ref to commands covering all ref operations to be performed on this
+     *     repository as part of the ongoing submit operation.
      */
-    public Map<String, ReceiveCommand> getCommands() {
+    public ImmutableMap<String, ReceiveCommand> getCommands() {
       return commands;
     }
+
+    /**
+     * Get a ref from the repository.
+     *
+     * @param name ref name; can be any ref, not just the ones mentioned in {@link #getCommands()}.
+     * @return latest value of a ref in the repository, as if all commands from {@link
+     *     #getCommands()} had already been applied.
+     * @throws IOException if an error occurred reading the ref.
+     */
+    public Optional<ObjectId> getRef(String name) throws IOException {
+      return refs.get(name);
+    }
   }
 
   /**
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java
index 55935d1..460889c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java
@@ -11,18 +11,18 @@
 // 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.git.validators;
 
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.IntegrationException;
 import com.google.gerrit.server.git.validators.OnSubmitValidationListener.Arguments;
+import com.google.gerrit.server.update.ChainedReceiveCommands;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
-import java.util.Map;
 import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 public class OnSubmitValidators {
   public interface Factory {
@@ -37,14 +37,12 @@
   }
 
   public void validate(
-      Project.NameKey project,
-      Repository repo,
-      ObjectReader objectReader,
-      Map<String, ReceiveCommand> commands)
+      Project.NameKey project, ObjectReader objectReader, ChainedReceiveCommands commands)
       throws IntegrationException {
-    try {
-      for (OnSubmitValidationListener listener : this.listeners) {
-        listener.preBranchUpdate(new Arguments(project, repo, objectReader, commands));
+    try (RevWalk rw = new RevWalk(objectReader)) {
+      Arguments args = new Arguments(project, rw, commands);
+      for (OnSubmitValidationListener listener : listeners) {
+        listener.preBranchUpdate(args);
       }
     } catch (ValidationException e) {
       throw new IntegrationException(e.getMessage());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
index 80792c3..0c4eb89 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
@@ -14,10 +14,13 @@
 package com.google.gerrit.server.git.validators;
 
 import com.google.common.base.Predicate;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.events.RefReceivedEvent;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
@@ -42,15 +45,18 @@
         update.getExpectedOldObjectId(), update.getNewObjectId(), update.getName(), type);
   }
 
-  private final RefReceivedEvent event;
+  private final AllUsersName allUsersName;
   private final DynamicSet<RefOperationValidationListener> refOperationValidationListeners;
+  private final RefReceivedEvent event;
 
   @Inject
   RefOperationValidators(
+      AllUsersName allUsersName,
       DynamicSet<RefOperationValidationListener> refOperationValidationListeners,
       @Assisted Project project,
       @Assisted IdentifiedUser user,
       @Assisted ReceiveCommand cmd) {
+    this.allUsersName = allUsersName;
     this.refOperationValidationListeners = refOperationValidationListeners;
     event = new RefReceivedEvent();
     event.command = cmd;
@@ -59,11 +65,13 @@
   }
 
   public List<ValidationMessage> validateForRefOperation() throws RefOperationValidationException {
-
     List<ValidationMessage> messages = new ArrayList<>();
     boolean withException = false;
+    List<RefOperationValidationListener> listeners = new ArrayList<>();
+    listeners.add(new DisallowDeletionOfUserBranches(allUsersName));
+    refOperationValidationListeners.forEach(l -> listeners.add(l));
     try {
-      for (RefOperationValidationListener listener : refOperationValidationListeners) {
+      for (RefOperationValidationListener listener : listeners) {
         messages.addAll(listener.onRefOperation(event));
       }
     } catch (ValidationException e) {
@@ -95,4 +103,26 @@
       return input.isError();
     }
   }
+
+  private static class DisallowDeletionOfUserBranches implements RefOperationValidationListener {
+    private final AllUsersName allUsersName;
+
+    DisallowDeletionOfUserBranches(AllUsersName allUsersName) {
+      this.allUsersName = allUsersName;
+    }
+
+    @Override
+    public List<ValidationMessage> onRefOperation(RefReceivedEvent refEvent)
+        throws ValidationException {
+      if (refEvent.project.getNameKey().equals(allUsersName)
+          && (refEvent.command.getRefName().startsWith(RefNames.REFS_USERS)
+              && !refEvent.command.getRefName().equals(RefNames.REFS_USERS_DEFAULT))
+          && refEvent.command.getType().equals(ReceiveCommand.Type.DELETE)) {
+        if (!refEvent.user.getCapabilities().canAccessDatabase()) {
+          throw new ValidationException("Not allowed to delete user branch.");
+        }
+      }
+      return ImmutableList.of();
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java
index 9bf14e7..5e3110c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java
@@ -356,7 +356,6 @@
 
   private List<AccountGroup> filterGroups(Collection<AccountGroup> groups) {
     List<AccountGroup> filteredGroups = new ArrayList<>(groups.size());
-    boolean isAdmin = identifiedUser.get().getCapabilities().canAdministrateServer();
     for (AccountGroup group : groups) {
       if (!Strings.isNullOrEmpty(matchSubstring)) {
         if (!group
@@ -372,13 +371,11 @@
       if (!groupsToInspect.isEmpty() && !groupsToInspect.contains(group.getGroupUUID())) {
         continue;
       }
-      if (!isAdmin) {
-        GroupControl c = groupControlFactory.controlFor(group);
-        if (!c.isVisible()) {
-          continue;
-        }
+
+      GroupControl c = groupControlFactory.controlFor(group);
+      if (c.isVisible()) {
+        filteredGroups.add(group);
       }
-      filteredGroups.add(group);
     }
     Collections.sort(filteredGroups, new GroupComparator());
     return filteredGroups;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/AbstractVersionManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/AbstractVersionManager.java
index 33cca1e..733fcce 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/AbstractVersionManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/AbstractVersionManager.java
@@ -64,7 +64,7 @@
     reindexers = Maps.newHashMapWithExpectedSize(defs.size());
     onlineUpgrade = cfg.getBoolean("index", null, "onlineUpgrade", true);
     runReindexMsg =
-        "No index versions ready; run java -jar "
+        "No index versions for index '%s' ready; run java -jar "
             + sitePaths.gerrit_war.toAbsolutePath()
             + " reindex";
   }
@@ -142,7 +142,7 @@
       }
     }
     if (search == null) {
-      throw new ProvisionException(runReindexMsg);
+      throw new ProvisionException(String.format(runReindexMsg, def.getName()));
     }
 
     IndexFactory<K, V, I> factory = def.getIndexFactory();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexConfig.java
index a368190..5c3cdf2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexConfig.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkArgument;
 
 import com.google.auto.value.AutoValue;
+import java.util.function.IntConsumer;
 import org.eclipse.jgit.lib.Config;
 
 /**
@@ -30,29 +31,61 @@
   private static final int DEFAULT_MAX_TERMS = 1024;
 
   public static IndexConfig createDefault() {
-    return create(0, 0, DEFAULT_MAX_TERMS);
+    return builder().build();
   }
 
-  public static IndexConfig fromConfig(Config cfg) {
-    return create(
-        cfg.getInt("index", null, "maxLimit", 0),
-        cfg.getInt("index", null, "maxPages", 0),
-        cfg.getInt("index", null, "maxTerms", 0));
+  public static Builder fromConfig(Config cfg) {
+    Builder b = builder();
+    setIfPresent(cfg, "maxLimit", b::maxLimit);
+    setIfPresent(cfg, "maxPages", b::maxPages);
+    setIfPresent(cfg, "maxTerms", b::maxTerms);
+    return b;
   }
 
-  public static IndexConfig create(int maxLimit, int maxPages, int maxTerms) {
-    return new AutoValue_IndexConfig(
-        checkLimit(maxLimit, "maxLimit", Integer.MAX_VALUE),
-        checkLimit(maxPages, "maxPages", Integer.MAX_VALUE),
-        checkLimit(maxTerms, "maxTerms", DEFAULT_MAX_TERMS));
-  }
-
-  private static int checkLimit(int limit, String name, int defaultValue) {
-    if (limit == 0) {
-      return defaultValue;
+  private static void setIfPresent(Config cfg, String name, IntConsumer setter) {
+    int n = cfg.getInt("index", null, name, 0);
+    if (n != 0) {
+      setter.accept(n);
     }
+  }
+
+  public static Builder builder() {
+    return new AutoValue_IndexConfig.Builder()
+        .maxLimit(Integer.MAX_VALUE)
+        .maxPages(Integer.MAX_VALUE)
+        .maxTerms(DEFAULT_MAX_TERMS)
+        .separateChangeSubIndexes(false);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder maxLimit(int maxLimit);
+
+    abstract int maxLimit();
+
+    public abstract Builder maxPages(int maxPages);
+
+    abstract int maxPages();
+
+    public abstract Builder maxTerms(int maxTerms);
+
+    abstract int maxTerms();
+
+    public abstract Builder separateChangeSubIndexes(boolean separate);
+
+    abstract IndexConfig autoBuild();
+
+    public IndexConfig build() {
+      IndexConfig cfg = autoBuild();
+      checkLimit(cfg.maxLimit(), "maxLimit");
+      checkLimit(cfg.maxPages(), "maxPages");
+      checkLimit(cfg.maxTerms(), "maxTerms");
+      return cfg;
+    }
+  }
+
+  private static void checkLimit(int limit, String name) {
     checkArgument(limit > 0, "%s must be positive: %s", name, limit);
-    return limit;
   }
 
   /**
@@ -71,4 +104,9 @@
    *     for performance reasons.
    */
   public abstract int maxTerms();
+
+  /**
+   * @return whether different subsets of changes may be stored in different physical sub-indexes.
+   */
+  public abstract boolean separateChangeSubIndexes();
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
index 96aec3f..9258913 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
@@ -24,7 +24,7 @@
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.index.FieldDef;
 import com.google.gerrit.server.index.SchemaUtil;
 import java.sql.Timestamp;
@@ -76,6 +76,14 @@
                       .transform(String::toLowerCase)
                       .toSet());
 
+  public static final FieldDef<AccountState, String> PREFERRED_EMAIL =
+      prefix("preferredemail")
+          .build(
+              a -> {
+                String preferredEmail = a.getAccount().getPreferredEmail();
+                return preferredEmail != null ? preferredEmail.toLowerCase() : null;
+              });
+
   public static final FieldDef<AccountState, Timestamp> REGISTERED =
       timestamp("registered").build(a -> a.getAccount().getRegisteredOn());
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
index 011f1d1..0bd3d2d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
@@ -36,7 +36,9 @@
 
   @Deprecated static final Schema<AccountState> V3 = schema(V2, AccountField.FULL_NAME);
 
-  static final Schema<AccountState> V4 = schema(V3);
+  @Deprecated static final Schema<AccountState> V4 = schema(V3);
+
+  static final Schema<AccountState> V5 = schema(V4, AccountField.PREFERRED_EMAIL);
 
   public static final AccountSchemaDefinitions INSTANCE = new AccountSchemaDefinitions();
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
index 1b84e8e..36a409a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
@@ -33,7 +33,6 @@
 import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -76,31 +75,28 @@
   }
 
   private SiteIndexer.Result reindexAccounts(
-      final AccountIndex index, List<Account.Id> ids, ProgressMonitor progress) {
+      AccountIndex index, List<Account.Id> ids, ProgressMonitor progress) {
     progress.beginTask("Reindexing accounts", ids.size());
     List<ListenableFuture<?>> futures = new ArrayList<>(ids.size());
     AtomicBoolean ok = new AtomicBoolean(true);
-    final AtomicInteger done = new AtomicInteger();
-    final AtomicInteger failed = new AtomicInteger();
+    AtomicInteger done = new AtomicInteger();
+    AtomicInteger failed = new AtomicInteger();
     Stopwatch sw = Stopwatch.createStarted();
-    for (final Account.Id id : ids) {
-      final String desc = "account " + id;
+    for (Account.Id id : ids) {
+      String desc = "account " + id;
       ListenableFuture<?> future =
           executor.submit(
-              new Callable<Void>() {
-                @Override
-                public Void call() throws Exception {
-                  try {
-                    accountCache.evict(id);
-                    index.replace(accountCache.get(id));
-                    verboseWriter.println("Reindexed " + desc);
-                    done.incrementAndGet();
-                  } catch (Exception e) {
-                    failed.incrementAndGet();
-                    throw e;
-                  }
-                  return null;
+              () -> {
+                try {
+                  accountCache.evict(id);
+                  index.replace(accountCache.get(id));
+                  verboseWriter.println("Reindexed " + desc);
+                  done.incrementAndGet();
+                } catch (Exception e) {
+                  failed.incrementAndGet();
+                  throw e;
                 }
+                return null;
               });
       addErrorListener(future, desc, progress, ok);
       futures.add(future);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
index b8acadc..a3bd230 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -40,12 +40,12 @@
 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.Comment;
 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.RefNames;
 import com.google.gerrit.server.OutputFormat;
+import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.index.FieldDef;
@@ -53,6 +53,7 @@
 import com.google.gerrit.server.index.SchemaUtil;
 import com.google.gerrit.server.index.change.StalenessChecker.RefState;
 import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -73,8 +74,12 @@
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
 import java.util.Set;
 import java.util.function.Function;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.FooterLine;
 
 /**
@@ -184,6 +189,12 @@
   public static final FieldDef<ChangeData, Iterable<String>> REVIEWER =
       exact("reviewer2").stored().buildRepeatable(cd -> getReviewerFieldValues(cd.reviewers()));
 
+  /** Reviewer(s) associated with the change that do not have a gerrit account. */
+  public static final FieldDef<ChangeData, Iterable<String>> REVIEWER_BY_EMAIL =
+      exact("reviewer_by_email")
+          .stored()
+          .buildRepeatable(cd -> getReviewerByEmailFieldValues(cd.reviewersByEmail()));
+
   @VisibleForTesting
   static List<String> getReviewerFieldValues(ReviewerSet reviewers) {
     List<String> r = new ArrayList<>(reviewers.asTable().size() * 2);
@@ -200,6 +211,27 @@
     return state.toString() + ',' + id;
   }
 
+  @VisibleForTesting
+  static List<String> getReviewerByEmailFieldValues(ReviewerByEmailSet reviewersByEmail) {
+    List<String> r = new ArrayList<>(reviewersByEmail.asTable().size() * 2);
+    for (Table.Cell<ReviewerStateInternal, Address, Timestamp> c :
+        reviewersByEmail.asTable().cellSet()) {
+      String v = getReviewerByEmailFieldValue(c.getRowKey(), c.getColumnKey());
+      r.add(v);
+      if (c.getColumnKey().getName() != null) {
+        // Add another entry without the name to provide search functionality on the email
+        Address emailOnly = new Address(c.getColumnKey().getEmail());
+        r.add(getReviewerByEmailFieldValue(c.getRowKey(), emailOnly));
+      }
+      r.add(v + ',' + c.getValue().getTime());
+    }
+    return r;
+  }
+
+  public static String getReviewerByEmailFieldValue(ReviewerStateInternal state, Address adr) {
+    return state.toString() + ',' + adr;
+  }
+
   public static ReviewerSet parseReviewerFieldValues(Iterable<String> values) {
     ImmutableTable.Builder<ReviewerStateInternal, Account.Id, Timestamp> b =
         ImmutableTable.builder();
@@ -220,6 +252,25 @@
     return ReviewerSet.fromTable(b.build());
   }
 
+  public static ReviewerByEmailSet parseReviewerByEmailFieldValues(Iterable<String> values) {
+    ImmutableTable.Builder<ReviewerStateInternal, Address, Timestamp> b = ImmutableTable.builder();
+    for (String v : values) {
+      int f = v.indexOf(',');
+      if (f < 0) {
+        continue;
+      }
+      int l = v.lastIndexOf(',');
+      if (l == f) {
+        continue;
+      }
+      b.put(
+          ReviewerStateInternal.valueOf(v.substring(0, f)),
+          Address.parse(v.substring(f + 1, l)),
+          new Timestamp(Long.valueOf(v.substring(l + 1, v.length()))));
+    }
+    return ReviewerByEmailSet.fromTable(b.build());
+  }
+
   /** Commit ID of any patch set on the change, using prefix match. */
   public static final FieldDef<ChangeData, Iterable<String>> COMMIT =
       prefix(ChangeQueryBuilder.FIELD_COMMIT).buildRepeatable(ChangeField::getRevisions);
@@ -250,13 +301,8 @@
                 return Sets.newHashSet(a.trackingFooters.extract(footers).values());
               });
 
-  /** List of labels on the current patch set. */
-  @Deprecated
-  public static final FieldDef<ChangeData, Iterable<String>> LABEL =
-      exact(ChangeQueryBuilder.FIELD_LABEL).buildRepeatable(cd -> getLabels(cd, false));
-
   /** List of labels on the current patch set including change owner votes. */
-  public static final FieldDef<ChangeData, Iterable<String>> LABEL2 =
+  public static final FieldDef<ChangeData, Iterable<String>> LABEL =
       exact("label2").buildRepeatable(cd -> getLabels(cd, true));
 
   private static Iterable<String> getLabels(ChangeData cd, boolean owners) throws OrmException {
@@ -280,10 +326,36 @@
     return SchemaUtil.getPersonParts(cd.getAuthor());
   }
 
+  public static Set<String> getAuthorNameAndEmail(ChangeData cd) throws OrmException, IOException {
+    return getNameAndEmail(cd.getAuthor());
+  }
+
   public static Set<String> getCommitterParts(ChangeData cd) throws OrmException, IOException {
     return SchemaUtil.getPersonParts(cd.getCommitter());
   }
 
+  public static Set<String> getCommitterNameAndEmail(ChangeData cd)
+      throws OrmException, IOException {
+    return getNameAndEmail(cd.getCommitter());
+  }
+
+  private static Set<String> getNameAndEmail(PersonIdent person) {
+    if (person == null) {
+      return ImmutableSet.of();
+    }
+
+    String name = person.getName().toLowerCase(Locale.US);
+    String email = person.getEmailAddress().toLowerCase(Locale.US);
+
+    StringBuilder nameEmailBuilder = new StringBuilder();
+    PersonIdent.appendSanitized(nameEmailBuilder, name);
+    nameEmailBuilder.append(" <");
+    PersonIdent.appendSanitized(nameEmailBuilder, email);
+    nameEmailBuilder.append('>');
+
+    return ImmutableSet.of(name, email, nameEmailBuilder.toString());
+  }
+
   /**
    * The exact email address, or any part of the author name or email address, in the current patch
    * set.
@@ -291,6 +363,11 @@
   public static final FieldDef<ChangeData, Iterable<String>> AUTHOR =
       fullText(ChangeQueryBuilder.FIELD_AUTHOR).buildRepeatable(ChangeField::getAuthorParts);
 
+  /** The exact name, email address and NameEmail of the author. */
+  public static final FieldDef<ChangeData, Iterable<String>> EXACT_AUTHOR =
+      exact(ChangeQueryBuilder.FIELD_EXACTAUTHOR)
+          .buildRepeatable(ChangeField::getAuthorNameAndEmail);
+
   /**
    * The exact email address, or any part of the committer name or email address, in the current
    * patch set.
@@ -298,6 +375,11 @@
   public static final FieldDef<ChangeData, Iterable<String>> COMMITTER =
       fullText(ChangeQueryBuilder.FIELD_COMMITTER).buildRepeatable(ChangeField::getCommitterParts);
 
+  /** The exact name, email address, and NameEmail of the committer. */
+  public static final FieldDef<ChangeData, Iterable<String>> EXACT_COMMITTER =
+      exact(ChangeQueryBuilder.FIELD_EXACTCOMMITTER)
+          .buildRepeatable(ChangeField::getCommitterNameAndEmail);
+
   public static final ProtobufCodec<Change> CHANGE_CODEC = CodecFactory.encoder(Change.class);
 
   /** Serialized change object, used for pre-populating results. */
@@ -338,16 +420,11 @@
   public static final FieldDef<ChangeData, Iterable<String>> COMMENT =
       fullText(ChangeQueryBuilder.FIELD_COMMENT)
           .buildRepeatable(
-              cd -> {
-                Set<String> r = new HashSet<>();
-                for (Comment c : cd.publishedComments()) {
-                  r.add(c.message);
-                }
-                for (ChangeMessage m : cd.messages()) {
-                  r.add(m.getMessage());
-                }
-                return r;
-              });
+              cd ->
+                  Stream.concat(
+                          cd.publishedComments().stream().map(c -> c.message),
+                          cd.messages().stream().map(ChangeMessage::getMessage))
+                      .collect(toSet()));
 
   /** Number of unresolved comments of the change. */
   public static final FieldDef<ChangeData, Integer> UNRESOLVED_COMMENT_COUNT =
@@ -385,22 +462,25 @@
       intRange(ChangeQueryBuilder.FIELD_DELTA)
           .build(cd -> cd.changedLines().map(c -> c.insertions + c.deletions).orElse(null));
 
+  /** Determines if this change is private. */
+  public static final FieldDef<ChangeData, String> PRIVATE =
+      exact(ChangeQueryBuilder.FIELD_PRIVATE).build(cd -> cd.change().isPrivate() ? "1" : "0");
+
+  /** Determines if this change is work in progress. */
+  public static final FieldDef<ChangeData, String> WIP =
+      exact(ChangeQueryBuilder.FIELD_WIP).build(cd -> cd.change().isWorkInProgress() ? "1" : "0");
+
   /** Users who have commented on this change. */
   public static final FieldDef<ChangeData, Iterable<Integer>> COMMENTBY =
       integer(ChangeQueryBuilder.FIELD_COMMENTBY)
           .buildRepeatable(
-              cd -> {
-                Set<Integer> r = new HashSet<>();
-                for (ChangeMessage m : cd.messages()) {
-                  if (m.getAuthor() != null) {
-                    r.add(m.getAuthor().get());
-                  }
-                }
-                for (Comment c : cd.publishedComments()) {
-                  r.add(c.author.getId().get());
-                }
-                return r;
-              });
+              cd ->
+                  Stream.concat(
+                          cd.messages().stream().map(ChangeMessage::getAuthor),
+                          cd.publishedComments().stream().map(c -> c.author.getId()))
+                      .filter(Objects::nonNull)
+                      .map(Account.Id::get)
+                      .collect(toSet()));
 
   /** Star labels on this change in the format: &lt;account-id&gt;:&lt;label&gt; */
   public static final FieldDef<ChangeData, Iterable<String>> STAR =
@@ -423,13 +503,8 @@
   public static final FieldDef<ChangeData, Iterable<String>> GROUP =
       exact(ChangeQueryBuilder.FIELD_GROUP)
           .buildRepeatable(
-              cd -> {
-                Set<String> r = Sets.newHashSetWithExpectedSize(1);
-                for (PatchSet ps : cd.patchSets()) {
-                  r.addAll(ps.getGroups());
-                }
-                return r;
-              });
+              cd ->
+                  cd.patchSets().stream().flatMap(ps -> ps.getGroups().stream()).collect(toSet()));
 
   public static final ProtobufCodec<PatchSet> PATCH_SET_CODEC =
       CodecFactory.encoder(PatchSet.class);
@@ -469,11 +544,7 @@
                 if (reviewedBy.isEmpty()) {
                   return ImmutableSet.of(NOT_REVIEWED);
                 }
-                List<Integer> result = new ArrayList<>(reviewedBy.size());
-                for (Account.Id id : reviewedBy) {
-                  result.add(id.get());
-                }
-                return result;
+                return reviewedBy.stream().map(Account.Id::get).collect(toList());
               });
 
   // Submit rule options in this class should never use fastEvalLabels. This
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
index a9e1362..e1513b3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
@@ -20,10 +20,12 @@
 import com.google.common.collect.Sets;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Status;
+import com.google.gerrit.server.index.FieldDef;
 import com.google.gerrit.server.index.IndexConfig;
 import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.IndexRewriter;
 import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.index.Schema;
 import com.google.gerrit.server.query.AndPredicate;
 import com.google.gerrit.server.query.LimitPredicate;
 import com.google.gerrit.server.query.NotPredicate;
@@ -188,6 +190,9 @@
       // and included that in their limit computation.
       return new LimitPredicate<>(ChangeQueryBuilder.FIELD_LIMIT, opts.limit());
     } else if (!isRewritePossible(in)) {
+      if (in instanceof IndexPredicate) {
+        throw new QueryParseException("Unsupported index predicate: " + in.toString());
+      }
       return null; // magic to indicate "in" cannot be rewritten
     }
 
@@ -226,7 +231,10 @@
       return false;
     }
     IndexPredicate<ChangeData> p = (IndexPredicate<ChangeData>) in;
-    return index.getSchema().hasField(p.getField());
+
+    FieldDef<ChangeData, ?> def = p.getField();
+    Schema<ChangeData> schema = index.getSchema();
+    return schema.hasField(def);
   }
 
   private Predicate<ChangeData> partitionChildren(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index a788f8c..4edfab2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -107,7 +107,7 @@
   private final ListeningExecutorService executor;
   private final DynamicSet<ChangeIndexedListener> indexedListeners;
   private final StalenessChecker stalenessChecker;
-  private final boolean reindexAfterIndexUpdate;
+  private final boolean autoReindexIfStale;
 
   @AssistedInject
   ChangeIndexer(
@@ -131,7 +131,7 @@
     this.indexedListeners = indexedListeners;
     this.stalenessChecker = stalenessChecker;
     this.batchExecutor = batchExecutor;
-    this.reindexAfterIndexUpdate = reindexAfterIndexUpdate(cfg);
+    this.autoReindexIfStale = autoReindexIfStale(cfg);
     this.index = index;
     this.indexes = null;
   }
@@ -158,13 +158,13 @@
     this.indexedListeners = indexedListeners;
     this.stalenessChecker = stalenessChecker;
     this.batchExecutor = batchExecutor;
-    this.reindexAfterIndexUpdate = reindexAfterIndexUpdate(cfg);
+    this.autoReindexIfStale = autoReindexIfStale(cfg);
     this.index = null;
     this.indexes = indexes;
   }
 
-  private static boolean reindexAfterIndexUpdate(Config cfg) {
-    return cfg.getBoolean("index", null, "testReindexAfterUpdate", true);
+  private static boolean autoReindexIfStale(Config cfg) {
+    return cfg.getBoolean("index", null, "testAutoReindexIfStale", true);
   }
 
   /**
@@ -221,7 +221,7 @@
     // and fix the staleness. It doesn't matter which order the two
     // reindexIfStale calls actually execute in; we are guaranteed that at least
     // one of them will execute after the second index write, (4).
-    reindexAfterIndexUpdate(cd);
+    autoReindexIfStale(cd);
   }
 
   private void fireChangeIndexedEvent(int id) {
@@ -253,7 +253,7 @@
   public void index(ReviewDb db, Change change) throws IOException, OrmException {
     index(newChangeData(db, change));
     // See comment in #index(ChangeData).
-    reindexAfterIndexUpdate(change.getProject(), change.getId());
+    autoReindexIfStale(change.getProject(), change.getId());
   }
 
   /**
@@ -268,7 +268,7 @@
     ChangeData cd = newChangeData(db, project, changeId);
     index(cd);
     // See comment in #index(ChangeData).
-    reindexAfterIndexUpdate(cd);
+    autoReindexIfStale(cd);
   }
 
   /**
@@ -304,16 +304,16 @@
     return submit(new ReindexIfStaleTask(project, id), batchExecutor);
   }
 
-  private void reindexAfterIndexUpdate(ChangeData cd) throws IOException {
+  private void autoReindexIfStale(ChangeData cd) throws IOException {
     try {
-      reindexAfterIndexUpdate(cd.project(), cd.getId());
+      autoReindexIfStale(cd.project(), cd.getId());
     } catch (OrmException e) {
       throw new IOException(e);
     }
   }
 
-  private void reindexAfterIndexUpdate(Project.NameKey project, Change.Id id) {
-    if (reindexAfterIndexUpdate) {
+  private void autoReindexIfStale(Project.NameKey project, Change.Id id) {
+    if (autoReindexIfStale) {
       // Don't retry indefinitely; if this fails the change will be stale.
       @SuppressWarnings("unused")
       Future<?> possiblyIgnoredError = reindexIfStale(project, id);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index d988612..be4f24b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -22,75 +22,60 @@
 
 public class ChangeSchemaDefinitions extends SchemaDefinitions<ChangeData> {
   @Deprecated
-  static final Schema<ChangeData> V32 =
+  static final Schema<ChangeData> V39 =
       schema(
-          ChangeField.LEGACY_ID,
+          ChangeField.ADDED,
+          ChangeField.APPROVAL,
+          ChangeField.ASSIGNEE,
+          ChangeField.AUTHOR,
+          ChangeField.CHANGE,
+          ChangeField.COMMENT,
+          ChangeField.COMMENTBY,
+          ChangeField.COMMIT,
+          ChangeField.COMMITTER,
+          ChangeField.COMMIT_MESSAGE,
+          ChangeField.DELETED,
+          ChangeField.DELTA,
+          ChangeField.DRAFTBY,
+          ChangeField.EDITBY,
+          ChangeField.EXACT_COMMIT,
+          ChangeField.EXACT_TOPIC,
+          ChangeField.FILE_PART,
+          ChangeField.FUZZY_TOPIC,
+          ChangeField.GROUP,
+          ChangeField.HASHTAG,
+          ChangeField.HASHTAG_CASE_AWARE,
           ChangeField.ID,
-          ChangeField.STATUS,
+          ChangeField.LABEL,
+          ChangeField.LEGACY_ID,
+          ChangeField.MERGEABLE,
+          ChangeField.OWNER,
+          ChangeField.PATCH_SET,
+          ChangeField.PATH,
           ChangeField.PROJECT,
           ChangeField.PROJECTS,
           ChangeField.REF,
-          ChangeField.EXACT_TOPIC,
-          ChangeField.FUZZY_TOPIC,
-          ChangeField.UPDATED,
-          ChangeField.FILE_PART,
-          ChangeField.PATH,
-          ChangeField.OWNER,
-          ChangeField.COMMIT,
-          ChangeField.TR,
-          ChangeField.LABEL,
-          ChangeField.COMMIT_MESSAGE,
-          ChangeField.COMMENT,
-          ChangeField.CHANGE,
-          ChangeField.APPROVAL,
-          ChangeField.MERGEABLE,
-          ChangeField.ADDED,
-          ChangeField.DELETED,
-          ChangeField.DELTA,
-          ChangeField.HASHTAG,
-          ChangeField.COMMENTBY,
-          ChangeField.PATCH_SET,
-          ChangeField.GROUP,
-          ChangeField.SUBMISSIONID,
-          ChangeField.EDITBY,
+          ChangeField.REF_STATE,
+          ChangeField.REF_STATE_PATTERN,
           ChangeField.REVIEWEDBY,
-          ChangeField.EXACT_COMMIT,
-          ChangeField.AUTHOR,
-          ChangeField.COMMITTER,
-          ChangeField.DRAFTBY,
-          ChangeField.HASHTAG_CASE_AWARE,
+          ChangeField.REVIEWER,
           ChangeField.STAR,
           ChangeField.STARBY,
-          ChangeField.REVIEWER);
-
-  @Deprecated static final Schema<ChangeData> V33 = schema(V32, ChangeField.ASSIGNEE);
-
-  @Deprecated
-  static final Schema<ChangeData> V34 =
-      new Schema.Builder<ChangeData>()
-          .add(V33)
-          .remove(ChangeField.LABEL)
-          .add(ChangeField.LABEL2)
-          .build();
-
-  @Deprecated
-  static final Schema<ChangeData> V35 =
-      schema(
-          V34,
-          ChangeField.SUBMIT_RECORD,
+          ChangeField.STATUS,
           ChangeField.STORED_SUBMIT_RECORD_LENIENT,
-          ChangeField.STORED_SUBMIT_RECORD_STRICT);
+          ChangeField.STORED_SUBMIT_RECORD_STRICT,
+          ChangeField.SUBMISSIONID,
+          ChangeField.SUBMIT_RECORD,
+          ChangeField.TR,
+          ChangeField.UNRESOLVED_COMMENT_COUNT,
+          ChangeField.UPDATED);
 
-  @Deprecated
-  static final Schema<ChangeData> V36 =
-      schema(V35, ChangeField.REF_STATE, ChangeField.REF_STATE_PATTERN);
+  @Deprecated static final Schema<ChangeData> V40 = schema(V39, ChangeField.PRIVATE);
+  @Deprecated static final Schema<ChangeData> V41 = schema(V40, ChangeField.REVIEWER_BY_EMAIL);
+  @Deprecated static final Schema<ChangeData> V42 = schema(V41, ChangeField.WIP);
 
-  @Deprecated static final Schema<ChangeData> V37 = schema(V36);
-
-  @Deprecated
-  static final Schema<ChangeData> V38 = schema(V37, ChangeField.UNRESOLVED_COMMENT_COUNT);
-
-  static final Schema<ChangeData> V39 = schema(V38);
+  static final Schema<ChangeData> V43 =
+      schema(V42, ChangeField.EXACT_AUTHOR, ChangeField.EXACT_COMMITTER);
 
   public static final String NAME = "changes";
   public static final ChangeSchemaDefinitions INSTANCE = new ChangeSchemaDefinitions();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
similarity index 81%
rename from gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterUpdate.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
index 2f6f898..a9f5306 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
@@ -21,11 +21,15 @@
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.QueueProvider.QueueType;
 import com.google.gerrit.server.index.IndexExecutor;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -41,38 +45,64 @@
 import java.util.List;
 import java.util.concurrent.Callable;
 import java.util.concurrent.Future;
+import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-public class ReindexAfterUpdate implements GitReferenceUpdatedListener {
-  private static final Logger log = LoggerFactory.getLogger(ReindexAfterUpdate.class);
+public class ReindexAfterRefUpdate implements GitReferenceUpdatedListener {
+  private static final Logger log = LoggerFactory.getLogger(ReindexAfterRefUpdate.class);
 
   private final OneOffRequestContext requestContext;
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeIndexer.Factory indexerFactory;
   private final ChangeIndexCollection indexes;
   private final ChangeNotes.Factory notesFactory;
+  private final AllUsersName allUsersName;
+  private final AccountCache accountCache;
   private final ListeningExecutorService executor;
+  private final boolean enabled;
 
   @Inject
-  ReindexAfterUpdate(
+  ReindexAfterRefUpdate(
+      @GerritServerConfig Config cfg,
       OneOffRequestContext requestContext,
       Provider<InternalChangeQuery> queryProvider,
       ChangeIndexer.Factory indexerFactory,
       ChangeIndexCollection indexes,
       ChangeNotes.Factory notesFactory,
+      AllUsersName allUsersName,
+      AccountCache accountCache,
       @IndexExecutor(QueueType.BATCH) ListeningExecutorService executor) {
     this.requestContext = requestContext;
     this.queryProvider = queryProvider;
     this.indexerFactory = indexerFactory;
     this.indexes = indexes;
     this.notesFactory = notesFactory;
+    this.allUsersName = allUsersName;
+    this.accountCache = accountCache;
     this.executor = executor;
+    this.enabled = cfg.getBoolean("index", null, "reindexAfterRefUpdate", true);
   }
 
   @Override
-  public void onGitReferenceUpdated(final Event event) {
-    if (event.getRefName().startsWith(RefNames.REFS_CHANGES)
+  public void onGitReferenceUpdated(Event event) {
+    if (allUsersName.get().equals(event.getProjectName())) {
+      Account.Id accountId = Account.Id.fromRef(event.getRefName());
+      if (accountId != null) {
+        try {
+          if (event.isDelete()) {
+            // TODO(ekempin): Delete account from cache and index.
+          } else {
+            accountCache.evict(accountId);
+          }
+        } catch (IOException e) {
+          log.error(String.format("Reindex account %s failed.", accountId), e);
+        }
+      }
+    }
+
+    if (!enabled
+        || event.getRefName().startsWith(RefNames.REFS_CHANGES)
         || event.getRefName().startsWith(RefNames.REFS_DRAFT_COMMENTS)
         || event.getRefName().startsWith(RefNames.REFS_USERS)) {
       return;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java
index 872dfaf..07e0203 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.joining;
 
@@ -135,15 +136,24 @@
 
   @VisibleForTesting
   static boolean reviewDbChangeIsStale(Change indexChange, @Nullable Change reviewDbChange) {
+    checkNotNull(indexChange);
+    PrimaryStorage storageFromIndex = PrimaryStorage.of(indexChange);
+    PrimaryStorage storageFromReviewDb = PrimaryStorage.of(reviewDbChange);
     if (reviewDbChange == null) {
-      return false; // Nothing the caller can do.
+      if (storageFromIndex == PrimaryStorage.REVIEW_DB) {
+        return true; // Index says it should have been in ReviewDb, but it wasn't.
+      }
+      return false; // Not in ReviewDb, but that's ok.
     }
     checkArgument(
         indexChange.getId().equals(reviewDbChange.getId()),
         "mismatched change ID: %s != %s",
         indexChange.getId(),
         reviewDbChange.getId());
-    if (PrimaryStorage.of(reviewDbChange) != PrimaryStorage.REVIEW_DB) {
+    if (storageFromIndex != storageFromReviewDb) {
+      return true; // Primary storage differs, definitely stale.
+    }
+    if (storageFromReviewDb != PrimaryStorage.REVIEW_DB) {
       return false; // Not a ReviewDb change, don't check rowVersion.
     }
     return reviewDbChange.getRowVersion() != indexChange.getRowVersion();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
index ec486b5..4014102 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
@@ -32,7 +32,6 @@
 import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -79,30 +78,27 @@
     progress.beginTask("Reindexing groups", uuids.size());
     List<ListenableFuture<?>> futures = new ArrayList<>(uuids.size());
     AtomicBoolean ok = new AtomicBoolean(true);
-    final AtomicInteger done = new AtomicInteger();
-    final AtomicInteger failed = new AtomicInteger();
+    AtomicInteger done = new AtomicInteger();
+    AtomicInteger failed = new AtomicInteger();
     Stopwatch sw = Stopwatch.createStarted();
-    for (final AccountGroup.UUID uuid : uuids) {
-      final String desc = "group " + uuid;
+    for (AccountGroup.UUID uuid : uuids) {
+      String desc = "group " + uuid;
       ListenableFuture<?> future =
           executor.submit(
-              new Callable<Void>() {
-                @Override
-                public Void call() throws Exception {
-                  try {
-                    AccountGroup oldGroup = groupCache.get(uuid);
-                    if (oldGroup != null) {
-                      groupCache.evict(oldGroup);
-                    }
-                    index.replace(groupCache.get(uuid));
-                    verboseWriter.println("Reindexed " + desc);
-                    done.incrementAndGet();
-                  } catch (Exception e) {
-                    failed.incrementAndGet();
-                    throw e;
+              () -> {
+                try {
+                  AccountGroup oldGroup = groupCache.get(uuid);
+                  if (oldGroup != null) {
+                    groupCache.evict(oldGroup);
                   }
-                  return null;
+                  index.replace(groupCache.get(uuid));
+                  verboseWriter.println("Reindexed " + desc);
+                  done.incrementAndGet();
+                } catch (Exception e) {
+                  failed.incrementAndGet();
+                  throw e;
                 }
+                return null;
               });
       addErrorListener(future, desc, progress, ok);
       futures.add(future);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/Address.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/Address.java
index f3b08fb..7f3ac01 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/Address.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/Address.java
@@ -42,6 +42,14 @@
     throw new IllegalArgumentException("Invalid email address: " + in);
   }
 
+  public static Address tryParse(String in) {
+    try {
+      return parse(in);
+    } catch (IllegalArgumentException e) {
+      return null;
+    }
+  }
+
   final String name;
   final String email;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index 862da9f..0c2cf04 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -50,6 +50,7 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
@@ -72,7 +73,7 @@
   private static final Logger log = LoggerFactory.getLogger(MailProcessor.class);
 
   private final AccountByEmailCache accountByEmailCache;
-  private final BatchUpdate.Factory buf;
+  private final RetryHelper retryHelper;
   private final ChangeMessagesUtil changeMessagesUtil;
   private final CommentsUtil commentsUtil;
   private final OneOffRequestContext oneOffRequestContext;
@@ -89,7 +90,7 @@
   @Inject
   public MailProcessor(
       AccountByEmailCache accountByEmailCache,
-      BatchUpdate.Factory buf,
+      RetryHelper retryHelper,
       ChangeMessagesUtil changeMessagesUtil,
       CommentsUtil commentsUtil,
       OneOffRequestContext oneOffRequestContext,
@@ -103,7 +104,7 @@
       AccountCache accountCache,
       @CanonicalWebUrl Provider<String> canonicalUrl) {
     this.accountByEmailCache = accountByEmailCache;
-    this.buf = buf;
+    this.retryHelper = retryHelper;
     this.changeMessagesUtil = changeMessagesUtil;
     this.commentsUtil = commentsUtil;
     this.oneOffRequestContext = oneOffRequestContext;
@@ -122,9 +123,17 @@
    * Parse comments from MailMessage and persist them on the change.
    *
    * @param message MailMessage to process.
-   * @throws OrmException
    */
-  public void process(MailMessage message) throws OrmException {
+  public void process(MailMessage message) throws RestApiException, UpdateException {
+    retryHelper.execute(
+        buf -> {
+          processImpl(buf, message);
+          return null;
+        });
+  }
+
+  private void processImpl(BatchUpdate.Factory buf, MailMessage message)
+      throws OrmException, UpdateException, RestApiException {
     for (DynamicMap.Entry<MailFilter> filter : mailFilters) {
       if (!filter.getProvider().get().shouldProcessMessage(message)) {
         log.warn(
@@ -200,11 +209,7 @@
       Op o = new Op(new PatchSet.Id(cd.getId(), metadata.patchSet), parsedComments, message.id());
       BatchUpdate batchUpdate = buf.create(cd.db(), project, ctx.getUser(), TimeUtil.nowTs());
       batchUpdate.addOp(cd.getId(), o);
-      try {
-        batchUpdate.execute();
-      } catch (UpdateException | RestApiException e) {
-        throw new OrmException(e);
-      }
+      batchUpdate.execute();
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailReceiver.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailReceiver.java
index 5068985..7c05478 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailReceiver.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailReceiver.java
@@ -16,10 +16,11 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.mail.EmailSettings;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.inject.Inject;
 import java.util.Collections;
 import java.util.HashSet;
@@ -132,7 +133,7 @@
                       try {
                         mailProcessor.process(m);
                         requestDeletion(m.id());
-                      } catch (OrmException e) {
+                      } catch (RestApiException | UpdateException e) {
                         log.error("Mail: Can't process message " + m.id() + " . Won't delete.", e);
                       }
                     });
@@ -141,7 +142,7 @@
         try {
           mailProcessor.process(m);
           requestDeletion(m.id());
-        } catch (OrmException e) {
+        } catch (RestApiException | UpdateException e) {
           log.error("Mail: Can't process messages. Won't delete.", e);
         }
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddKeySender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddKeySender.java
index 0c09639..30938f1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddKeySender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddKeySender.java
@@ -17,9 +17,13 @@
 import com.google.common.base.Joiner;
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.util.List;
@@ -31,6 +35,7 @@
     AddKeySender create(IdentifiedUser user, List<String> gpgKey);
   }
 
+  private final PermissionBackend permissionBackend;
   private final IdentifiedUser callingUser;
   private final IdentifiedUser user;
   private final AccountSshKey sshKey;
@@ -39,10 +44,12 @@
   @AssistedInject
   public AddKeySender(
       EmailArguments ea,
+      PermissionBackend permissionBackend,
       IdentifiedUser callingUser,
       @Assisted IdentifiedUser user,
       @Assisted AccountSshKey sshKey) {
     super(ea, "addkey");
+    this.permissionBackend = permissionBackend;
     this.callingUser = callingUser;
     this.user = user;
     this.sshKey = sshKey;
@@ -52,10 +59,12 @@
   @AssistedInject
   public AddKeySender(
       EmailArguments ea,
+      PermissionBackend permissionBackend,
       IdentifiedUser callingUser,
       @Assisted IdentifiedUser user,
       @Assisted List<String> gpgKeys) {
     super(ea, "addkey");
+    this.permissionBackend = permissionBackend;
     this.callingUser = callingUser;
     this.user = user;
     this.sshKey = null;
@@ -71,12 +80,25 @@
 
   @Override
   protected boolean shouldSendMessage() {
-    /*
-     * Don't send an email if no keys are added, or an admin is adding a key to
-     * a user.
-     */
-    return (sshKey != null || gpgKeys.size() > 0)
-        && (user.equals(callingUser) || !callingUser.getCapabilities().canAdministrateServer());
+    if (sshKey == null && (gpgKeys == null || gpgKeys.isEmpty())) {
+      // Don't email if no keys were added.
+      return false;
+    }
+
+    if (user.equals(callingUser)) {
+      // Send email if the user self-added a key; this notification is necessary to alert
+      // the user if their account was compromised and a key was unexpectedly added.
+      return true;
+    }
+
+    try {
+      // Don't email if an administrator added a key on behalf of the user.
+      permissionBackend.user(callingUser).check(GlobalPermission.ADMINISTRATE_SERVER);
+      return false;
+    } catch (AuthException | PermissionBackendException e) {
+      // Send email if a non-administrator modified the keys, e.g. by MODIFY_ACCOUNT.
+      return true;
+    }
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index bc09488..b9ade64 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.common.base.Splitter;
 import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -39,6 +40,8 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
+import com.google.template.soy.data.SoyListData;
+import com.google.template.soy.data.SoyMapData;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.text.MessageFormat;
@@ -180,6 +183,18 @@
     setHeader("X-Gerrit-Change-Number", "" + change.getChangeId());
     setChangeUrlHeader();
     setCommitIdHeader();
+
+    if (notify.ordinal() >= NotifyHandling.OWNER_REVIEWERS.ordinal()) {
+      try {
+        addByEmail(
+            RecipientType.CC, changeData.reviewersByEmail().byState(ReviewerStateInternal.CC));
+        addByEmail(
+            RecipientType.TO,
+            changeData.reviewersByEmail().byState(ReviewerStateInternal.REVIEWER));
+      } catch (OrmException e) {
+        throw new EmailException("Failed to add unregistered CCs " + change.getChangeId(), e);
+      }
+    }
   }
 
   private void setChangeUrlHeader() {
@@ -440,6 +455,7 @@
     soyContext.put("coverLetter", getCoverLetter());
     soyContext.put("fromName", getNameFor(fromId));
     soyContext.put("fromEmail", getNameEmailFor(fromId));
+    soyContext.put("diffLines", getDiffTemplateData());
 
     soyContextEmailData.put("unifiedDiff", getUnifiedDiff());
     soyContextEmailData.put("changeDetail", getChangeDetail());
@@ -539,4 +555,37 @@
       }
     }
   }
+
+  /**
+   * Generate a Soy list of maps representing each line of the unified diff. The line maps will have
+   * a 'type' key which maps to one of 'common', 'add' or 'remove' and a 'text' key which maps to
+   * the line's content.
+   */
+  private SoyListData getDiffTemplateData() {
+    SoyListData result = new SoyListData();
+    Splitter lineSplitter = Splitter.on(System.getProperty("line.separator"));
+    for (String diffLine : lineSplitter.split(getUnifiedDiff())) {
+      SoyMapData lineData = new SoyMapData();
+      lineData.put("text", diffLine);
+
+      // Skip empty lines and lines that look like diff headers.
+      if (diffLine.isEmpty() || diffLine.startsWith("---") || diffLine.startsWith("+++")) {
+        lineData.put("type", "common");
+      } else {
+        switch (diffLine.charAt(0)) {
+          case '+':
+            lineData.put("type", "add");
+            break;
+          case '-':
+            lineData.put("type", "remove");
+            break;
+          default:
+            lineData.put("type", "common");
+            break;
+        }
+      }
+      result.add(lineData);
+    }
+    return result;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java
index b572e8d..21e9ad5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -155,7 +155,9 @@
     }
     if (notify.compareTo(NotifyHandling.ALL) >= 0) {
       bccStarredBy();
-      includeWatchers(NotifyType.ALL_COMMENTS, !patchSet.isDraft());
+      includeWatchers(
+          NotifyType.ALL_COMMENTS,
+          !patchSet.isDraft() && !change.isWorkInProgress() && !change.isPrivate());
     }
     removeUsersThatIgnoredTheChange();
 
@@ -588,6 +590,7 @@
 
     footers.add("Gerrit-Comment-Date: " + getCommentTimestamp());
     footers.add("Gerrit-HasComments: " + (hasComments ? "Yes" : "No"));
+    footers.add("Gerrit-HasLabels: " + (labels.isEmpty() ? "No" : "Yes"));
   }
 
   private String getLine(PatchFile fileInfo, short side, int lineNbr) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CreateChangeSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
index 3e9e62c..8757a28 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
@@ -48,9 +48,13 @@
     super.init();
 
     boolean isDraft = change.getStatus() == Change.Status.DRAFT;
+
     try {
       // Try to mark interested owners with TO and CC or BCC line.
-      Watchers matching = getWatchers(NotifyType.NEW_CHANGES, !isDraft);
+      Watchers matching =
+          getWatchers(
+              NotifyType.NEW_CHANGES,
+              !isDraft && !change.isWorkInProgress() && !change.isPrivate());
       for (Account.Id user :
           Iterables.concat(matching.to.accounts, matching.cc.accounts, matching.bcc.accounts)) {
         if (isOwnerOfProjectOrBranch(user)) {
@@ -69,7 +73,8 @@
       log.warn("Cannot notify watchers for new change", err);
     }
 
-    includeWatchers(NotifyType.NEW_PATCHSETS, !isDraft);
+    includeWatchers(
+        NotifyType.NEW_PATCHSETS, !isDraft && !change.isWorkInProgress() && !change.isPrivate());
   }
 
   private boolean isOwnerOfProjectOrBranch(Account.Id user) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
index a563846..0fea7ce 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.mail.Address;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -32,6 +33,7 @@
 /** Let users know that a reviewer and possibly her review have been removed. */
 public class DeleteReviewerSender extends ReplyToChangeSender {
   private final Set<Account.Id> reviewers = new HashSet<>();
+  private final Set<Address> reviewersByEmail = new HashSet<>();
 
   public interface Factory extends ReplyToChangeSender.Factory<DeleteReviewerSender> {
     @Override
@@ -49,6 +51,10 @@
     reviewers.addAll(cc);
   }
 
+  public void addReviewersByEmail(Collection<Address> cc) {
+    reviewersByEmail.addAll(cc);
+  }
+
   @Override
   protected void init() throws EmailException {
     super.init();
@@ -58,6 +64,7 @@
     ccExistingReviewers();
     includeWatchers(NotifyType.ALL_COMMENTS);
     add(RecipientType.TO, reviewers);
+    addByEmail(RecipientType.TO, reviewersByEmail);
     removeUsersThatIgnoredTheChange();
   }
 
@@ -70,13 +77,16 @@
   }
 
   public List<String> getReviewerNames() {
-    if (reviewers.isEmpty()) {
+    if (reviewers.isEmpty() && reviewersByEmail.isEmpty()) {
       return null;
     }
     List<String> names = new ArrayList<>();
     for (Account.Id id : reviewers) {
       names.add(getNameFor(id));
     }
+    for (Address a : reviewersByEmail) {
+      names.add(a.toString());
+    }
     return names;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailArguments.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailArguments.java
index 9306c7a..683416f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailArguments.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailArguments.java
@@ -80,6 +80,7 @@
   final DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners;
   final StarredChangesUtil starredChangesUtil;
   final Provider<InternalAccountQuery> accountQueryProvider;
+  final OutgoingEmailValidator validator;
 
   @Inject
   EmailArguments(
@@ -111,7 +112,8 @@
       SitePaths site,
       DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners,
       StarredChangesUtil starredChangesUtil,
-      Provider<InternalAccountQuery> accountQueryProvider) {
+      Provider<InternalAccountQuery> accountQueryProvider,
+      OutgoingEmailValidator validator) {
     this.server = server;
     this.projectCache = projectCache;
     this.groupBackend = groupBackend;
@@ -141,5 +143,6 @@
     this.outgoingEmailValidationListeners = outgoingEmailValidationListeners;
     this.starredChangesUtil = starredChangesUtil;
     this.accountQueryProvider = accountQueryProvider;
+    this.validator = validator;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MergedSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MergedSender.java
index 47115af..4d48990 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MergedSender.java
@@ -41,7 +41,7 @@
   public MergedSender(EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id)
       throws OrmException {
     super(ea, "merged", newChangeData(ea, project, id));
-    labelTypes = changeData.changeControl().getLabelTypes();
+    labelTypes = changeData.getLabelTypes();
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NewChangeSender.java
index f1a9ad8..3f6d991 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import java.util.ArrayList;
@@ -28,7 +29,9 @@
 /** Sends an email alerting a user to a new change for them to review. */
 public abstract class NewChangeSender extends ChangeEmail {
   private final Set<Account.Id> reviewers = new HashSet<>();
+  private final Set<Address> reviewersByEmail = new HashSet<>();
   private final Set<Account.Id> extraCC = new HashSet<>();
+  private final Set<Address> extraCCByEmail = new HashSet<>();
 
   protected NewChangeSender(EmailArguments ea, ChangeData cd) throws OrmException {
     super(ea, "newchange", cd);
@@ -38,10 +41,18 @@
     reviewers.addAll(cc);
   }
 
+  public void addReviewersByEmail(final Collection<Address> cc) {
+    reviewersByEmail.addAll(cc);
+  }
+
   public void addExtraCC(final Collection<Account.Id> cc) {
     extraCC.addAll(cc);
   }
 
+  public void addExtraCCByEmail(final Collection<Address> cc) {
+    extraCCByEmail.addAll(cc);
+  }
+
   @Override
   protected void init() throws EmailException {
     super.init();
@@ -55,9 +66,11 @@
       case ALL:
       default:
         add(RecipientType.CC, extraCC);
+        extraCCByEmail.stream().forEach(cc -> add(RecipientType.CC, cc));
         //$FALL-THROUGH$
       case OWNER_REVIEWERS:
         add(RecipientType.TO, reviewers);
+        addByEmail(RecipientType.TO, reviewersByEmail);
         break;
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index 730b710..c2ae0bc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -441,6 +441,13 @@
     }
   }
 
+  /** Schedule this message for delivery to the listed address. */
+  protected void addByEmail(final RecipientType rt, final Collection<Address> list) {
+    for (final Address id : list) {
+      add(rt, id);
+    }
+  }
+
   protected void add(final RecipientType rt, final UserIdentity who) {
     if (who != null && who.getAccount() != null) {
       add(rt, who.getAccount());
@@ -471,7 +478,7 @@
   /** Schedule delivery of this message to the given account. */
   protected void add(final RecipientType rt, final Address addr) {
     if (addr != null && addr.getEmail() != null && addr.getEmail().length() > 0) {
-      if (!OutgoingEmailValidator.isValid(addr.getEmail())) {
+      if (!args.validator.isValid(addr.getEmail())) {
         log.warn("Not emailing " + addr.getEmail() + " (invalid email address)");
       } else if (!args.emailSender.canEmail(addr.getEmail())) {
         log.warn("Not emailing " + addr.getEmail() + " (prohibited by allowrcpt)");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmailValidator.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmailValidator.java
index 2d9db1d..1a4d39b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmailValidator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmailValidator.java
@@ -16,15 +16,34 @@
 
 import static org.apache.commons.validator.routines.DomainValidator.ArrayType.GENERIC_PLUS;
 
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import org.apache.commons.validator.routines.DomainValidator;
 import org.apache.commons.validator.routines.EmailValidator;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
+@Singleton
 public class OutgoingEmailValidator {
-  static {
-    DomainValidator.updateTLDOverride(GENERIC_PLUS, new String[] {"local"});
+  private static final Logger log = LoggerFactory.getLogger(OutgoingEmailValidator.class);
+
+  @Inject
+  OutgoingEmailValidator(@GerritServerConfig Config config) {
+    String[] allowTLD = config.getStringList("sendemail", null, "allowTLD");
+    if (allowTLD.length != 0) {
+      try {
+        DomainValidator.updateTLDOverride(GENERIC_PLUS, allowTLD);
+      } catch (IllegalStateException e) {
+        // Should only happen in tests, where the OutgoingEmailValidator
+        // is instantiated repeatedly.
+        log.error("Failed to update TLD override: " + e.getMessage());
+      }
+    }
   }
 
-  public static boolean isValid(String addr) {
+  public boolean isValid(String addr) {
     return EmailValidator.getInstance(true, true).isValid(addr);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
index c90000f..1483e21 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
@@ -66,7 +66,9 @@
     add(RecipientType.CC, extraCC);
     rcptToAuthors(RecipientType.CC);
     bccStarredBy();
-    includeWatchers(NotifyType.NEW_PATCHSETS, !patchSet.isDraft());
+    includeWatchers(
+        NotifyType.NEW_PATCHSETS,
+        !patchSet.isDraft() && !change.isWorkInProgress() && !change.isPrivate());
     removeUsersThatIgnoredTheChange();
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mime/FileTypeRegistry.java b/gerrit-server/src/main/java/com/google/gerrit/server/mime/FileTypeRegistry.java
index e9e3c71..15ee1bd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mime/FileTypeRegistry.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mime/FileTypeRegistry.java
@@ -16,6 +16,7 @@
 
 import eu.medsea.mimeutil.MimeType;
 import eu.medsea.mimeutil.MimeUtil2;
+import java.io.InputStream;
 
 public interface FileTypeRegistry {
   /**
@@ -33,6 +34,20 @@
   MimeType getMimeType(String path, byte[] content);
 
   /**
+   * Get the most specific MIME type available for a file.
+   *
+   * @param path name of the file. The base name (component after the last '/') may be used to help
+   *     determine the MIME type, such as by examining the extension (portion after the last '.' if
+   *     present).
+   * @param is InputStream corresponding to the complete file content. The content may be used to
+   *     guess the MIME type by examining the beginning for common file headers.
+   * @return the MIME type for this content. If the MIME type is not recognized or cannot be
+   *     determined, {@link MimeUtil2#UNKNOWN_MIME_TYPE} which is an alias for {@code
+   *     application/octet-stream}.
+   */
+  MimeType getMimeType(String path, InputStream is);
+
+  /**
    * Is this content type safe to transmit to a browser directly?
    *
    * @param type the MIME type of the file content.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java b/gerrit-server/src/main/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java
index 859363c..77ba79d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java
@@ -20,6 +20,7 @@
 import eu.medsea.mimeutil.MimeException;
 import eu.medsea.mimeutil.MimeType;
 import eu.medsea.mimeutil.MimeUtil2;
+import java.io.InputStream;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -87,6 +88,23 @@
         log.warn("Unable to determine MIME type from content", e);
       }
     }
+    return getMimeType(mimeTypes, path);
+  }
+
+  @Override
+  @SuppressWarnings("unchecked")
+  public MimeType getMimeType(final String path, final InputStream is) {
+    Set<MimeType> mimeTypes = new HashSet<>();
+    try {
+      mimeTypes.addAll(mimeUtil.getMimeTypes(is));
+    } catch (MimeException e) {
+      log.warn("Unable to determine MIME type from content", e);
+    }
+    return getMimeType(mimeTypes, path);
+  }
+
+  @SuppressWarnings("unchecked")
+  private MimeType getMimeType(Set<MimeType> mimeTypes, final String path) {
     try {
       mimeTypes.addAll(mimeUtil.getMimeTypes(path));
     } catch (MimeException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index 4cb570a..91a93b0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -123,7 +123,10 @@
     this.args = checkNotNull(args);
     this.changeId = checkNotNull(changeId);
     this.primaryStorage = primaryStorage;
-    this.autoRebuild = primaryStorage == PrimaryStorage.REVIEW_DB && autoRebuild;
+    this.autoRebuild =
+        primaryStorage == PrimaryStorage.REVIEW_DB
+            && !args.migration.disableChangeReviewDb()
+            && autoRebuild;
   }
 
   public Change.Id getChangeId() {
@@ -143,7 +146,7 @@
     if (!read && primaryStorage == PrimaryStorage.NOTE_DB) {
       throw new OrmException("NoteDb is required to read change " + changeId);
     }
-    boolean readOrWrite = read || args.migration.writeChanges();
+    boolean readOrWrite = read || args.migration.rawWriteChangesSetting();
     if (!readOrWrite && !autoRebuild) {
       loadDefaults();
       return self();
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 d5b1b3d..7a25163a 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
@@ -233,7 +233,7 @@
     // last time this file was updated.
     checkColumns(Change.Id.class, 1);
 
-    checkColumns(Change.class, 1, 2, 3, 4, 5, 7, 8, 10, 12, 13, 14, 17, 18, 19, 101);
+    checkColumns(Change.class, 1, 2, 3, 4, 5, 7, 8, 10, 12, 13, 14, 17, 18, 19, 20, 21, 101);
     checkColumns(ChangeMessage.Key.class, 1, 2);
     checkColumns(ChangeMessage.class, 1, 2, 3, 4, 5, 6, 7);
     checkColumns(PatchSet.Id.class, 1, 2);
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 c848987..ce3b664 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
@@ -73,6 +73,7 @@
   public static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
   public static final FooterKey FOOTER_PATCH_SET_DESCRIPTION =
       new FooterKey("Patch-set-description");
+  public static final FooterKey FOOTER_PRIVATE = new FooterKey("Private");
   public static final FooterKey FOOTER_READ_ONLY_UNTIL = new FooterKey("Read-only-until");
   public static final FooterKey FOOTER_REAL_USER = new FooterKey("Real-user");
   public static final FooterKey FOOTER_STATUS = new FooterKey("Status");
@@ -81,6 +82,7 @@
   public static final FooterKey FOOTER_SUBMITTED_WITH = new FooterKey("Submitted-with");
   public static final FooterKey FOOTER_TOPIC = new FooterKey("Topic");
   public static final FooterKey FOOTER_TAG = new FooterKey("Tag");
+  public static final FooterKey FOOTER_WORK_IN_PROGRESS = new FooterKey("Work-in-progress");
 
   private static final String AUTHOR = "Author";
   private static final String BASE_PATCH_SET = "Base-for-patch-set";
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 4993a5d..967bb69 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
@@ -48,6 +48,7 @@
 import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.git.RefCache;
@@ -94,6 +95,7 @@
     return new ConfigInvalidException("Change " + changeId + ": " + String.format(fmt, args));
   }
 
+  @Nullable
   public static Change readOneReviewDbChange(ReviewDb db, Change.Id id) throws OrmException {
     return ReviewDbUtil.unwrapDb(db).changes().get(id);
   }
@@ -250,9 +252,15 @@
       List<ChangeNotes> notes = new ArrayList<>();
       if (args.migration.enabled()) {
         for (Change.Id cid : changeIds) {
-          ChangeNotes cn = create(db, project, cid);
-          if (cn.getChange() != null && predicate.test(cn)) {
-            notes.add(cn);
+          try {
+            ChangeNotes cn = create(db, project, cid);
+            if (cn.getChange() != null && predicate.test(cn)) {
+              notes.add(cn);
+            }
+          } catch (NoSuchChangeException e) {
+            // Match ReviewDb behavior, returning not found; maybe the caller learned about it from
+            // a dangling patch set ref or something.
+            continue;
           }
         }
         return notes;
@@ -428,6 +436,11 @@
     return state.reviewers();
   }
 
+  /** @return reviewers that do not currently have a Gerrit account and were added by email. */
+  public ReviewerByEmailSet getReviewersByEmail() {
+    return state.reviewersByEmail();
+  }
+
   public ImmutableList<ReviewerStatusUpdate> getReviewerUpdates() {
     return state.reviewerUpdates();
   }
@@ -563,6 +576,20 @@
     return state.readOnlyUntil();
   }
 
+  public boolean isPrivate() {
+    if (state.isPrivate() == null) {
+      return false;
+    }
+    return state.isPrivate();
+  }
+
+  public boolean isWorkInProgress() {
+    if (state.isWorkInProgress() == null) {
+      return false;
+    }
+    return state.isWorkInProgress();
+  }
+
   @Override
   protected void onLoad(LoadHandle handle)
       throws NoSuchChangeException, IOException, ConfigInvalidException {
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 dac999c..a8ed423 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
@@ -24,6 +24,7 @@
 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_PATCH_SET_DESCRIPTION;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PRIVATE;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_READ_ONLY_UNTIL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
@@ -32,6 +33,7 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_WORK_IN_PROGRESS;
 import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
 import static java.util.stream.Collectors.joining;
 
@@ -62,8 +64,10 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.util.LabelVote;
 import java.io.IOException;
@@ -127,6 +131,7 @@
   // Private final but mutable members initialized in the constructor and filled
   // in during the parsing process.
   private final Table<Account.Id, ReviewerStateInternal, Timestamp> reviewers;
+  private final Table<Address, ReviewerStateInternal, Timestamp> reviewersByEmail;
   private final List<Account.Id> allPastReviewers;
   private final List<ReviewerStatusUpdate> reviewerUpdates;
   private final List<SubmitRecord> submitRecords;
@@ -157,6 +162,8 @@
   private String tag;
   private RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
   private Timestamp readOnlyUntil;
+  private Boolean isPrivate;
+  private Boolean workInProgress;
 
   ChangeNotesParser(
       Change.Id changeId,
@@ -172,6 +179,7 @@
     approvals = new LinkedHashMap<>();
     bufferedApprovals = new ArrayList<>();
     reviewers = HashBasedTable.create();
+    reviewersByEmail = HashBasedTable.create();
     allPastReviewers = new ArrayList<>();
     reviewerUpdates = new ArrayList<>();
     submitRecords = Lists.newArrayListWithExpectedSize(1);
@@ -199,6 +207,7 @@
       parseNotes();
       allPastReviewers.addAll(reviewers.rowKeySet());
       pruneReviewers();
+      pruneReviewersByEmail();
 
       updatePatchSetStates();
       checkMandatoryFooters();
@@ -232,13 +241,16 @@
         patchSets,
         buildApprovals(),
         ReviewerSet.fromTable(Tables.transpose(reviewers)),
+        ReviewerByEmailSet.fromTable(Tables.transpose(reviewersByEmail)),
         allPastReviewers,
         buildReviewerUpdates(),
         submitRecords,
         buildAllMessages(),
         buildMessagesByPatchSet(),
         comments,
-        readOnlyUntil);
+        readOnlyUntil,
+        isPrivate,
+        workInProgress);
   }
 
   private PatchSet.Id buildCurrentPatchSetId() {
@@ -371,6 +383,9 @@
       for (String line : commit.getFooterLineValues(state.getFooterKey())) {
         parseReviewer(ts, state, line);
       }
+      for (String line : commit.getFooterLineValues(state.getByEmailFooterKey())) {
+        parseReviewerByEmail(ts, state, line);
+      }
       // Don't update timestamp when a reviewer was added, matching RevewDb
       // behavior.
     }
@@ -379,6 +394,14 @@
       parseReadOnlyUntil(commit);
     }
 
+    if (isPrivate == null) {
+      parseIsPrivate(commit);
+    }
+
+    if (workInProgress == null) {
+      parseWorkInProgress(commit);
+    }
+
     if (lastUpdatedOn == null || ts.after(lastUpdatedOn)) {
       lastUpdatedOn = ts;
     }
@@ -910,6 +933,19 @@
     }
   }
 
+  private void parseReviewerByEmail(Timestamp ts, ReviewerStateInternal state, String line)
+      throws ConfigInvalidException {
+    Address adr;
+    try {
+      adr = Address.parse(line);
+    } catch (IllegalArgumentException e) {
+      throw invalidFooter(state.getByEmailFooterKey(), line);
+    }
+    if (!reviewersByEmail.containsRow(adr)) {
+      reviewersByEmail.put(adr, state, ts);
+    }
+  }
+
   private void parseReadOnlyUntil(ChangeNotesCommit commit) throws ConfigInvalidException {
     String raw = parseOneFooter(commit, FOOTER_READ_ONLY_UNTIL);
     if (raw == null) {
@@ -924,6 +960,34 @@
     }
   }
 
+  private void parseIsPrivate(ChangeNotesCommit commit) throws ConfigInvalidException {
+    String raw = parseOneFooter(commit, FOOTER_PRIVATE);
+    if (raw == null) {
+      return;
+    } else if (Boolean.TRUE.toString().equalsIgnoreCase(raw)) {
+      isPrivate = true;
+      return;
+    } else if (Boolean.FALSE.toString().equalsIgnoreCase(raw)) {
+      isPrivate = false;
+      return;
+    }
+    throw invalidFooter(FOOTER_PRIVATE, raw);
+  }
+
+  private void parseWorkInProgress(ChangeNotesCommit commit) throws ConfigInvalidException {
+    String raw = parseOneFooter(commit, FOOTER_WORK_IN_PROGRESS);
+    if (raw == null) {
+      return;
+    } else if (Boolean.TRUE.toString().equalsIgnoreCase(raw)) {
+      workInProgress = true;
+      return;
+    } else if (Boolean.FALSE.toString().equalsIgnoreCase(raw)) {
+      workInProgress = false;
+      return;
+    }
+    throw invalidFooter(FOOTER_WORK_IN_PROGRESS, raw);
+  }
+
   private void pruneReviewers() {
     Iterator<Table.Cell<Account.Id, ReviewerStateInternal, Timestamp>> rit =
         reviewers.cellSet().iterator();
@@ -935,6 +999,17 @@
     }
   }
 
+  private void pruneReviewersByEmail() {
+    Iterator<Table.Cell<Address, ReviewerStateInternal, Timestamp>> rit =
+        reviewersByEmail.cellSet().iterator();
+    while (rit.hasNext()) {
+      Table.Cell<Address, ReviewerStateInternal, Timestamp> e = rit.next();
+      if (e.getColumnKey() == ReviewerStateInternal.REMOVED) {
+        rit.remove();
+      }
+    }
+  }
+
   private void updatePatchSetStates() {
     Set<PatchSet.Id> missing = new TreeSet<>(ReviewDbUtil.intKeyOrdering());
     for (Iterator<PatchSet> it = patchSets.values().iterator(); it.hasNext(); ) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 7b25bbd..1087884 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -34,6 +34,7 @@
 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.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
@@ -65,12 +66,15 @@
         ImmutableList.of(),
         ImmutableList.of(),
         ReviewerSet.empty(),
+        ReviewerByEmailSet.empty(),
         ImmutableList.of(),
         ImmutableList.of(),
         ImmutableList.of(),
         ImmutableList.of(),
         ImmutableListMultimap.of(),
         ImmutableListMultimap.of(),
+        null,
+        null,
         null);
   }
 
@@ -94,13 +98,16 @@
       Map<PatchSet.Id, PatchSet> patchSets,
       ListMultimap<PatchSet.Id, PatchSetApproval> approvals,
       ReviewerSet reviewers,
+      ReviewerByEmailSet reviewersByEmail,
       List<Account.Id> allPastReviewers,
       List<ReviewerStatusUpdate> reviewerUpdates,
       List<SubmitRecord> submitRecords,
       List<ChangeMessage> allChangeMessages,
       ListMultimap<PatchSet.Id, ChangeMessage> changeMessagesByPatchSet,
       ListMultimap<RevId, Comment> publishedComments,
-      @Nullable Timestamp readOnlyUntil) {
+      @Nullable Timestamp readOnlyUntil,
+      @Nullable Boolean isPrivate,
+      @Nullable Boolean workInProgress) {
     if (hashtags == null) {
       hashtags = ImmutableSet.of();
     }
@@ -119,19 +126,24 @@
             originalSubject,
             submissionId,
             assignee,
-            status),
+            status,
+            isPrivate,
+            workInProgress),
         ImmutableSet.copyOf(pastAssignees),
         ImmutableSet.copyOf(hashtags),
         ImmutableList.copyOf(patchSets.entrySet()),
         ImmutableList.copyOf(approvals.entries()),
         reviewers,
+        reviewersByEmail,
         ImmutableList.copyOf(allPastReviewers),
         ImmutableList.copyOf(reviewerUpdates),
         ImmutableList.copyOf(submitRecords),
         ImmutableList.copyOf(allChangeMessages),
         ImmutableListMultimap.copyOf(changeMessagesByPatchSet),
         ImmutableListMultimap.copyOf(publishedComments),
-        readOnlyUntil);
+        readOnlyUntil,
+        isPrivate,
+        workInProgress);
   }
 
   /**
@@ -174,6 +186,12 @@
     // TODO(dborowitz): Use a sensible default other than null
     @Nullable
     abstract Change.Status status();
+
+    @Nullable
+    abstract Boolean isPrivate();
+
+    @Nullable
+    abstract Boolean isWorkInProgress();
   }
 
   // Only null if NoteDb is disabled.
@@ -197,6 +215,8 @@
 
   abstract ReviewerSet reviewers();
 
+  abstract ReviewerByEmailSet reviewersByEmail();
+
   abstract ImmutableList<Account.Id> allPastReviewers();
 
   abstract ImmutableList<ReviewerStatusUpdate> reviewerUpdates();
@@ -212,6 +232,12 @@
   @Nullable
   abstract Timestamp readOnlyUntil();
 
+  @Nullable
+  abstract Boolean isPrivate();
+
+  @Nullable
+  abstract Boolean isWorkInProgress();
+
   Change newChange(Project.NameKey project) {
     ChangeColumns c = checkNotNull(columns(), "columns are required");
     Change change =
@@ -269,6 +295,8 @@
     change.setLastUpdatedOn(c.lastUpdatedOn());
     change.setSubmissionId(c.submissionId());
     change.setAssignee(c.assignee());
+    change.setPrivate(c.isPrivate() == null ? false : c.isPrivate());
+    change.setWorkInProgress(c.isWorkInProgress() == null ? false : c.isWorkInProgress());
 
     if (!patchSets().isEmpty()) {
       change.setCurrentPatchSet(c.currentPatchSetId(), c.subject(), c.originalSubject());
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 7af0cb4..fcde617 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
@@ -29,6 +29,7 @@
 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_PATCH_SET_DESCRIPTION;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PRIVATE;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_READ_ONLY_UNTIL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
@@ -37,6 +38,7 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_WORK_IN_PROGRESS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.sanitizeFooter;
 import static java.util.Comparator.comparing;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
@@ -60,6 +62,7 @@
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.util.LabelVote;
@@ -121,12 +124,14 @@
   }
 
   private final AccountCache accountCache;
+  private final NoteDbUpdateManager.Factory updateManagerFactory;
   private final ChangeDraftUpdate.Factory draftUpdateFactory;
   private final RobotCommentUpdate.Factory robotCommentUpdateFactory;
-  private final NoteDbUpdateManager.Factory updateManagerFactory;
+  private final DeleteCommentRewriter.Factory deleteCommentRewriterFactory;
 
   private final Table<String, Account.Id, Optional<Short>> approvals;
   private final Map<Account.Id, ReviewerStateInternal> reviewers = new LinkedHashMap<>();
+  private final Map<Address, ReviewerStateInternal> reviewersByEmail = new LinkedHashMap<>();
   private final List<Comment> comments = new ArrayList<>();
 
   private String commitSubject;
@@ -149,9 +154,12 @@
   private String psDescription;
   private boolean currentPatchSet;
   private Timestamp readOnlyUntil;
+  private Boolean isPrivate;
+  private Boolean workInProgress;
 
   private ChangeDraftUpdate draftUpdate;
   private RobotCommentUpdate robotCommentUpdate;
+  private DeleteCommentRewriter deleteCommentRewriter;
 
   @AssistedInject
   private ChangeUpdate(
@@ -163,6 +171,7 @@
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeDraftUpdate.Factory draftUpdateFactory,
       RobotCommentUpdate.Factory robotCommentUpdateFactory,
+      DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
       ProjectCache projectCache,
       @Assisted ChangeControl ctl,
       ChangeNoteUtil noteUtil) {
@@ -175,6 +184,7 @@
         updateManagerFactory,
         draftUpdateFactory,
         robotCommentUpdateFactory,
+        deleteCommentRewriterFactory,
         projectCache,
         ctl,
         serverIdent.getWhen(),
@@ -191,6 +201,7 @@
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeDraftUpdate.Factory draftUpdateFactory,
       RobotCommentUpdate.Factory robotCommentUpdateFactory,
+      DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
       ProjectCache projectCache,
       @Assisted ChangeControl ctl,
       @Assisted Date when,
@@ -204,6 +215,7 @@
         updateManagerFactory,
         draftUpdateFactory,
         robotCommentUpdateFactory,
+        deleteCommentRewriterFactory,
         ctl,
         when,
         projectCache.get(getProjectName(ctl)).getLabelTypes().nameComparator(),
@@ -229,15 +241,17 @@
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeDraftUpdate.Factory draftUpdateFactory,
       RobotCommentUpdate.Factory robotCommentUpdateFactory,
+      DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
       @Assisted ChangeControl ctl,
       @Assisted Date when,
       @Assisted Comparator<String> labelNameComparator,
       ChangeNoteUtil noteUtil) {
     super(cfg, migration, ctl, serverIdent, anonymousCowardName, noteUtil, when);
     this.accountCache = accountCache;
+    this.updateManagerFactory = updateManagerFactory;
     this.draftUpdateFactory = draftUpdateFactory;
     this.robotCommentUpdateFactory = robotCommentUpdateFactory;
-    this.updateManagerFactory = updateManagerFactory;
+    this.deleteCommentRewriterFactory = deleteCommentRewriterFactory;
     this.approvals = approvals(labelNameComparator);
   }
 
@@ -251,6 +265,7 @@
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeDraftUpdate.Factory draftUpdateFactory,
       RobotCommentUpdate.Factory robotCommentUpdateFactory,
+      DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
       ChangeNoteUtil noteUtil,
       @Assisted Change change,
       @Assisted("effective") @Nullable Account.Id accountId,
@@ -274,6 +289,7 @@
     this.draftUpdateFactory = draftUpdateFactory;
     this.robotCommentUpdateFactory = robotCommentUpdateFactory;
     this.updateManagerFactory = updateManagerFactory;
+    this.deleteCommentRewriterFactory = deleteCommentRewriterFactory;
     this.approvals = approvals(labelNameComparator);
   }
 
@@ -388,6 +404,11 @@
     createDraftUpdateIfNull().deleteComment(c);
   }
 
+  public void deleteCommentByRewritingHistory(String uuid, String newMessage) {
+    deleteCommentRewriter =
+        deleteCommentRewriterFactory.create(getChange().getId(), uuid, newMessage);
+  }
+
   @VisibleForTesting
   ChangeDraftUpdate createDraftUpdateIfNull() {
     if (draftUpdate == null) {
@@ -469,6 +490,15 @@
     reviewers.put(reviewer, ReviewerStateInternal.REMOVED);
   }
 
+  public void putReviewerByEmail(Address reviewer, ReviewerStateInternal type) {
+    checkArgument(type != ReviewerStateInternal.REMOVED, "invalid ReviewerType");
+    reviewersByEmail.put(reviewer, type);
+  }
+
+  public void removeReviewerByEmail(Address reviewer) {
+    reviewersByEmail.put(reviewer, ReviewerStateInternal.REMOVED);
+  }
+
   public void setPatchSetState(PatchSetState psState) {
     this.psState = psState;
   }
@@ -581,6 +611,8 @@
   @Override
   protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr)
       throws OrmException, IOException {
+    checkState(deleteCommentRewriter == null, "cannot update and rewrite ref in one BatchUpdate");
+
     CommitBuilder cb = new CommitBuilder();
 
     int ps = psId != null ? psId.get() : getChange().currentPatchSetId().get();
@@ -658,6 +690,10 @@
       addIdent(msg, e.getKey()).append('\n');
     }
 
+    for (Map.Entry<Address, ReviewerStateInternal> e : reviewersByEmail.entrySet()) {
+      addFooter(msg, e.getValue().getByEmailFooterKey(), e.getKey().toString());
+    }
+
     for (Table.Cell<String, Account.Id, Optional<Short>> c : approvals.cellSet()) {
       addFooter(msg, FOOTER_LABEL);
       // Label names/values are safe to append without sanitizing.
@@ -711,6 +747,14 @@
       addFooter(msg, FOOTER_READ_ONLY_UNTIL, ChangeNoteUtil.formatTime(serverIdent, readOnlyUntil));
     }
 
+    if (isPrivate != null) {
+      addFooter(msg, FOOTER_PRIVATE, isPrivate);
+    }
+
+    if (workInProgress != null) {
+      addFooter(msg, FOOTER_WORK_IN_PROGRESS, workInProgress);
+    }
+
     cb.setMessage(msg.toString());
     try {
       ObjectId treeId = storeRevisionNotes(rw, ins, curr);
@@ -743,6 +787,7 @@
         && changeMessage == null
         && comments.isEmpty()
         && reviewers.isEmpty()
+        && reviewersByEmail.isEmpty()
         && changeId == null
         && branch == null
         && status == null
@@ -757,7 +802,9 @@
         && tag == null
         && psDescription == null
         && !currentPatchSet
-        && readOnlyUntil == null;
+        && readOnlyUntil == null
+        && isPrivate == null
+        && workInProgress == null;
   }
 
   ChangeDraftUpdate getDraftUpdate() {
@@ -768,6 +815,10 @@
     return robotCommentUpdate;
   }
 
+  public DeleteCommentRewriter getDeleteCommentRewriter() {
+    return deleteCommentRewriter;
+  }
+
   public void setAllowWriteToNewRef(boolean allow) {
     isAllowWriteToNewtRef = allow;
   }
@@ -777,6 +828,14 @@
     return isAllowWriteToNewtRef;
   }
 
+  public void setPrivate(boolean isPrivate) {
+    this.isPrivate = isPrivate;
+  }
+
+  public void setWorkInProgress(boolean workInProgress) {
+    this.workInProgress = workInProgress;
+  }
+
   void setReadOnlyUntil(Timestamp readOnlyUntil) {
     this.readOnlyUntil = readOnlyUntil;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ConfigNotesMigration.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ConfigNotesMigration.java
index c0b0525..cd00149 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ConfigNotesMigration.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ConfigNotesMigration.java
@@ -17,13 +17,11 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
 
-import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.util.Set;
 import org.eclipse.jgit.lib.Config;
 
 /**
@@ -44,36 +42,24 @@
     }
   }
 
-  private static final String NOTE_DB = "noteDb";
+  public static final String SECTION_NOTE_DB = "noteDb";
 
-  // All of these names must be reflected in the allowed set in checkConfig.
   private static final String DISABLE_REVIEW_DB = "disableReviewDb";
+  private static final String FUSE_UPDATES = "fuseUpdates";
   private static final String PRIMARY_STORAGE = "primaryStorage";
   private static final String READ = "read";
   private static final String SEQUENCE = "sequence";
   private static final String WRITE = "write";
 
-  private static void checkConfig(Config cfg) {
-    Set<String> keys = ImmutableSet.of(CHANGES.key());
-    Set<String> allowed =
-        ImmutableSet.of(
-            DISABLE_REVIEW_DB.toLowerCase(),
-            PRIMARY_STORAGE.toLowerCase(),
-            READ.toLowerCase(),
-            WRITE.toLowerCase(),
-            SEQUENCE.toLowerCase());
-    for (String t : cfg.getSubsections(NOTE_DB)) {
-      checkArgument(keys.contains(t.toLowerCase()), "invalid NoteDb table: %s", t);
-      for (String key : cfg.getNames(NOTE_DB, t)) {
-        checkArgument(allowed.contains(key.toLowerCase()), "invalid NoteDb key: %s.%s", t, key);
-      }
-    }
-  }
-
   public static Config allEnabledConfig() {
     Config cfg = new Config();
-    cfg.setBoolean(NOTE_DB, CHANGES.key(), WRITE, true);
-    cfg.setBoolean(NOTE_DB, CHANGES.key(), READ, true);
+    cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), WRITE, true);
+    cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), READ, true);
+    cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), SEQUENCE, true);
+    cfg.setString(SECTION_NOTE_DB, CHANGES.key(), PRIMARY_STORAGE, PrimaryStorage.NOTE_DB.name());
+    cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), DISABLE_REVIEW_DB, true);
+    // TODO(dborowitz): Set to true when FileRepository supports it.
+    cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), FUSE_UPDATES, false);
     return cfg;
   }
 
@@ -82,23 +68,24 @@
   private final boolean readChangeSequence;
   private final PrimaryStorage changePrimaryStorage;
   private final boolean disableChangeReviewDb;
+  private final boolean fuseUpdates;
 
   @Inject
-  ConfigNotesMigration(@GerritServerConfig Config cfg) {
-    checkConfig(cfg);
-
-    writeChanges = cfg.getBoolean(NOTE_DB, CHANGES.key(), WRITE, false);
-    readChanges = cfg.getBoolean(NOTE_DB, CHANGES.key(), READ, false);
+  public ConfigNotesMigration(@GerritServerConfig Config cfg) {
+    writeChanges = cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), WRITE, false);
+    readChanges = cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), READ, false);
 
     // Reading change sequence numbers from NoteDb is not the default even if
     // reading changes themselves is. Once this is enabled, it's not easy to
     // undo: ReviewDb might hand out numbers that have already been assigned by
     // NoteDb. This decision for the default may be reevaluated later.
-    readChangeSequence = cfg.getBoolean(NOTE_DB, CHANGES.key(), SEQUENCE, false);
+    readChangeSequence = cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), SEQUENCE, false);
 
     changePrimaryStorage =
-        cfg.getEnum(NOTE_DB, CHANGES.key(), PRIMARY_STORAGE, PrimaryStorage.REVIEW_DB);
-    disableChangeReviewDb = cfg.getBoolean(NOTE_DB, CHANGES.key(), DISABLE_REVIEW_DB, false);
+        cfg.getEnum(SECTION_NOTE_DB, CHANGES.key(), PRIMARY_STORAGE, PrimaryStorage.REVIEW_DB);
+    disableChangeReviewDb =
+        cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), DISABLE_REVIEW_DB, false);
+    fuseUpdates = cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), FUSE_UPDATES, false);
 
     checkArgument(
         !(disableChangeReviewDb && changePrimaryStorage != PrimaryStorage.NOTE_DB),
@@ -106,7 +93,7 @@
   }
 
   @Override
-  protected boolean writeChanges() {
+  public boolean rawWriteChangesSetting() {
     return writeChanges;
   }
 
@@ -129,4 +116,9 @@
   public boolean disableChangeReviewDb() {
     return disableChangeReviewDb;
   }
+
+  @Override
+  public boolean fuseUpdates() {
+    return fuseUpdates;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
new file mode 100644
index 0000000..c11e6c1
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
@@ -0,0 +1,246 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.reviewdb.client.PatchLineComment.Status.PUBLISHED;
+import static java.util.stream.Collectors.toList;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Deletes a published comment from NoteDb by rewriting the commit history. Instead of deleting the
+ * whole comment, it just replaces the comment's message with a new message.
+ */
+public class DeleteCommentRewriter implements NoteDbRewriter {
+
+  public interface Factory {
+    /**
+     * Creates a DeleteCommentRewriter instance.
+     *
+     * @param id the id of the change which contains the target comment.
+     * @param uuid the uuid of the target comment.
+     * @param newMessage the message used to replace the old message of the target comment.
+     * @return the DeleteCommentRewriter instance
+     */
+    DeleteCommentRewriter create(
+        Change.Id id, @Assisted("uuid") String uuid, @Assisted("newMessage") String newMessage);
+  }
+
+  private final ChangeNoteUtil noteUtil;
+  private final Change.Id changeId;
+  private final String uuid;
+  private final String newMessage;
+
+  @Inject
+  DeleteCommentRewriter(
+      ChangeNoteUtil noteUtil,
+      @Assisted Change.Id changeId,
+      @Assisted("uuid") String uuid,
+      @Assisted("newMessage") String newMessage) {
+    this.noteUtil = noteUtil;
+    this.changeId = changeId;
+    this.uuid = uuid;
+    this.newMessage = newMessage;
+  }
+
+  @Override
+  public String getRefName() {
+    return RefNames.changeMetaRef(changeId);
+  }
+
+  @Override
+  public ObjectId rewriteCommitHistory(RevWalk revWalk, ObjectInserter inserter, ObjectId currTip)
+      throws IOException, ConfigInvalidException, OrmException {
+    checkArgument(!currTip.equals(ObjectId.zeroId()));
+
+    // Walk from the first commit of the branch.
+    revWalk.reset();
+    revWalk.markStart(revWalk.parseCommit(currTip));
+    revWalk.sort(RevSort.REVERSE);
+
+    ObjectReader reader = revWalk.getObjectReader();
+    ObjectId newTip = revWalk.next(); // The first commit will not be rewritten.
+    Map<String, Comment> parentComments =
+        getPublishedComments(
+            noteUtil, changeId, reader, NoteMap.read(reader, revWalk.parseCommit(newTip)));
+
+    boolean rewrite = false;
+    RevCommit originalCommit;
+    while ((originalCommit = revWalk.next()) != null) {
+      NoteMap noteMap = NoteMap.read(reader, originalCommit);
+      Map<String, Comment> currComments = getPublishedComments(noteUtil, changeId, reader, noteMap);
+
+      if (!rewrite && currComments.containsKey(uuid)) {
+        rewrite = true;
+      }
+
+      if (!rewrite) {
+        parentComments = currComments;
+        newTip = originalCommit;
+        continue;
+      }
+
+      List<Comment> putInComments = getPutInComments(parentComments, currComments);
+      List<Comment> deletedComments = getDeletedComments(parentComments, currComments);
+      newTip =
+          rewriteCommit(
+              originalCommit,
+              NoteMap.read(reader, revWalk.parseCommit(newTip)),
+              newTip,
+              inserter,
+              reader,
+              putInComments,
+              deletedComments);
+      parentComments = currComments;
+    }
+
+    return newTip;
+  }
+
+  /**
+   * Gets all the comments which are presented at a commit. Note they include the comments put in by
+   * the previous commits.
+   */
+  @VisibleForTesting
+  public static Map<String, Comment> getPublishedComments(
+      ChangeNoteUtil noteUtil, Change.Id changeId, ObjectReader reader, NoteMap noteMap)
+      throws IOException, ConfigInvalidException {
+    return RevisionNoteMap.parse(noteUtil, changeId, reader, noteMap, PUBLISHED)
+        .revisionNotes
+        .values()
+        .stream()
+        .flatMap(n -> n.getComments().stream())
+        .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
+  }
+
+  /**
+   * Gets the comments put in by the current commit. The message of the target comment will be
+   * replaced by the new message.
+   *
+   * @param parMap the comment map of the parent commit.
+   * @param curMap the comment map of the current commit.
+   * @return The comments put in by the current commit.
+   */
+  private List<Comment> getPutInComments(Map<String, Comment> parMap, Map<String, Comment> curMap) {
+    List<Comment> comments = new ArrayList<>();
+    for (String key : curMap.keySet()) {
+      if (!parMap.containsKey(key)) {
+        Comment comment = curMap.get(key);
+        if (key.equals(uuid)) {
+          comment.message = newMessage;
+        }
+        comments.add(comment);
+      }
+    }
+    return comments;
+  }
+
+  /**
+   * Gets the comments deleted by the current commit.
+   *
+   * @param parMap the comment map of the parent commit.
+   * @param curMap the comment map of the current commit.
+   * @return The comments deleted by the current commit.
+   */
+  private List<Comment> getDeletedComments(
+      Map<String, Comment> parMap, Map<String, Comment> curMap) {
+    return parMap
+        .entrySet()
+        .stream()
+        .filter(c -> !curMap.containsKey(c.getKey()))
+        .map(c -> c.getValue())
+        .collect(toList());
+  }
+
+  /**
+   * Rewrites one commit.
+   *
+   * @param originalCommit the original commit to be rewritten.
+   * @param parentNoteMap the {@code NoteMap} of the new commit's parent.
+   * @param parentId the {@code ObjectId} of the new commit's parent.
+   * @param inserter the {@code ObjectInserter} for the rewrite process.
+   * @param reader the {@code ObjectReader} for the rewrite process.
+   * @param putInComments the comments put in by this commit.
+   * @param deletedComments the comments deleted by this commit.
+   * @return the {@code objectId} of the new commit.
+   * @throws IOException
+   * @throws ConfigInvalidException
+   */
+  private ObjectId rewriteCommit(
+      RevCommit originalCommit,
+      NoteMap parentNoteMap,
+      ObjectId parentId,
+      ObjectInserter inserter,
+      ObjectReader reader,
+      List<Comment> putInComments,
+      List<Comment> deletedComments)
+      throws IOException, ConfigInvalidException {
+    RevisionNoteMap<ChangeRevisionNote> revNotesMap =
+        RevisionNoteMap.parse(noteUtil, changeId, reader, parentNoteMap, PUBLISHED);
+    RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(revNotesMap);
+
+    for (Comment c : putInComments) {
+      cache.get(new RevId(c.revId)).putComment(c);
+    }
+
+    for (Comment c : deletedComments) {
+      cache.get(new RevId(c.revId)).deleteComment(c.key);
+    }
+
+    Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
+    for (Map.Entry<RevId, RevisionNoteBuilder> entry : builders.entrySet()) {
+      ObjectId objectId = ObjectId.fromString(entry.getKey().get());
+      byte[] data = entry.getValue().build(noteUtil, noteUtil.getWriteJson());
+      if (data.length == 0) {
+        revNotesMap.noteMap.remove(objectId);
+      } else {
+        revNotesMap.noteMap.set(objectId, inserter.insert(OBJ_BLOB, data));
+      }
+    }
+
+    CommitBuilder cb = new CommitBuilder();
+    cb.setParentId(parentId);
+    cb.setTreeId(revNotesMap.noteMap.writeTree(inserter));
+    cb.setMessage(originalCommit.getFullMessage());
+    cb.setCommitter(originalCommit.getCommitterIdent());
+    cb.setAuthor(originalCommit.getAuthorIdent());
+    cb.setEncoding(originalCommit.getEncoding());
+
+    return inserter.insert(cb);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java
index fef7fdf..12967b8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java
@@ -78,11 +78,11 @@
       this.code = code;
     }
 
-    public static PrimaryStorage of(Change c) {
+    public static PrimaryStorage of(@Nullable Change c) {
       return of(NoteDbChangeState.parse(c));
     }
 
-    public static PrimaryStorage of(NoteDbChangeState s) {
+    public static PrimaryStorage of(@Nullable NoteDbChangeState s) {
       return s != null ? s.getPrimaryStorage() : REVIEW_DB;
     }
   }
@@ -150,12 +150,12 @@
     }
   }
 
-  public static NoteDbChangeState parse(Change c) {
+  public static NoteDbChangeState parse(@Nullable Change c) {
     return c != null ? parse(c.getId(), c.getNoteDbState()) : null;
   }
 
   @VisibleForTesting
-  public static NoteDbChangeState parse(Change.Id id, String str) {
+  public static NoteDbChangeState parse(Change.Id id, @Nullable String str) {
     if (Strings.isNullOrEmpty(str)) {
       // Return null rather than Optional as this is what goes in the field in
       // ReviewDb.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java
index d249689..64b8b44 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java
@@ -49,6 +49,7 @@
   public void configure() {
     factory(ChangeUpdate.Factory.class);
     factory(ChangeDraftUpdate.Factory.class);
+    factory(DeleteCommentRewriter.Factory.class);
     factory(DraftCommentNotes.Factory.class);
     factory(RobotCommentUpdate.Factory.class);
     factory(RobotCommentNotes.Factory.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbRewriter.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbRewriter.java
new file mode 100644
index 0000000..3c7b0a3
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbRewriter.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public interface NoteDbRewriter {
+
+  /** Gets the name of the target ref which will be rewritten. */
+  String getRefName();
+
+  /**
+   * Rewrites the commit history.
+   *
+   * @param revWalk a {@code RevWalk} instance.
+   * @param inserter a {@code ObjectInserter} instance.
+   * @param currTip the {@code ObjectId} of the ref's tip commit.
+   * @return the {@code ObjectId} of the ref's new tip commit.
+   */
+  ObjectId rewriteCommitHistory(RevWalk revWalk, ObjectInserter inserter, ObjectId currTip)
+      throws IOException, ConfigInvalidException, OrmException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbTable.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbTable.java
index 255998c..be24e28 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbTable.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbTable.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.notedb;
 
-enum NoteDbTable {
+public enum NoteDbTable {
   ACCOUNTS,
   CHANGES;
 
-  String key() {
+  public String key() {
     return name().toLowerCase();
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index 59d7cbb..6b3492a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -22,6 +22,7 @@
 import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ListMultimap;
@@ -43,9 +44,9 @@
 import com.google.gerrit.server.update.ChainedReceiveCommands;
 import com.google.gwtorm.server.OrmConcurrencyException;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.HashMap;
@@ -53,6 +54,8 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.internal.JGitText;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectId;
@@ -62,6 +65,7 @@
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.PushCertificate;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
 /**
@@ -168,11 +172,15 @@
     }
 
     void flush() throws IOException {
+      flushToFinalInserter();
+      finalIns.flush();
+    }
+
+    void flushToFinalInserter() throws IOException {
       checkState(finalIns != null);
       for (InsertedObject obj : tempIns.getInsertedObjects()) {
         finalIns.insert(obj.type(), obj.data().toByteArray());
       }
-      finalIns.flush();
       tempIns.clear();
     }
 
@@ -198,6 +206,7 @@
   private final ListMultimap<String, ChangeUpdate> changeUpdates;
   private final ListMultimap<String, ChangeDraftUpdate> draftUpdates;
   private final ListMultimap<String, RobotCommentUpdate> robotCommentUpdates;
+  private final ListMultimap<String, NoteDbRewriter> rewriters;
   private final Set<Change.Id> toDelete;
 
   private OpenRepo changeRepo;
@@ -206,8 +215,9 @@
   private boolean checkExpectedState = true;
   private String refLogMessage;
   private PersonIdent refLogIdent;
+  private PushCertificate pushCert;
 
-  @AssistedInject
+  @Inject
   NoteDbUpdateManager(
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
       GitRepositoryManager repoManager,
@@ -224,6 +234,7 @@
     changeUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
     draftUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
     robotCommentUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
+    rewriters = MultimapBuilder.hashKeys().arrayListValues().build();
     toDelete = new HashSet<>();
   }
 
@@ -273,6 +284,25 @@
     return this;
   }
 
+  /**
+   * Set a push certificate for the push that originally triggered this NoteDb update.
+   *
+   * <p>The pusher will not necessarily have specified any of the NoteDb refs explicitly, such as
+   * when processing a push to {@code refs/for/master}. That's fine; this is just passed to the
+   * underlying {@link BatchRefUpdate}, and the implementation decides what to do with it.
+   *
+   * <p>The cert should be associated with the main repo. There is currently no way of associating a
+   * push cert with the {@code All-Users} repo, since it is not currently possible to update draft
+   * changes via push.
+   *
+   * @param pushCert push certificate; may be null.
+   * @return this
+   */
+  public NoteDbUpdateManager setPushCertificate(PushCertificate pushCert) {
+    this.pushCert = pushCert;
+    return this;
+  }
+
   public OpenRepo getChangeRepo() throws IOException {
     initChangeRepo();
     return changeRepo;
@@ -317,7 +347,14 @@
     return changeUpdates.isEmpty()
         && draftUpdates.isEmpty()
         && robotCommentUpdates.isEmpty()
-        && toDelete.isEmpty();
+        && rewriters.isEmpty()
+        && toDelete.isEmpty()
+        && !hasCommands(changeRepo)
+        && !hasCommands(allUsersRepo);
+  }
+
+  private static boolean hasCommands(@Nullable OpenRepo or) {
+    return or != null && !or.cmds.isEmpty();
   }
 
   /**
@@ -344,6 +381,10 @@
     if (rcu != null) {
       robotCommentUpdates.put(rcu.getRefName(), rcu);
     }
+    DeleteCommentRewriter deleteCommentRewriter = update.getDeleteCommentRewriter();
+    if (deleteCommentRewriter != null) {
+      rewriters.put(deleteCommentRewriter.getRefName(), deleteCommentRewriter);
+    }
   }
 
   public void add(ChangeDraftUpdate draftUpdate) {
@@ -385,6 +426,10 @@
       Set<Change.Id> changeIds = new HashSet<>();
       for (ReceiveCommand cmd : changeRepo.getCommandsSnapshot()) {
         Change.Id changeId = Change.Id.fromRef(cmd.getRefName());
+        if (changeId == null || !cmd.getRefName().equals(RefNames.changeMetaRef(changeId))) {
+          // Not a meta ref update, likely due to a repo update along with the change meta update.
+          continue;
+        }
         changeIds.add(changeId);
         Optional<ObjectId> metaId = Optional.of(cmd.getNewId());
         staged.put(
@@ -450,13 +495,19 @@
     }
   }
 
-  public void execute() throws OrmException, IOException {
+  @Nullable
+  public BatchRefUpdate execute() throws OrmException, IOException {
+    return execute(false);
+  }
+
+  @Nullable
+  public BatchRefUpdate execute(boolean dryrun) throws OrmException, IOException {
     // Check before even inspecting the list, as this is a programmer error.
     if (migration.failChangeWrites()) {
       throw new OrmException(CHANGES_READ_ONLY);
     }
     if (isEmpty()) {
-      return;
+      return null;
     }
     try (Timer1.Context timer = metrics.updateLatency.start(CHANGES)) {
       stage();
@@ -468,35 +519,80 @@
       // we may have stale draft comments. Doing it in this order allows stale
       // comments to be filtered out by ChangeNotes, reflecting the fact that
       // comments can only go from DRAFT to PUBLISHED, not vice versa.
-      execute(changeRepo);
-      execute(allUsersRepo);
+      BatchRefUpdate result = execute(changeRepo, dryrun, pushCert);
+      execute(allUsersRepo, dryrun, null);
+      return result;
     } finally {
       close();
     }
   }
 
-  private void execute(OpenRepo or) throws IOException {
+  private BatchRefUpdate execute(OpenRepo or, boolean dryrun, @Nullable PushCertificate pushCert)
+      throws IOException {
     if (or == null || or.cmds.isEmpty()) {
-      return;
+      return null;
     }
-    or.flush();
+    if (!dryrun) {
+      or.flush();
+    } else {
+      // OpenRepo buffers objects separately; caller may assume that objects are available in the
+      // inserter it previously passed via setChangeRepo.
+      or.flushToFinalInserter();
+    }
+
     BatchRefUpdate bru = or.repo.getRefDatabase().newBatchUpdate();
+    bru.setPushCertificate(pushCert);
     bru.setRefLogMessage(firstNonNull(refLogMessage, "Update NoteDb refs"), false);
     bru.setRefLogIdent(refLogIdent != null ? refLogIdent : serverIdent.get());
     or.cmds.addTo(bru);
     bru.setAllowNonFastForwards(true);
-    bru.execute(or.rw, NullProgressMonitor.INSTANCE);
 
-    boolean lockFailure = false;
+    if (!dryrun) {
+      bru.execute(or.rw, NullProgressMonitor.INSTANCE);
+      checkResults(bru);
+    }
+    return bru;
+  }
+
+  /**
+   * Check results of all commands in the update batch, reducing to a single exception if there was
+   * a failure.
+   *
+   * <p>Throws {@link LockFailureException} if at least one command failed with {@code
+   * LOCK_FAILURE}, and the entire transaction was aborted, i.e. any non-{@code LOCK_FAILURE}
+   * results, if there were any, failed with "transaction aborted".
+   *
+   * <p>In particular, if the underlying ref database does not {@link
+   * org.eclipse.jgit.lib.RefDatabase#performsAtomicTransactions() perform atomic transactions},
+   * then a combination of {@code LOCK_FAILURE} on one ref and {@code OK} or another result on other
+   * refs will <em>not</em> throw {@code LockFailureException}.
+   *
+   * @param bru batch update; should already have been executed.
+   * @throws LockFailureException if the transaction was aborted due to lock failure.
+   * @throws IOException if any result was not {@code OK}.
+   */
+  @VisibleForTesting
+  static void checkResults(BatchRefUpdate bru) throws LockFailureException, IOException {
+    int lockFailure = 0;
+    int aborted = 0;
+    int failure = 0;
+
     for (ReceiveCommand cmd : bru.getCommands()) {
+      if (cmd.getResult() != ReceiveCommand.Result.OK) {
+        failure++;
+      }
       if (cmd.getResult() == ReceiveCommand.Result.LOCK_FAILURE) {
-        lockFailure = true;
-      } else if (cmd.getResult() != ReceiveCommand.Result.OK) {
-        throw new IOException("Update failed: " + bru);
+        lockFailure++;
+      } else if (cmd.getResult() == ReceiveCommand.Result.REJECTED_OTHER_REASON
+          && JGitText.get().transactionAborted.equals(cmd.getMessage())) {
+        aborted++;
       }
     }
-    if (lockFailure) {
-      throw new LockFailureException("Update failed with one or more lock failures: " + bru);
+
+    if (lockFailure + aborted == bru.getCommands().size()) {
+      throw new LockFailureException("Update aborted with one or more lock failures: " + bru);
+    } else if (failure > 0) {
+      throw new IOException("Update failed: " + bru);
     }
   }
 
@@ -515,6 +611,21 @@
     if (!robotCommentUpdates.isEmpty()) {
       addUpdates(robotCommentUpdates, changeRepo);
     }
+    if (!rewriters.isEmpty()) {
+      Optional<String> conflictKey =
+          rewriters
+              .keySet()
+              .stream()
+              .filter(k -> (draftUpdates.containsKey(k) || robotCommentUpdates.containsKey(k)))
+              .findAny();
+      if (conflictKey.isPresent()) {
+        throw new IllegalArgumentException(
+            String.format(
+                "cannot update and rewrite ref %s in one BatchUpdate", conflictKey.get()));
+      }
+      addRewrites(rewriters, changeRepo);
+    }
+
     for (Change.Id id : toDelete) {
       doDelete(id);
     }
@@ -635,6 +746,35 @@
     }
   }
 
+  private static void addRewrites(ListMultimap<String, NoteDbRewriter> rewriters, OpenRepo openRepo)
+      throws OrmException, IOException {
+    for (Map.Entry<String, Collection<NoteDbRewriter>> entry : rewriters.asMap().entrySet()) {
+      String refName = entry.getKey();
+      ObjectId oldTip = openRepo.cmds.get(refName).orElse(ObjectId.zeroId());
+
+      if (oldTip.equals(ObjectId.zeroId())) {
+        throw new OrmException(String.format("Ref %s is empty", refName));
+      }
+
+      ObjectId currTip = oldTip;
+      try {
+        for (NoteDbRewriter noteDbRewriter : entry.getValue()) {
+          ObjectId nextTip =
+              noteDbRewriter.rewriteCommitHistory(openRepo.rw, openRepo.tempIns, currTip);
+          if (nextTip != null) {
+            currTip = nextTip;
+          }
+        }
+      } catch (ConfigInvalidException e) {
+        throw new OrmException("Cannot rewrite commit history", e);
+      }
+
+      if (!oldTip.equals(currTip)) {
+        openRepo.cmds.add(new ReceiveCommand(oldTip, currTip, refName));
+      }
+    }
+  }
+
   private static <U extends AbstractChangeUpdate> boolean allowWrite(
       Collection<U> updates, ObjectId old) {
     if (!old.equals(ObjectId.zeroId())) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigration.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigration.java
index c708bfe..7b0d76f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigration.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigration.java
@@ -49,6 +49,9 @@
   /**
    * Write changes to NoteDb.
    *
+   * <p>This method is awkwardly named because you should be using either {@link
+   * #commitChangeWrites()} or {@link #failChangeWrites()} instead.
+   *
    * <p>Updates to change data are written to NoteDb refs, but ReviewDb is still the source of
    * truth. Change data will not be written unless the NoteDb refs are already up to date, and the
    * write path will attempt to rebuild the change if not.
@@ -57,7 +60,7 @@
    * readChanges() = false}, writes to NoteDb are simply ignored; if {@code true}, any attempts to
    * write will generate an error.
    */
-  protected abstract boolean writeChanges();
+  public abstract boolean rawWriteChangesSetting();
 
   /**
    * Read sequential change ID numbers from NoteDb.
@@ -80,6 +83,19 @@
   public abstract boolean disableChangeReviewDb();
 
   /**
+   * Fuse meta ref updates in the same batch as code updates.
+   *
+   * <p>When set, each {@link com.google.gerrit.server.update.BatchUpdate} results in a single
+   * {@link org.eclipse.jgit.lib.BatchRefUpdate} to update both code and meta refs atomically.
+   * Setting this option with a repository backend that does not support atomic multi-ref
+   * transactions ({@link org.eclipse.jgit.lib.RefDatabase#performsAtomicTransactions()}) is a
+   * configuration error, and all updates will fail at runtime.
+   *
+   * <p>Has no effect if {@link #disableChangeReviewDb()} is false.
+   */
+  public abstract boolean fuseUpdates();
+
+  /**
    * Whether to fail when reading any data from NoteDb.
    *
    * <p>Used in conjunction with {@link #readChanges()} for tests.
@@ -99,14 +115,14 @@
     // same codepath. This specific condition is used by the auto-rebuilding
     // path to rebuild a change and stage the results, but not commit them due
     // to failChangeWrites().
-    return writeChanges() || readChanges();
+    return rawWriteChangesSetting() || readChanges();
   }
 
   public boolean failChangeWrites() {
-    return !writeChanges() && readChanges();
+    return !rawWriteChangesSetting() && readChanges();
   }
 
   public boolean enabled() {
-    return writeChanges() || readChanges();
+    return rawWriteChangesSetting() || readChanges();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java
index 3f0db77..8503f0a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java
@@ -52,6 +52,7 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.AtomicUpdate;
 import com.google.gwtorm.server.OrmException;
@@ -82,7 +83,6 @@
   private static final Logger log = LoggerFactory.getLogger(PrimaryStorageMigrator.class);
 
   private final AllUsersName allUsers;
-  private final BatchUpdate.Factory batchUpdateFactory;
   private final ChangeControl.GenericFactory changeControlFactory;
   private final ChangeRebuilder rebuilder;
   private final ChangeUpdate.Factory updateFactory;
@@ -90,6 +90,7 @@
   private final InternalUser.Factory internalUserFactory;
   private final Provider<InternalChangeQuery> queryProvider;
   private final Provider<ReviewDb> db;
+  private final RetryHelper retryHelper;
 
   private final long skewMs;
   private final long timeoutMs;
@@ -106,7 +107,7 @@
       Provider<InternalChangeQuery> queryProvider,
       ChangeUpdate.Factory updateFactory,
       InternalUser.Factory internalUserFactory,
-      BatchUpdate.Factory batchUpdateFactory) {
+      RetryHelper retryHelper) {
     this(
         cfg,
         db,
@@ -118,7 +119,7 @@
         queryProvider,
         updateFactory,
         internalUserFactory,
-        batchUpdateFactory);
+        retryHelper);
   }
 
   @VisibleForTesting
@@ -133,7 +134,7 @@
       Provider<InternalChangeQuery> queryProvider,
       ChangeUpdate.Factory updateFactory,
       InternalUser.Factory internalUserFactory,
-      BatchUpdate.Factory batchUpdateFactory) {
+      RetryHelper retryHelper) {
     this.db = db;
     this.repoManager = repoManager;
     this.allUsers = allUsers;
@@ -143,7 +144,7 @@
     this.queryProvider = queryProvider;
     this.updateFactory = updateFactory;
     this.internalUserFactory = internalUserFactory;
-    this.batchUpdateFactory = batchUpdateFactory;
+    this.retryHelper = retryHelper;
     skewMs = NoteDbChangeState.getReadOnlySkew(cfg);
 
     String s = "notedb";
@@ -451,19 +452,27 @@
   private void releaseReadOnlyLeaseInNoteDb(Project.NameKey project, Change.Id id)
       throws OrmException {
     // Use a BatchUpdate since ReviewDb is primary at this point, so it needs to reflect the update.
-    try (BatchUpdate bu =
-        batchUpdateFactory.create(
-            db.get(), project, internalUserFactory.create(), TimeUtil.nowTs())) {
-      bu.addOp(
-          id,
-          new BatchUpdateOp() {
-            @Override
-            public boolean updateChange(ChangeContext ctx) {
-              ctx.getUpdate(ctx.getChange().currentPatchSetId()).setReadOnlyUntil(new Timestamp(0));
-              return true;
+    // (In practice retrying won't happen, since we aren't using fused updates at this point.)
+    try {
+      retryHelper.execute(
+          updateFactory -> {
+            try (BatchUpdate bu =
+                updateFactory.create(
+                    db.get(), project, internalUserFactory.create(), TimeUtil.nowTs())) {
+              bu.addOp(
+                  id,
+                  new BatchUpdateOp() {
+                    @Override
+                    public boolean updateChange(ChangeContext ctx) {
+                      ctx.getUpdate(ctx.getChange().currentPatchSetId())
+                          .setReadOnlyUntil(new Timestamp(0));
+                      return true;
+                    }
+                  });
+              bu.execute();
+              return null;
             }
           });
-      bu.execute();
     } catch (RestApiException | UpdateException e) {
       throw new OrmException(e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
index f250646..fad9832 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
@@ -29,13 +29,17 @@
   /** The user was previously a reviewer on the change, but was removed. */
   REMOVED(new FooterKey("Removed"), ReviewerState.REMOVED);
 
+  public static ReviewerStateInternal fromReviewerState(ReviewerState state) {
+    return ReviewerStateInternal.values()[state.ordinal()];
+  }
+
   static {
     boolean ok = true;
     if (ReviewerStateInternal.values().length != ReviewerState.values().length) {
       ok = false;
     }
-    for (ReviewerStateInternal s : ReviewerStateInternal.values()) {
-      ok &= s.name().equals(s.state.name());
+    for (int i = 0; i < ReviewerStateInternal.values().length; i++) {
+      ok &= ReviewerState.values()[i].equals(ReviewerStateInternal.values()[i].state);
     }
     if (!ok) {
       throw new IllegalStateException(
@@ -58,6 +62,10 @@
     return footerKey;
   }
 
+  FooterKey getByEmailFooterKey() {
+    return new FooterKey(footerKey.getName() + "-email");
+  }
+
   public ReviewerState asReviewerState() {
     return state;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNote.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNote.java
index aec8442..deec7e9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNote.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNote.java
@@ -61,7 +61,7 @@
     MutableInteger p = new MutableInteger();
     trimLeadingEmptyLines(raw, p);
     if (p.value >= raw.length) {
-      comments = null;
+      comments = ImmutableList.of();
       return;
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
index e6549f0..99d9615 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
@@ -24,8 +24,8 @@
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -44,7 +44,7 @@
   private RevisionNoteMap<RobotCommentsRevisionNote> revisionNoteMap;
   private ObjectId metaId;
 
-  @AssistedInject
+  @Inject
   RobotCommentNotes(Args args, @Assisted Change change) {
     super(args, change.getId(), PrimaryStorage.of(change), false);
     this.change = change;
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 ad22330..29528f9 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
@@ -35,11 +35,17 @@
                   "^Change has been successfully (merged|cherry-picked|rebased|pushed).*$"),
           Change.Status.NEW, Pattern.compile("^Restored(\n.*)*$"));
 
+  private static final Pattern PRIVATE_SET_REGEXP = Pattern.compile("^Set private$");
+  private static final Pattern PRIVATE_UNSET_REGEXP = Pattern.compile("^Unset private$");
+
   private static final Pattern TOPIC_SET_REGEXP = Pattern.compile("^Topic set to (.+)$");
   private static final Pattern TOPIC_CHANGED_REGEXP =
       Pattern.compile("^Topic changed from (.+) to (.+)$");
   private static final Pattern TOPIC_REMOVED_REGEXP = Pattern.compile("^Topic (.+) removed$");
 
+  private static final Pattern WIP_SET_REGEXP = Pattern.compile("^Set Work In Progress$");
+  private static final Pattern WIP_UNSET_REGEXP = Pattern.compile("^Set Ready For Review$");
+
   private final Change change;
   private final Change noteDbChange;
   private final Optional<Change.Status> status;
@@ -80,7 +86,9 @@
   void apply(ChangeUpdate update) throws OrmException {
     checkUpdate(update);
     update.setChangeMessage(message.getMessage());
+    setPrivate(update);
     setTopic(update);
+    setWorkInProgress(update);
 
     if (status.isPresent()) {
       Change.Status s = status.get();
@@ -106,6 +114,25 @@
     return Optional.empty();
   }
 
+  private void setPrivate(ChangeUpdate update) {
+    String msg = message.getMessage();
+    if (msg == null) {
+      return;
+    }
+    Matcher m = PRIVATE_SET_REGEXP.matcher(msg);
+    if (m.matches()) {
+      update.setPrivate(true);
+      noteDbChange.setPrivate(true);
+      return;
+    }
+
+    m = PRIVATE_UNSET_REGEXP.matcher(msg);
+    if (m.matches()) {
+      update.setPrivate(false);
+      noteDbChange.setPrivate(false);
+    }
+  }
+
   private void setTopic(ChangeUpdate update) {
     String msg = message.getMessage();
     if (msg == null) {
@@ -133,6 +160,25 @@
     }
   }
 
+  private void setWorkInProgress(ChangeUpdate update) {
+    String msg = message.getMessage();
+    if (msg == null) {
+      return;
+    }
+    Matcher m = WIP_SET_REGEXP.matcher(msg);
+    if (m.matches()) {
+      update.setWorkInProgress(true);
+      noteDbChange.setWorkInProgress(true);
+      return;
+    }
+
+    m = WIP_UNSET_REGEXP.matcher(msg);
+    if (m.matches()) {
+      update.setWorkInProgress(false);
+      noteDbChange.setWorkInProgress(false);
+    }
+  }
+
   @Override
   protected void addToString(ToStringHelper helper) {
     helper.add("message", message);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java
index 6f9090f..8ce9987 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java
@@ -25,7 +25,6 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import java.io.IOException;
-import java.util.concurrent.Callable;
 
 public abstract class ChangeRebuilder {
   public static class NoPatchSetsException extends OrmException {
@@ -43,14 +42,11 @@
   }
 
   public final ListenableFuture<Result> rebuildAsync(
-      final Change.Id id, ListeningExecutorService executor) {
+      Change.Id id, ListeningExecutorService executor) {
     return executor.submit(
-        new Callable<Result>() {
-          @Override
-          public Result call() throws Exception {
-            try (ReviewDb db = schemaFactory.open()) {
-              return rebuild(db, id);
-            }
+        () -> {
+          try (ReviewDb db = schemaFactory.open()) {
+            return rebuild(db, 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 b1bd6ec..55d5a31 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
@@ -57,6 +57,12 @@
       // TODO(dborowitz): Stamp approximate approvals at this time.
       update.fixStatus(change.getStatus());
     }
+    if (change.isPrivate() != noteDbChange.isPrivate()) {
+      update.setPrivate(change.isPrivate());
+    }
+    if (change.isWorkInProgress() != noteDbChange.isWorkInProgress()) {
+      update.setWorkInProgress(change.isWorkInProgress());
+    }
     if (change.getSubmissionId() != null && noteDbChange.getSubmissionId() == null) {
       update.setSubmissionId(change.getSubmissionId());
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
index fa02691..188513f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
@@ -16,8 +16,8 @@
 
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -36,7 +36,7 @@
   private final DiffSummaryKey key;
   private final Project.NameKey project;
 
-  @AssistedInject
+  @Inject
   DiffSummaryLoader(PatchListCache plc, @Assisted DiffSummaryKey k, @Assisted Project.NameKey p) {
     patchListCache = plc;
     key = k;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java
index a571c46..54d9540 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java
@@ -18,8 +18,8 @@
 import com.google.common.base.Throwables;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.util.List;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
@@ -52,7 +52,7 @@
   private final IntraLineDiffKey key;
   private final IntraLineDiffArgs args;
 
-  @AssistedInject
+  @Inject
   IntraLineLoader(
       @DiffExecutor ExecutorService diffExecutor,
       @GerritServerConfig Config cfg,
@@ -75,12 +75,7 @@
   public IntraLineDiff call() throws Exception {
     Future<IntraLineDiff> result =
         diffExecutor.submit(
-            new Callable<IntraLineDiff>() {
-              @Override
-              public IntraLineDiff call() throws Exception {
-                return IntraLineLoader.compute(args.aText(), args.bText(), args.edits());
-              }
-            });
+            () -> IntraLineLoader.compute(args.aText(), args.bText(), args.edits()));
     try {
       return result.get(timeoutMillis, TimeUnit.MILLISECONDS);
     } catch (InterruptedException | TimeoutException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java
index 124fe8e..b766a02 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java
@@ -29,8 +29,8 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -86,7 +86,7 @@
   private final long timeoutMillis;
   private final boolean save;
 
-  @AssistedInject
+  @Inject
   PatchListLoader(
       GitRepositoryManager mgr,
       PatchListCache plc,
@@ -250,17 +250,13 @@
   }
 
   private FileHeader toFileHeader(
-      PatchListKey key, final DiffFormatter diffFormatter, final DiffEntry diffEntry)
-      throws IOException {
+      PatchListKey key, DiffFormatter diffFormatter, DiffEntry diffEntry) throws IOException {
 
     Future<FileHeader> result =
         diffExecutor.submit(
-            new Callable<FileHeader>() {
-              @Override
-              public FileHeader call() throws IOException {
-                synchronized (diffEntry) {
-                  return diffFormatter.toFileHeader(diffEntry);
-                }
+            () -> {
+              synchronized (diffEntry) {
+                return diffFormatter.toFileHeader(diffEntry);
               }
             });
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
index 2dd5af7..7e55abe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
@@ -534,11 +534,7 @@
           }
         }
 
-        if (srcContent.length > 0 && srcContent[srcContent.length - 1] != '\n') {
-          dst.setMissingNewlineAtEnd(true);
-        }
         dst.setSize(size());
-        dst.setPath(path);
 
         if (mode == FileMode.SYMLINK) {
           fileMode = PatchScript.FileMode.SYMLINK;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 82c6150..9e80f38 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -253,9 +253,7 @@
     return b;
   }
 
-  private ObjectId toObjectId(PatchSet ps)
-      throws NoSuchChangeException, AuthException, NoSuchChangeException, IOException,
-          OrmException {
+  private ObjectId toObjectId(PatchSet ps) throws AuthException, IOException, OrmException {
     if (ps.getId().get() == 0) {
       return getEditRev();
     }
@@ -271,11 +269,10 @@
     }
   }
 
-  private ObjectId getEditRev()
-      throws AuthException, NoSuchChangeException, IOException, OrmException {
+  private ObjectId getEditRev() throws AuthException, IOException, OrmException {
     edit = editReader.byChange(change);
     if (edit.isPresent()) {
-      return edit.get().getRef().getObjectId();
+      return edit.get().getEditCommit();
     }
     throw new NoSuchChangeException(change.getId());
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ChangePermission.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ChangePermission.java
new file mode 100644
index 0000000..4b06861
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ChangePermission.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2017 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.permissions;
+
+import com.google.gerrit.common.data.Permission;
+import java.util.Locale;
+import java.util.Optional;
+
+public enum ChangePermission implements ChangePermissionOrLabel {
+  READ(Permission.READ),
+  RESTORE,
+  DELETE,
+  ABANDON(Permission.ABANDON),
+  EDIT_ASSIGNEE(Permission.EDIT_ASSIGNEE),
+  EDIT_DESCRIPTION,
+  EDIT_HASHTAGS(Permission.EDIT_HASHTAGS),
+  EDIT_TOPIC_NAME(Permission.EDIT_TOPIC_NAME),
+  REMOVE_REVIEWER(Permission.REMOVE_REVIEWER),
+  ADD_PATCH_SET(Permission.ADD_PATCH_SET),
+  REBASE(Permission.REBASE),
+  SUBMIT(Permission.SUBMIT),
+  SUBMIT_AS(Permission.SUBMIT_AS);
+
+  private final String name;
+
+  ChangePermission() {
+    name = null;
+  }
+
+  ChangePermission(String name) {
+    this.name = name;
+  }
+
+  /** @return name used in {@code project.config} permissions. */
+  @Override
+  public Optional<String> permissionName() {
+    return Optional.ofNullable(name);
+  }
+
+  @Override
+  public String describeForException() {
+    return toString().toLowerCase(Locale.US).replace('_', ' ');
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
new file mode 100644
index 0000000..06c0d73
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2017 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.permissions;
+
+import java.util.Optional;
+
+/** A {@link ChangePermission} or a {@link LabelPermission}. */
+public interface ChangePermissionOrLabel {
+  /** @return name used in {@code project.config} permissions. */
+  public Optional<String> permissionName();
+
+  /** @return readable identifier of this permission for exception message. */
+  public String describeForException();
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
new file mode 100644
index 0000000..24f5164
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
@@ -0,0 +1,173 @@
+// Copyright (C) 2017 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.permissions;
+
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
+import com.google.gerrit.server.permissions.PermissionBackend.ForProject;
+import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Provider;
+import java.util.Collection;
+import java.util.Set;
+
+/**
+ * Helpers for {@link PermissionBackend} that must fail.
+ *
+ * <p>These helpers are useful to curry failure state identified inside a non-throwing factory
+ * method to the throwing {@code check} or {@code test} methods.
+ */
+public class FailedPermissionBackend {
+  public static ForProject project(String message) {
+    return project(message, null);
+  }
+
+  public static ForProject project(String message, Throwable cause) {
+    return new FailedProject(message, cause);
+  }
+
+  public static ForRef ref(String message) {
+    return ref(message, null);
+  }
+
+  public static ForRef ref(String message, Throwable cause) {
+    return new FailedRef(message, cause);
+  }
+
+  public static ForChange change(String message) {
+    return change(message, null);
+  }
+
+  public static ForChange change(String message, Throwable cause) {
+    return new FailedChange(message, cause);
+  }
+
+  private FailedPermissionBackend() {}
+
+  private static class FailedProject extends ForProject {
+    private final String message;
+    private final Throwable cause;
+
+    FailedProject(String message, Throwable cause) {
+      this.message = message;
+      this.cause = cause;
+    }
+
+    @Override
+    public ForProject database(Provider<ReviewDb> db) {
+      return this;
+    }
+
+    @Override
+    public ForProject user(CurrentUser user) {
+      return this;
+    }
+
+    @Override
+    public ForRef ref(String ref) {
+      return new FailedRef(message, cause);
+    }
+
+    @Override
+    public void check(ProjectPermission perm) throws PermissionBackendException {
+      throw new PermissionBackendException(message, cause);
+    }
+
+    @Override
+    public Set<ProjectPermission> test(Collection<ProjectPermission> permSet)
+        throws PermissionBackendException {
+      throw new PermissionBackendException(message, cause);
+    }
+  }
+
+  private static class FailedRef extends ForRef {
+    private final String message;
+    private final Throwable cause;
+
+    FailedRef(String message, Throwable cause) {
+      this.message = message;
+      this.cause = cause;
+    }
+
+    @Override
+    public ForRef database(Provider<ReviewDb> db) {
+      return this;
+    }
+
+    @Override
+    public ForRef user(CurrentUser user) {
+      return this;
+    }
+
+    @Override
+    public ForChange change(ChangeData cd) {
+      return new FailedChange(message, cause);
+    }
+
+    @Override
+    public ForChange change(ChangeNotes cd) {
+      return new FailedChange(message, cause);
+    }
+
+    @Override
+    public void check(RefPermission perm) throws PermissionBackendException {
+      throw new PermissionBackendException(message, cause);
+    }
+
+    @Override
+    public Set<RefPermission> test(Collection<RefPermission> permSet)
+        throws PermissionBackendException {
+      throw new PermissionBackendException(message, cause);
+    }
+  }
+
+  private static class FailedChange extends ForChange {
+    private final String message;
+    private final Throwable cause;
+
+    FailedChange(String message, Throwable cause) {
+      this.message = message;
+      this.cause = cause;
+    }
+
+    @Override
+    public ForChange database(Provider<ReviewDb> db) {
+      return this;
+    }
+
+    @Override
+    public ForChange user(CurrentUser user) {
+      return this;
+    }
+
+    @Override
+    public void check(ChangePermissionOrLabel perm) throws PermissionBackendException {
+      throw new PermissionBackendException(message, cause);
+    }
+
+    @Override
+    public <T extends ChangePermissionOrLabel> Set<T> test(Collection<T> permSet)
+        throws PermissionBackendException {
+      throw new PermissionBackendException(message, cause);
+    }
+
+    @Override
+    public CurrentUser user() {
+      throw new UnsupportedOperationException("FailedPermissionBackend is not scoped to user");
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/GlobalPermission.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/GlobalPermission.java
new file mode 100644
index 0000000..926057b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/GlobalPermission.java
@@ -0,0 +1,168 @@
+// Copyright (C) 2017 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.permissions;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.CapabilityScope;
+import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.api.access.PluginPermission;
+import java.lang.annotation.Annotation;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Locale;
+import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Global server permissions built into Gerrit. */
+public enum GlobalPermission implements GlobalOrPluginPermission {
+  ACCESS_DATABASE(GlobalCapability.ACCESS_DATABASE),
+  ADMINISTRATE_SERVER(GlobalCapability.ADMINISTRATE_SERVER),
+  CREATE_ACCOUNT(GlobalCapability.CREATE_ACCOUNT),
+  CREATE_GROUP(GlobalCapability.CREATE_GROUP),
+  CREATE_PROJECT(GlobalCapability.CREATE_PROJECT),
+  EMAIL_REVIEWERS(GlobalCapability.EMAIL_REVIEWERS),
+  FLUSH_CACHES(GlobalCapability.FLUSH_CACHES),
+  KILL_TASK(GlobalCapability.KILL_TASK),
+  MAINTAIN_SERVER(GlobalCapability.MAINTAIN_SERVER),
+  MODIFY_ACCOUNT(GlobalCapability.MODIFY_ACCOUNT),
+  RUN_AS(GlobalCapability.RUN_AS),
+  RUN_GC(GlobalCapability.RUN_GC),
+  STREAM_EVENTS(GlobalCapability.STREAM_EVENTS),
+  VIEW_ALL_ACCOUNTS(GlobalCapability.VIEW_ALL_ACCOUNTS),
+  VIEW_CACHES(GlobalCapability.VIEW_CACHES),
+  VIEW_CONNECTIONS(GlobalCapability.VIEW_CONNECTIONS),
+  VIEW_PLUGINS(GlobalCapability.VIEW_PLUGINS),
+  VIEW_QUEUE(GlobalCapability.VIEW_QUEUE);
+
+  private static final Logger log = LoggerFactory.getLogger(GlobalPermission.class);
+  private static final ImmutableMap<String, GlobalPermission> BY_NAME;
+
+  static {
+    ImmutableMap.Builder<String, GlobalPermission> m = ImmutableMap.builder();
+    for (GlobalPermission p : values()) {
+      m.put(p.permissionName(), p);
+    }
+    BY_NAME = m.build();
+  }
+
+  @Nullable
+  public static GlobalPermission byName(String name) {
+    return BY_NAME.get(name);
+  }
+
+  /**
+   * Extracts the {@code @RequiresCapability} or {@code @RequiresAnyCapability} annotation.
+   *
+   * @param pluginName name of the declaring plugin. May be {@code null} or {@code "gerrit"} for
+   *     classes originating from the core server.
+   * @param clazz target class to extract annotation from.
+   * @return empty set if no annotations were found, or a collection of permissions, any of which
+   *     are suitable to enable access.
+   * @throws PermissionBackendException the annotation could not be parsed.
+   */
+  public static Set<GlobalOrPluginPermission> fromAnnotation(
+      @Nullable String pluginName, Class<?> clazz) throws PermissionBackendException {
+    RequiresCapability rc = findAnnotation(clazz, RequiresCapability.class);
+    RequiresAnyCapability rac = findAnnotation(clazz, RequiresAnyCapability.class);
+    if (rc != null && rac != null) {
+      log.error(
+          String.format(
+              "Class %s uses both @%s and @%s",
+              clazz.getName(),
+              RequiresCapability.class.getSimpleName(),
+              RequiresAnyCapability.class.getSimpleName()));
+      throw new PermissionBackendException("cannot extract permission");
+    } else if (rc != null) {
+      return Collections.singleton(
+          resolve(pluginName, rc.value(), rc.scope(), clazz, RequiresCapability.class));
+    } else if (rac != null) {
+      Set<GlobalOrPluginPermission> r = new LinkedHashSet<>();
+      for (String capability : rac.value()) {
+        r.add(resolve(pluginName, capability, rac.scope(), clazz, RequiresAnyCapability.class));
+      }
+      return Collections.unmodifiableSet(r);
+    } else {
+      return Collections.emptySet();
+    }
+  }
+
+  public static Set<GlobalOrPluginPermission> fromAnnotation(Class<?> clazz)
+      throws PermissionBackendException {
+    return fromAnnotation(null, clazz);
+  }
+
+  private final String name;
+
+  GlobalPermission(String name) {
+    this.name = name;
+  }
+
+  /** @return name used in {@code project.config} permissions. */
+  @Override
+  public String permissionName() {
+    return name;
+  }
+
+  @Override
+  public String describeForException() {
+    return toString().toLowerCase(Locale.US).replace('_', ' ');
+  }
+
+  private static GlobalOrPluginPermission resolve(
+      @Nullable String pluginName,
+      String capability,
+      CapabilityScope scope,
+      Class<?> clazz,
+      Class<?> annotationClass)
+      throws PermissionBackendException {
+    if (pluginName != null
+        && !"gerrit".equals(pluginName)
+        && (scope == CapabilityScope.PLUGIN || scope == CapabilityScope.CONTEXT)) {
+      return new PluginPermission(pluginName, capability);
+    }
+
+    if (scope == CapabilityScope.PLUGIN) {
+      log.error(
+          String.format(
+              "Class %s uses @%s(scope=%s), but is not within a plugin",
+              clazz.getName(), annotationClass.getSimpleName(), scope.name()));
+      throw new PermissionBackendException("cannot extract permission");
+    }
+
+    GlobalPermission perm = byName(capability);
+    if (perm == null) {
+      log.error(
+          String.format("Class %s requires unknown capability %s", clazz.getName(), capability));
+      throw new PermissionBackendException("cannot extract permission");
+    }
+    return perm;
+  }
+
+  @Nullable
+  private static <T extends Annotation> T findAnnotation(Class<?> clazz, Class<T> annotation) {
+    for (; clazz != null; clazz = clazz.getSuperclass()) {
+      T t = clazz.getAnnotation(annotation);
+      if (t != null) {
+        return t;
+      }
+    }
+    return null;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/LabelPermission.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/LabelPermission.java
new file mode 100644
index 0000000..747c997
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/LabelPermission.java
@@ -0,0 +1,273 @@
+// Copyright (C) 2017 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.permissions;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
+import static com.google.gerrit.server.permissions.LabelPermission.ForUser.SELF;
+
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.server.util.LabelVote;
+import java.util.Optional;
+
+/** Permission representing a label. */
+public class LabelPermission implements ChangePermissionOrLabel {
+  public enum ForUser {
+    SELF,
+    ON_BEHALF_OF;
+  }
+
+  private final ForUser forUser;
+  private final String name;
+
+  /**
+   * Construct a reference to a label permission.
+   *
+   * @param type type description of the label.
+   */
+  public LabelPermission(LabelType type) {
+    this(SELF, type);
+  }
+
+  /**
+   * Construct a reference to a label permission.
+   *
+   * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+   * @param type type description of the label.
+   */
+  public LabelPermission(ForUser forUser, LabelType type) {
+    this(forUser, type.getName());
+  }
+
+  /**
+   * Construct a reference to a label permission.
+   *
+   * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
+   */
+  public LabelPermission(String name) {
+    this(SELF, name);
+  }
+
+  /**
+   * Construct a reference to a label permission.
+   *
+   * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+   * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
+   */
+  public LabelPermission(ForUser forUser, String name) {
+    this.forUser = checkNotNull(forUser, "ForUser");
+    this.name = LabelType.checkName(name);
+  }
+
+  /** @return {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
+  public ForUser forUser() {
+    return forUser;
+  }
+
+  /** @return name of the label, e.g. {@code "Code-Review"}. */
+  public String label() {
+    return name;
+  }
+
+  /** @return name used in {@code project.config} permissions. */
+  @Override
+  public Optional<String> permissionName() {
+    switch (forUser) {
+      case SELF:
+        return Optional.of(Permission.forLabel(name));
+      case ON_BEHALF_OF:
+        return Optional.of(Permission.forLabelAs(name));
+    }
+    return Optional.empty();
+  }
+
+  @Override
+  public String describeForException() {
+    if (forUser == ON_BEHALF_OF) {
+      return "labelAs " + name;
+    }
+    return "label " + name;
+  }
+
+  @Override
+  public int hashCode() {
+    return name.hashCode();
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (other instanceof LabelPermission) {
+      LabelPermission b = (LabelPermission) other;
+      return forUser == b.forUser && name.equals(b.name);
+    }
+    return false;
+  }
+
+  @Override
+  public String toString() {
+    if (forUser == ON_BEHALF_OF) {
+      return "LabelAs[" + name + ']';
+    }
+    return "Label[" + name + ']';
+  }
+
+  /** A {@link LabelPermission} at a specific value. */
+  public static class WithValue implements ChangePermissionOrLabel {
+    private final ForUser forUser;
+    private final LabelVote label;
+
+    /**
+     * Construct a reference to a label at a specific value.
+     *
+     * @param type description of the label.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(LabelType type, LabelValue value) {
+      this(SELF, type, value);
+    }
+
+    /**
+     * Construct a reference to a label at a specific value.
+     *
+     * @param type description of the label.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(LabelType type, short value) {
+      this(SELF, type.getName(), value);
+    }
+
+    /**
+     * Construct a reference to a label at a specific value.
+     *
+     * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+     * @param type description of the label.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(ForUser forUser, LabelType type, LabelValue value) {
+      this(forUser, type.getName(), value.getValue());
+    }
+
+    /**
+     * Construct a reference to a label at a specific value.
+     *
+     * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+     * @param type description of the label.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(ForUser forUser, LabelType type, short value) {
+      this(forUser, type.getName(), value);
+    }
+
+    /**
+     * Construct a reference to a label at a specific value.
+     *
+     * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(String name, short value) {
+      this(SELF, name, value);
+    }
+
+    /**
+     * Construct a reference to a label at a specific value.
+     *
+     * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+     * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(ForUser forUser, String name, short value) {
+      this(forUser, LabelVote.create(name, value));
+    }
+
+    /**
+     * Construct a reference to a label at a specific value.
+     *
+     * @param label label name and vote.
+     */
+    public WithValue(LabelVote label) {
+      this(SELF, label);
+    }
+
+    /**
+     * Construct a reference to a label at a specific value.
+     *
+     * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+     * @param label label name and vote.
+     */
+    public WithValue(ForUser forUser, LabelVote label) {
+      this.forUser = checkNotNull(forUser, "ForUser");
+      this.label = checkNotNull(label, "LabelVote");
+    }
+
+    /** @return {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
+    public ForUser forUser() {
+      return forUser;
+    }
+
+    /** @return name of the label, e.g. {@code "Code-Review"}. */
+    public String label() {
+      return label.label();
+    }
+
+    /** @return specific value of the label, e.g. 1 or 2. */
+    public short value() {
+      return label.value();
+    }
+
+    /** @return name used in {@code project.config} permissions. */
+    @Override
+    public Optional<String> permissionName() {
+      switch (forUser) {
+        case SELF:
+          return Optional.of(Permission.forLabel(label()));
+        case ON_BEHALF_OF:
+          return Optional.of(Permission.forLabelAs(label()));
+      }
+      return Optional.empty();
+    }
+
+    @Override
+    public String describeForException() {
+      if (forUser == ON_BEHALF_OF) {
+        return "labelAs " + label.formatWithEquals();
+      }
+      return "label " + label.formatWithEquals();
+    }
+
+    @Override
+    public int hashCode() {
+      return label.hashCode();
+    }
+
+    @Override
+    public boolean equals(Object other) {
+      if (other instanceof WithValue) {
+        WithValue b = (WithValue) other;
+        return forUser == b.forUser && label.equals(b.label);
+      }
+      return false;
+    }
+
+    @Override
+    public String toString() {
+      if (forUser == ON_BEHALF_OF) {
+        return "LabelAs[" + label.format() + ']';
+      }
+      return "Label[" + label.format() + ']';
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackend.java
new file mode 100644
index 0000000..9aa8d27
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -0,0 +1,431 @@
+// Copyright (C) 2017 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.permissions;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Provider;
+import com.google.inject.util.Providers;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.Iterator;
+import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Checks authorization to perform an action on a project, reference, or change.
+ *
+ * <p>{@code check} methods should be used during action handlers to verify the user is allowed to
+ * exercise the specified permission. For convenience in implementation {@code check} methods throw
+ * {@link AuthException} if the permission is denied.
+ *
+ * <p>{@code test} methods should be used when constructing replies to the client and the result
+ * object needs to include a true/false hint indicating the user's ability to exercise the
+ * permission. This is suitable for configuring UI button state, but should not be relied upon to
+ * guard handlers before making state changes.
+ *
+ * <p>{@code PermissionBackend} is a singleton for the server, acting as a factory for lightweight
+ * request instances. Implementation classes may cache supporting data inside of {@link WithUser},
+ * {@link ForProject}, {@link ForRef}, and {@link ForChange} instances, in addition to storing
+ * within {@link CurrentUser} using a {@link com.google.gerrit.server.CurrentUser.PropertyKey}.
+ * {@link GlobalPermission} caching for {@link WithUser} may best cached inside {@link CurrentUser}
+ * as {@link WithUser} instances are frequently created.
+ *
+ * <p>Example use:
+ *
+ * <pre>
+ *   private final PermissionBackend permissions;
+ *   private final Provider<CurrentUser> user;
+ *
+ *   @Inject
+ *   Foo(PermissionBackend permissions, Provider<CurrentUser> user) {
+ *     this.permissions = permissions;
+ *     this.user = user;
+ *   }
+ *
+ *   public void apply(...) {
+ *     permissions.user(user).change(cd).check(ChangePermission.SUBMIT);
+ *   }
+ *
+ *   public UiAction.Description getDescription(ChangeResource rsrc) {
+ *     return new UiAction.Description()
+ *       .setLabel("Submit")
+ *       .setVisible(rsrc.permissions().testOrFalse(ChangePermission.SUBMIT));
+ * }
+ * </pre>
+ */
+public abstract class PermissionBackend {
+  private static final Logger logger = LoggerFactory.getLogger(PermissionBackend.class);
+
+  /** @return lightweight factory scoped to answer for the specified user. */
+  public abstract WithUser user(CurrentUser user);
+
+  /** @return lightweight factory scoped to answer for the specified user. */
+  public <U extends CurrentUser> WithUser user(Provider<U> user) {
+    return user(checkNotNull(user, "Provider<CurrentUser>").get());
+  }
+
+  /** PermissionBackend with an optional per-request ReviewDb handle. */
+  public abstract static class AcceptsReviewDb<T> {
+    protected Provider<ReviewDb> db;
+
+    public T database(Provider<ReviewDb> db) {
+      if (db != null) {
+        this.db = db;
+      }
+      return self();
+    }
+
+    public T database(ReviewDb db) {
+      return database(Providers.of(checkNotNull(db, "ReviewDb")));
+    }
+
+    @SuppressWarnings("unchecked")
+    private T self() {
+      return (T) this;
+    }
+  }
+
+  /** PermissionBackend scoped to a specific user. */
+  public abstract static class WithUser extends AcceptsReviewDb<WithUser> {
+    /** @return instance scoped for the specified project. */
+    public abstract ForProject project(Project.NameKey project);
+
+    /** @return instance scoped for the {@code ref}, and its parent project. */
+    public ForRef ref(Branch.NameKey ref) {
+      return project(ref.getParentKey()).ref(ref.get()).database(db);
+    }
+
+    /** @return instance scoped for the change, and its destination ref and project. */
+    public ForChange change(ChangeData cd) {
+      try {
+        return ref(cd.change().getDest()).change(cd);
+      } catch (OrmException e) {
+        return FailedPermissionBackend.change("unavailable", e);
+      }
+    }
+
+    /** @return instance scoped for the change, and its destination ref and project. */
+    public ForChange change(ChangeNotes notes) {
+      return ref(notes.getChange().getDest()).change(notes);
+    }
+
+    /** Verify scoped user can {@code perm}, throwing if denied. */
+    public abstract void check(GlobalOrPluginPermission perm)
+        throws AuthException, PermissionBackendException;
+
+    /**
+     * Verify scoped user can perform at least one listed permission.
+     *
+     * <p>If {@code any} is empty, the method completes normally and allows the caller to continue.
+     * Since no permissions were supplied to check, its assumed no permissions are necessary to
+     * continue with the caller's operation.
+     *
+     * <p>If the user has at least one of the permissions in {@code any}, the method completes
+     * normally, possibly without checking all listed permissions.
+     *
+     * <p>If {@code any} is non-empty and the user has none, {@link AuthException} is thrown for one
+     * of the failed permissions.
+     *
+     * @param any set of permissions to check.
+     */
+    public void checkAny(Set<GlobalOrPluginPermission> any)
+        throws PermissionBackendException, AuthException {
+      for (Iterator<GlobalOrPluginPermission> itr = any.iterator(); itr.hasNext(); ) {
+        try {
+          check(itr.next());
+          return;
+        } catch (AuthException err) {
+          if (!itr.hasNext()) {
+            throw err;
+          }
+        }
+      }
+    }
+
+    /** Filter {@code permSet} to permissions scoped user might be able to perform. */
+    public abstract <T extends GlobalOrPluginPermission> Set<T> test(Collection<T> permSet)
+        throws PermissionBackendException;
+
+    public boolean test(GlobalOrPluginPermission perm) throws PermissionBackendException {
+      return test(Collections.singleton(perm)).contains(perm);
+    }
+
+    public boolean testOrFalse(GlobalOrPluginPermission perm) {
+      try {
+        return test(perm);
+      } catch (PermissionBackendException e) {
+        logger.warn("Cannot test " + perm + "; assuming false", e);
+        return false;
+      }
+    }
+
+    /**
+     * Filter a set of projects using {@code check(perm)}.
+     *
+     * @param perm required permission in a project to be included in result.
+     * @param projects candidate set of projects; may be empty.
+     * @return filtered set of {@code projects} where {@code check(perm)} was successful.
+     * @throws PermissionBackendException backend cannot access its internal state.
+     */
+    public Set<Project.NameKey> filter(ProjectPermission perm, Collection<Project.NameKey> projects)
+        throws PermissionBackendException {
+      checkNotNull(perm, "ProjectPermission");
+      checkNotNull(projects, "projects");
+      Set<Project.NameKey> allowed = Sets.newHashSetWithExpectedSize(projects.size());
+      for (Project.NameKey project : projects) {
+        try {
+          project(project).check(perm);
+          allowed.add(project);
+        } catch (AuthException e) {
+          // Do not include this project in allowed.
+        }
+      }
+      return allowed;
+    }
+  }
+
+  /** PermissionBackend scoped to a user and project. */
+  public abstract static class ForProject extends AcceptsReviewDb<ForProject> {
+    /** @return new instance rescoped to same project, but different {@code user}. */
+    public abstract ForProject user(CurrentUser user);
+
+    /** @return instance scoped for {@code ref} in this project. */
+    public abstract ForRef ref(String ref);
+
+    /** @return instance scoped for the change, and its destination ref and project. */
+    public ForChange change(ChangeData cd) {
+      try {
+        return ref(cd.change().getDest().get()).change(cd);
+      } catch (OrmException e) {
+        return FailedPermissionBackend.change("unavailable", e);
+      }
+    }
+
+    /** @return instance scoped for the change, and its destination ref and project. */
+    public ForChange change(ChangeNotes notes) {
+      return ref(notes.getChange().getDest().get()).change(notes);
+    }
+
+    /** Verify scoped user can {@code perm}, throwing if denied. */
+    public abstract void check(ProjectPermission perm)
+        throws AuthException, PermissionBackendException;
+
+    /** Filter {@code permSet} to permissions scoped user might be able to perform. */
+    public abstract Set<ProjectPermission> test(Collection<ProjectPermission> permSet)
+        throws PermissionBackendException;
+
+    public boolean test(ProjectPermission perm) throws PermissionBackendException {
+      return test(EnumSet.of(perm)).contains(perm);
+    }
+
+    public boolean testOrFalse(ProjectPermission perm) {
+      try {
+        return test(perm);
+      } catch (PermissionBackendException e) {
+        logger.warn("Cannot test " + perm + "; assuming false", e);
+        return false;
+      }
+    }
+  }
+
+  /** PermissionBackend scoped to a user, project and reference. */
+  public abstract static class ForRef extends AcceptsReviewDb<ForRef> {
+    /** @return new instance rescoped to same reference, but different {@code user}. */
+    public abstract ForRef user(CurrentUser user);
+
+    /** @return instance scoped to change. */
+    public abstract ForChange change(ChangeData cd);
+
+    /** @return instance scoped to change. */
+    public abstract ForChange change(ChangeNotes notes);
+
+    /** Verify scoped user can {@code perm}, throwing if denied. */
+    public abstract void check(RefPermission perm) throws AuthException, PermissionBackendException;
+
+    /** Filter {@code permSet} to permissions scoped user might be able to perform. */
+    public abstract Set<RefPermission> test(Collection<RefPermission> permSet)
+        throws PermissionBackendException;
+
+    public boolean test(RefPermission perm) throws PermissionBackendException {
+      return test(EnumSet.of(perm)).contains(perm);
+    }
+
+    /**
+     * Test if user may be able to perform the permission.
+     *
+     * <p>Similar to {@link #test(RefPermission)} except this method returns {@code false} instead
+     * of throwing an exception.
+     *
+     * @param perm the permission to test.
+     * @return true if the user might be able to perform the permission; false if the user may be
+     *     missing the necessary grants or state, or if the backend threw an exception.
+     */
+    public boolean testOrFalse(RefPermission perm) {
+      try {
+        return test(perm);
+      } catch (PermissionBackendException e) {
+        logger.warn("Cannot test " + perm + "; assuming false", e);
+        return false;
+      }
+    }
+  }
+
+  /** PermissionBackend scoped to a user, project, reference and change. */
+  public abstract static class ForChange extends AcceptsReviewDb<ForChange> {
+    /** @return user this instance is scoped to. */
+    public abstract CurrentUser user();
+
+    /** @return new instance rescoped to same change, but different {@code user}. */
+    public abstract ForChange user(CurrentUser user);
+
+    /** Verify scoped user can {@code perm}, throwing if denied. */
+    public abstract void check(ChangePermissionOrLabel perm)
+        throws AuthException, PermissionBackendException;
+
+    /** Filter {@code permSet} to permissions scoped user might be able to perform. */
+    public abstract <T extends ChangePermissionOrLabel> Set<T> test(Collection<T> permSet)
+        throws PermissionBackendException;
+
+    public boolean test(ChangePermissionOrLabel perm) throws PermissionBackendException {
+      return test(Collections.singleton(perm)).contains(perm);
+    }
+
+    /**
+     * Test if user may be able to perform the permission.
+     *
+     * <p>Similar to {@link #test(ChangePermissionOrLabel)} except this method returns {@code false}
+     * instead of throwing an exception.
+     *
+     * @param perm the permission to test.
+     * @return true if the user might be able to perform the permission; false if the user may be
+     *     missing the necessary grants or state, or if the backend threw an exception.
+     */
+    public boolean testOrFalse(ChangePermissionOrLabel perm) {
+      try {
+        return test(perm);
+      } catch (PermissionBackendException e) {
+        logger.warn("Cannot test " + perm + "; assuming false", e);
+        return false;
+      }
+    }
+
+    /**
+     * Test which values of a label the user may be able to set.
+     *
+     * @param label definition of the label to test values of.
+     * @return set containing values the user may be able to use; may be empty if none.
+     * @throws PermissionBackendException if failure consulting backend configuration.
+     */
+    public Set<LabelPermission.WithValue> test(LabelType label) throws PermissionBackendException {
+      return test(valuesOf(checkNotNull(label, "LabelType")));
+    }
+
+    /**
+     * Test which values of a group of labels the user may be able to set.
+     *
+     * @param types definition of the labels to test values of.
+     * @return set containing values the user may be able to use; may be empty if none.
+     * @throws PermissionBackendException if failure consulting backend configuration.
+     */
+    public Set<LabelPermission.WithValue> testLabels(Collection<LabelType> types)
+        throws PermissionBackendException {
+      checkNotNull(types, "LabelType");
+      return test(types.stream().flatMap((t) -> valuesOf(t).stream()).collect(toSet()));
+    }
+
+    private static Set<LabelPermission.WithValue> valuesOf(LabelType label) {
+      return label
+          .getValues()
+          .stream()
+          .map((v) -> new LabelPermission.WithValue(label, v))
+          .collect(toSet());
+    }
+
+    /**
+     * Squash a label value to the nearest allowed value.
+     *
+     * <p>For multi-valued labels like Code-Review with values -2..+2 a user may try to use +2, but
+     * only have permission for the -1..+1 range. The caller should have already tried:
+     *
+     * <pre>
+     * check(new LabelPermission.WithValue("Code-Review", 2));
+     * </pre>
+     *
+     * and caught {@link AuthException}. {@code squashThenCheck} will use {@link #test(LabelType)}
+     * to determine potential values of Code-Review the user can use, and select the nearest value
+     * along the same sign, e.g. -1 for -2 and +1 for +2.
+     *
+     * @param label definition of the label to test values of.
+     * @param val previously denied value the user attempted.
+     * @return nearest allowed value, or {@code 0} if no value was allowed.
+     * @throws PermissionBackendException backend cannot run test or check.
+     */
+    public short squashThenCheck(LabelType label, short val) throws PermissionBackendException {
+      short s = squashByTest(label, val);
+      if (s == 0 || s == val) {
+        return 0;
+      }
+      try {
+        check(new LabelPermission.WithValue(label, s));
+        return s;
+      } catch (AuthException e) {
+        return 0;
+      }
+    }
+
+    /**
+     * Squash a label value to the nearest allowed value using only test methods.
+     *
+     * <p>Tests all possible values and selects the closet available to {@code val} while matching
+     * the sign of {@code val}. Unlike {@code #squashThenCheck(LabelType, short)} this method only
+     * uses {@code test} methods and should not be used in contexts like a review handler without
+     * checking the resulting score.
+     *
+     * @param label definition of the label to test values of.
+     * @param val previously denied value the user attempted.
+     * @return nearest likely allowed value, or {@code 0} if no value was identified.
+     * @throws PermissionBackendException backend cannot run test.
+     */
+    public short squashByTest(LabelType label, short val) throws PermissionBackendException {
+      return nearest(test(label), val);
+    }
+
+    private static short nearest(Iterable<LabelPermission.WithValue> possible, short wanted) {
+      short s = 0;
+      for (LabelPermission.WithValue v : possible) {
+        if ((wanted < 0 && v.value() < 0 && wanted <= v.value() && v.value() < s)
+            || (wanted > 0 && v.value() > 0 && wanted >= v.value() && v.value() > s)) {
+          s = v.value();
+        }
+      }
+      return s;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackendException.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackendException.java
new file mode 100644
index 0000000..be02a6f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackendException.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2017 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.permissions;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.server.account.GroupBackend;
+
+/**
+ * Thrown when {@link PermissionBackend} cannot compute the result.
+ *
+ * <p>This is typically a transient failure, such as a required {@link GroupBackend} not responding
+ * to membership requests.
+ */
+public class PermissionBackendException extends Exception {
+  private static final long serialVersionUID = 1L;
+
+  public PermissionBackendException(String message) {
+    super(message);
+  }
+
+  public PermissionBackendException(@Nullable Throwable cause) {
+    super(cause);
+  }
+
+  public PermissionBackendException(String message, @Nullable Throwable cause) {
+    super(message, cause);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ProjectPermission.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ProjectPermission.java
new file mode 100644
index 0000000..85b66c4
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ProjectPermission.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2017 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.permissions;
+
+import com.google.gerrit.common.data.Permission;
+import java.util.Locale;
+import java.util.Optional;
+
+public enum ProjectPermission {
+  /**
+   * Can access at least one reference or change within the repository.
+   *
+   * <p>Checking this permission instead of {@link #READ} may require filtering to hide specific
+   * references or changes, which can be expensive.
+   */
+  ACCESS,
+
+  /**
+   * Can read all references in the repository.
+   *
+   * <p>This is a stronger form of {@link #ACCESS} where no filtering is required.
+   */
+  READ(Permission.READ);
+
+  private final String name;
+
+  ProjectPermission() {
+    name = null;
+  }
+
+  ProjectPermission(String name) {
+    this.name = name;
+  }
+
+  /** @return name used in {@code project.config} permissions. */
+  public Optional<String> permissionName() {
+    return Optional.ofNullable(name);
+  }
+
+  public String describeForException() {
+    return toString().toLowerCase(Locale.US).replace('_', ' ');
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/RefPermission.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/RefPermission.java
new file mode 100644
index 0000000..37744b0
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/RefPermission.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2017 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.permissions;
+
+import com.google.gerrit.common.data.Permission;
+import java.util.Locale;
+import java.util.Optional;
+
+public enum RefPermission {
+  READ(Permission.READ),
+  CREATE(Permission.CREATE),
+  DELETE(Permission.DELETE),
+  UPDATE(Permission.PUSH),
+  FORCE_UPDATE,
+
+  FORGE_AUTHOR(Permission.FORGE_AUTHOR),
+  FORGE_COMMITTER(Permission.FORGE_COMMITTER),
+  FORGE_SERVER(Permission.FORGE_SERVER),
+
+  CREATE_CHANGE;
+
+  private final String name;
+
+  RefPermission() {
+    name = null;
+  }
+
+  RefPermission(String name) {
+    this.name = name;
+  }
+
+  /** @return name used in {@code project.config} permissions. */
+  public Optional<String> permissionName() {
+    return Optional.ofNullable(name);
+  }
+
+  public String describeForException() {
+    return toString().toLowerCase(Locale.US).replace('_', ' ');
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DelegatingClassLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DelegatingClassLoader.java
new file mode 100644
index 0000000..daee9c7
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DelegatingClassLoader.java
@@ -0,0 +1,71 @@
+// 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.plugins;
+
+import com.google.common.io.ByteStreams;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.Enumeration;
+
+public class DelegatingClassLoader extends ClassLoader {
+  private final ClassLoader target;
+
+  public DelegatingClassLoader(ClassLoader parent, ClassLoader target) {
+    super(parent);
+    this.target = target;
+  }
+
+  @Override
+  public Class<?> findClass(String name) throws ClassNotFoundException {
+    String path = name.replace('.', '/') + ".class";
+    InputStream resource = target.getResourceAsStream(path);
+    if (resource != null) {
+      try {
+        byte[] bytes = ByteStreams.toByteArray(resource);
+        return defineClass(name, bytes, 0, bytes.length);
+      } catch (IOException e) {
+      }
+    }
+    throw new ClassNotFoundException(name);
+  }
+
+  @Override
+  public URL getResource(String name) {
+    URL rtn = getParent().getResource(name);
+    if (rtn == null) {
+      rtn = target.getResource(name);
+    }
+    return rtn;
+  }
+
+  @Override
+  public Enumeration<URL> getResources(String name) throws IOException {
+    Enumeration<URL> rtn = getParent().getResources(name);
+    if (rtn == null) {
+      rtn = target.getResources(name);
+    }
+    return rtn;
+  }
+
+  @Override
+  public InputStream getResourceAsStream(String name) {
+    InputStream rtn = getParent().getResourceAsStream(name);
+    if (rtn == null) {
+      rtn = target.getResourceAsStream(name);
+    }
+    return rtn;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
index ac57bc9..e0afc08 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
@@ -432,7 +432,7 @@
       String name = entry.getKey();
       Path path = entry.getValue();
       String fileName = path.getFileName().toString();
-      if (!isJsPlugin(fileName) && !serverPluginFactory.handles(path)) {
+      if (!isUiPlugin(fileName) && !serverPluginFactory.handles(path)) {
         log.warn("No Plugin provider was found that handles this file format: {}", fileName);
         continue;
       }
@@ -614,7 +614,7 @@
   private Plugin loadPlugin(String name, Path srcPlugin, FileSnapshot snapshot)
       throws InvalidPluginException {
     String pluginName = srcPlugin.getFileName().toString();
-    if (isJsPlugin(pluginName)) {
+    if (isUiPlugin(pluginName)) {
       return loadJsPlugin(name, srcPlugin, snapshot);
     } else if (serverPluginFactory.handles(srcPlugin)) {
       return loadServerPlugin(srcPlugin, snapshot);
@@ -746,8 +746,8 @@
 
   public String getGerritPluginName(Path srcPath) {
     String fileName = srcPath.getFileName().toString();
-    if (isJsPlugin(fileName)) {
-      return fileName.substring(0, fileName.length() - 3);
+    if (isUiPlugin(fileName)) {
+      return fileName.substring(0, fileName.lastIndexOf('.'));
     }
     if (serverPluginFactory.handles(srcPath)) {
       return serverPluginFactory.getPluginName(srcPath);
@@ -763,8 +763,8 @@
     return map;
   }
 
-  private static boolean isJsPlugin(String name) {
-    return isPlugin(name, "js");
+  private static boolean isUiPlugin(String name) {
+    return isPlugin(name, "js") || isPlugin(name, "html");
   }
 
   private static boolean isPlugin(String fileName, String ext) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/AccessControlModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/AccessControlModule.java
index 5c0d8d7..6d77267 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/AccessControlModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/AccessControlModule.java
@@ -46,8 +46,5 @@
         .annotatedWith(GitReceivePackGroups.class)
         .toProvider(GitReceivePackGroupsProvider.class)
         .in(SINGLETON);
-
-    bind(ChangeControl.Factory.class);
-    factory(ProjectControl.AssistedFactory.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
index ec114d8..2cb8d96 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
@@ -15,13 +15,18 @@
 package com.google.gerrit.server.project;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
 
 import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.RefConfigSection;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -32,13 +37,22 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.ChangePermissionOrLabel;
+import com.google.gerrit.server.permissions.LabelPermission;
+import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Collection;
+import java.util.EnumSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
 
 /** Access control management for a user accessing a single change. */
 public class ChangeControl {
@@ -200,14 +214,12 @@
 
   /** Can this user see this change? */
   public boolean isVisible(ReviewDb db, @Nullable ChangeData cd) throws OrmException {
+    if (getChange().isPrivate() && !isPrivateVisible(db, cd)) {
+      return false;
+    }
     if (getChange().getStatus() == Change.Status.DRAFT && !isDraftVisible(db, cd)) {
       return false;
     }
-    return isRefVisible();
-  }
-
-  /** Can the user see this change? Does not account for draft status */
-  public boolean isRefVisible() {
     return getRefControl().isVisible();
   }
 
@@ -230,21 +242,16 @@
   }
 
   /** Can this user abandon this change? */
-  public boolean canAbandon(ReviewDb db) throws OrmException {
+  private boolean canAbandon(ReviewDb db) throws OrmException {
     return (isOwner() // owner (aka creator) of the change can abandon
             || getRefControl().isOwner() // branch owner can abandon
             || getProjectControl().isOwner() // project owner can abandon
-            || getUser().getCapabilities().canAdministrateServer() // site administers are god
+            || getUser().getCapabilities().isAdmin_DoNotUse() // site administers are god
             || getRefControl().canAbandon() // user can abandon a specific ref
         )
         && !isPatchSetLocked(db);
   }
 
-  /** Can this user change the destination branch of this change to the new ref? */
-  public boolean canMoveTo(String ref, ReviewDb db) throws OrmException {
-    return getProjectControl().controlForRef(ref).canUpload() && canAbandon(db);
-  }
-
   /** Can this user publish this draft change or any draft patch set of this change? */
   public boolean canPublish(final ReviewDb db) throws OrmException {
     return (isOwner() || getRefControl().canPublishDrafts()) && isVisible(db);
@@ -258,10 +265,13 @@
 
     switch (status) {
       case DRAFT:
-        return (isOwner() || getRefControl().canDeleteDrafts());
+        return isOwner()
+            || getRefControl().canDeleteDrafts()
+            || getUser().getCapabilities().isAdmin_DoNotUse();
       case NEW:
       case ABANDONED:
-        return (isAdmin() || (isOwner() && getRefControl().canDeleteOwnChanges()));
+        return (isOwner() && getRefControl().canDeleteOwnChanges())
+            || getUser().getCapabilities().isAdmin_DoNotUse();
       case MERGED:
       default:
         return false;
@@ -269,13 +279,14 @@
   }
 
   /** Can this user rebase this change? */
-  public boolean canRebase(ReviewDb db) throws OrmException {
+  private boolean canRebase(ReviewDb db) throws OrmException {
     return (isOwner() || getRefControl().canSubmit(isOwner()) || getRefControl().canRebase())
+        && getRefControl().canUpload()
         && !isPatchSetLocked(db);
   }
 
   /** Can this user restore this change? */
-  public boolean canRestore(ReviewDb db) throws OrmException {
+  private boolean canRestore(ReviewDb db) throws OrmException {
     return canAbandon(db) // Anyone who can abandon the change can restore it back
         && getRefControl().canUpload(); // as long as you can upload too
   }
@@ -314,7 +325,7 @@
   }
 
   /** Can this user add a patch set to this change? */
-  public boolean canAddPatchSet(ReviewDb db) throws OrmException {
+  private boolean canAddPatchSet(ReviewDb db) throws OrmException {
     if (!getRefControl().canUpload()
         || isPatchSetLocked(db)
         || !isPatchVisible(patchSetUtil.current(db, notes), db)) {
@@ -345,7 +356,7 @@
   }
 
   /** Is this user the owner of the change? */
-  public boolean isOwner() {
+  private boolean isOwner() {
     if (getUser().isIdentifiedUser()) {
       Account.Id id = getUser().asIdentifiedUser().getAccountId();
       return id.equals(getChange().getOwner());
@@ -354,7 +365,7 @@
   }
 
   /** Is this user assigned to this change? */
-  public boolean isAssignee() {
+  private boolean isAssignee() {
     Account.Id currentAssignee = notes.getChange().getAssignee();
     if (currentAssignee != null && getUser().isIdentifiedUser()) {
       Account.Id id = getUser().getAccountId();
@@ -364,12 +375,7 @@
   }
 
   /** Is this user a reviewer for the change? */
-  public boolean isReviewer(ReviewDb db) throws OrmException {
-    return isReviewer(db, null);
-  }
-
-  /** Is this user a reviewer for the change? */
-  public boolean isReviewer(ReviewDb db, @Nullable ChangeData cd) throws OrmException {
+  private boolean isReviewer(ReviewDb db, @Nullable ChangeData cd) throws OrmException {
     if (getUser().isIdentifiedUser()) {
       Collection<Account.Id> results = changeData(db, cd).reviewers().all();
       return results.contains(getUser().getAccountId());
@@ -377,10 +383,6 @@
     return false;
   }
 
-  public boolean isAdmin() {
-    return getUser().getCapabilities().canAdministrateServer();
-  }
-
   /** @return true if the user is allowed to remove this reviewer. */
   public boolean canRemoveReviewer(PatchSetApproval approval) {
     return canRemoveReviewer(approval.getAccountId(), approval.getValue());
@@ -407,7 +409,7 @@
       if (getRefControl().canRemoveReviewer() // has removal permissions
           || getRefControl().isOwner() // branch owner
           || getProjectControl().isOwner() // project owner
-          || getUser().getCapabilities().canAdministrateServer()) {
+          || getUser().getCapabilities().isAdmin_DoNotUse()) {
         return true;
       }
     }
@@ -416,12 +418,12 @@
   }
 
   /** Can this user edit the topic name? */
-  public boolean canEditTopicName() {
+  private boolean canEditTopicName() {
     if (getChange().getStatus().isOpen()) {
       return isOwner() // owner (aka creator) of the change can edit topic
           || getRefControl().isOwner() // branch owner can edit topic
           || getProjectControl().isOwner() // project owner can edit topic
-          || getUser().getCapabilities().canAdministrateServer() // site administers are god
+          || getUser().getCapabilities().isAdmin_DoNotUse() // site administers are god
           || getRefControl().canEditTopicName() // user can edit topic on a specific ref
       ;
     }
@@ -429,18 +431,18 @@
   }
 
   /** Can this user edit the description? */
-  public boolean canEditDescription() {
+  private boolean canEditDescription() {
     if (getChange().getStatus().isOpen()) {
       return isOwner() // owner (aka creator) of the change can edit desc
           || getRefControl().isOwner() // branch owner can edit desc
           || getProjectControl().isOwner() // project owner can edit desc
-          || getUser().getCapabilities().canAdministrateServer() // site administers are god
+          || getUser().getCapabilities().isAdmin_DoNotUse() // site administers are god
       ;
     }
     return false;
   }
 
-  public boolean canEditAssignee() {
+  private boolean canEditAssignee() {
     return isOwner()
         || getProjectControl().isOwner()
         || getRefControl().canEditAssignee()
@@ -448,22 +450,14 @@
   }
 
   /** Can this user edit the hashtag name? */
-  public boolean canEditHashtags() {
+  private boolean canEditHashtags() {
     return isOwner() // owner (aka creator) of the change can edit hashtags
         || getRefControl().isOwner() // branch owner can edit hashtags
         || getProjectControl().isOwner() // project owner can edit hashtags
-        || getUser().getCapabilities().canAdministrateServer() // site administers are god
+        || getUser().getCapabilities().isAdmin_DoNotUse() // site administers are god
         || getRefControl().canEditHashtags(); // user can edit hashtag on a specific ref
   }
 
-  public boolean canSubmit() {
-    return getRefControl().canSubmit(isOwner());
-  }
-
-  public boolean canSubmitAs() {
-    return getRefControl().canSubmitAs();
-  }
-
   private boolean match(String destBranch, String refPattern) {
     return RefPatternMatcher.getMatcher(refPattern).match(destBranch, getUser());
   }
@@ -478,4 +472,155 @@
         || getRefControl().canViewDrafts()
         || getUser().isInternalUser();
   }
+
+  private boolean isPrivateVisible(ReviewDb db, ChangeData cd) throws OrmException {
+    return isOwner()
+        || isReviewer(db, cd)
+        || getRefControl().canViewPrivateChanges()
+        || getUser().isInternalUser();
+  }
+
+  ForChange asForChange(@Nullable ChangeData cd, @Nullable Provider<ReviewDb> db) {
+    return new ForChangeImpl(cd, db);
+  }
+
+  private class ForChangeImpl extends ForChange {
+    private ChangeData cd;
+    private Map<String, PermissionRange> labels;
+
+    ForChangeImpl(@Nullable ChangeData cd, @Nullable Provider<ReviewDb> db) {
+      this.cd = cd;
+      this.db = db;
+    }
+
+    private ReviewDb db() {
+      if (db != null) {
+        return db.get();
+      } else if (cd != null) {
+        return cd.db();
+      } else {
+        return null;
+      }
+    }
+
+    private ChangeData changeData() {
+      if (cd == null) {
+        ReviewDb reviewDb = db();
+        checkState(reviewDb != null, "need ReviewDb");
+        cd = changeDataFactory.create(reviewDb, ChangeControl.this);
+      }
+      return cd;
+    }
+
+    @Override
+    public CurrentUser user() {
+      return getUser();
+    }
+
+    @Override
+    public ForChange user(CurrentUser user) {
+      return user().equals(user) ? this : forUser(user).asForChange(cd, db);
+    }
+
+    @Override
+    public void check(ChangePermissionOrLabel perm)
+        throws AuthException, PermissionBackendException {
+      if (!can(perm)) {
+        throw new AuthException(perm.describeForException() + " not permitted");
+      }
+    }
+
+    @Override
+    public <T extends ChangePermissionOrLabel> Set<T> test(Collection<T> permSet)
+        throws PermissionBackendException {
+      Set<T> ok = newSet(permSet);
+      for (T perm : permSet) {
+        if (can(perm)) {
+          ok.add(perm);
+        }
+      }
+      return ok;
+    }
+
+    private boolean can(ChangePermissionOrLabel perm) throws PermissionBackendException {
+      if (perm instanceof ChangePermission) {
+        return can((ChangePermission) perm);
+      } else if (perm instanceof LabelPermission) {
+        return can((LabelPermission) perm);
+      } else if (perm instanceof LabelPermission.WithValue) {
+        return can((LabelPermission.WithValue) perm);
+      }
+      throw new PermissionBackendException(perm + " unsupported");
+    }
+
+    private boolean can(ChangePermission perm) throws PermissionBackendException {
+      try {
+        switch (perm) {
+          case READ:
+            return isVisible(db(), changeData());
+          case ABANDON:
+            return canAbandon(db());
+          case DELETE:
+            return canDelete(db(), getChange().getStatus());
+          case ADD_PATCH_SET:
+            return canAddPatchSet(db());
+          case EDIT_ASSIGNEE:
+            return canEditAssignee();
+          case EDIT_DESCRIPTION:
+            return canEditDescription();
+          case EDIT_HASHTAGS:
+            return canEditHashtags();
+          case EDIT_TOPIC_NAME:
+            return canEditTopicName();
+          case REBASE:
+            return canRebase(db());
+          case RESTORE:
+            return canRestore(db());
+          case SUBMIT:
+            return getRefControl().canSubmit(isOwner());
+
+          case REMOVE_REVIEWER: // TODO Honor specific removal filters?
+          case SUBMIT_AS:
+            return getRefControl().canPerform(perm.permissionName().get());
+        }
+      } catch (OrmException e) {
+        throw new PermissionBackendException("unavailable", e);
+      }
+      throw new PermissionBackendException(perm + " unsupported");
+    }
+
+    private boolean can(LabelPermission perm) {
+      return !label(perm.permissionName().get()).isEmpty();
+    }
+
+    private boolean can(LabelPermission.WithValue perm) {
+      PermissionRange r = label(perm.permissionName().get());
+      if (perm.forUser() == ON_BEHALF_OF && r.isEmpty()) {
+        return false;
+      }
+      return r.contains(perm.value());
+    }
+
+    private PermissionRange label(String permission) {
+      if (labels == null) {
+        labels = Maps.newHashMapWithExpectedSize(4);
+      }
+      PermissionRange r = labels.get(permission);
+      if (r == null) {
+        r = getRange(permission);
+        labels.put(permission, r);
+      }
+      return r;
+    }
+  }
+
+  static <T extends ChangePermissionOrLabel> Set<T> newSet(Collection<T> permSet) {
+    if (permSet instanceof EnumSet) {
+      @SuppressWarnings({"unchecked", "rawtypes"})
+      Set<T> s = ((EnumSet) permSet).clone();
+      s.clear();
+      return s;
+    }
+    return Sets.newHashSetWithExpectedSize(permSet.size());
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckMergeability.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckMergeability.java
index f824f81..1a81726 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckMergeability.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckMergeability.java
@@ -92,7 +92,7 @@
     try (Repository git = gitManager.openRepository(resource.getNameKey());
         RevWalk rw = new RevWalk(git);
         ObjectInserter inserter = new InMemoryInserter(git)) {
-      Merger m = MergeUtil.newMerger(git, inserter, strategy);
+      Merger m = MergeUtil.newMerger(inserter, git.getConfig(), strategy);
 
       Ref destRef = git.getRefDatabase().exactRef(resource.getRef());
       if (destRef == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectsCollection.java
index 7aa5f68..2110034 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectsCollection.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -50,7 +51,7 @@
 
   @Override
   public ChildProjectResource parse(ProjectResource parent, IdString id)
-      throws ResourceNotFoundException, IOException {
+      throws ResourceNotFoundException, IOException, PermissionBackendException {
     ProjectResource p = projectsCollection.parse(TopLevelResource.INSTANCE, id);
     for (ProjectState pp : p.getControl().getProjectState().parents()) {
       if (parent.getNameKey().equals(pp.getProject().getNameKey())) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java
index 2f02728..4f83d5f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java
@@ -31,7 +31,6 @@
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.extensions.webui.UiActions;
 import com.google.gerrit.server.git.TransferConfig;
-import com.google.inject.util.Providers;
 import java.util.Arrays;
 import java.util.LinkedHashMap;
 import java.util.Map;
@@ -45,6 +44,7 @@
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
+      UiActions uiActions,
       DynamicMap<RestView<ProjectResource>> views) {
     ProjectState projectState = control.getProjectState();
     Project p = control.getProject();
@@ -58,6 +58,7 @@
     InheritedBooleanInfo enableSignedPush = new InheritedBooleanInfo();
     InheritedBooleanInfo requireSignedPush = new InheritedBooleanInfo();
     InheritedBooleanInfo rejectImplicitMerges = new InheritedBooleanInfo();
+    InheritedBooleanInfo enableReviewerByEmail = new InheritedBooleanInfo();
 
     useContributorAgreements.value = projectState.isUseContributorAgreements();
     useSignedOffBy.value = projectState.isUseSignedOffBy();
@@ -73,6 +74,7 @@
     enableSignedPush.configuredValue = p.getEnableSignedPush();
     requireSignedPush.configuredValue = p.getRequireSignedPush();
     rejectImplicitMerges.configuredValue = p.getRejectImplicitMerges();
+    enableReviewerByEmail.configuredValue = p.getEnableReviewerByEmail();
 
     ProjectState parentState = Iterables.getFirst(projectState.parents(), null);
     if (parentState != null) {
@@ -85,6 +87,7 @@
       enableSignedPush.inheritedValue = projectState.isEnableSignedPush();
       requireSignedPush.inheritedValue = projectState.isRequireSignedPush();
       rejectImplicitMerges.inheritedValue = projectState.isRejectImplicitMerges();
+      enableReviewerByEmail.inheritedValue = projectState.isEnableReviewerByEmail();
     }
 
     this.useContributorAgreements = useContributorAgreements;
@@ -93,6 +96,7 @@
     this.requireChangeId = requireChangeId;
     this.rejectImplicitMerges = rejectImplicitMerges;
     this.createNewChangeForAllNotInTarget = createNewChangeForAllNotInTarget;
+    this.enableReviewerByEmail = enableReviewerByEmail;
     if (serverEnableSignedPush) {
       this.enableSignedPush = enableSignedPush;
       this.requireSignedPush = requireSignedPush;
@@ -122,11 +126,12 @@
         getPluginConfig(control.getProjectState(), pluginConfigEntries, cfgFactory, allProjects);
 
     actions = new TreeMap<>();
-    for (UiAction.Description d :
-        UiActions.from(views, new ProjectResource(control), Providers.of(control.getUser()))) {
+    for (UiAction.Description d : uiActions.from(views, new ProjectResource(control))) {
       actions.put(d.getId(), new ActionInfo(d));
     }
     this.theme = projectState.getTheme();
+
+    this.extensionPanelNames = projectState.getConfig().getExtensionPanelSections();
   }
 
   private Map<String, Map<String, ConfigParameterInfo>> getPluginConfig(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateBranch.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateBranch.java
index 5919ba1..0598d75 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateBranch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateBranch.java
@@ -26,6 +26,8 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -50,6 +52,7 @@
   }
 
   private final Provider<IdentifiedUser> identifiedUser;
+  private final PermissionBackend permissionBackend;
   private final GitRepositoryManager repoManager;
   private final Provider<ReviewDb> db;
   private final GitReferenceUpdated referenceUpdated;
@@ -59,12 +62,14 @@
   @Inject
   CreateBranch(
       Provider<IdentifiedUser> identifiedUser,
+      PermissionBackend permissionBackend,
       GitRepositoryManager repoManager,
       Provider<ReviewDb> db,
       GitReferenceUpdated referenceUpdated,
       RefValidationHelper.Factory refHelperFactory,
       @Assisted String ref) {
     this.identifiedUser = identifiedUser;
+    this.permissionBackend = permissionBackend;
     this.repoManager = repoManager;
     this.db = db;
     this.referenceUpdated = referenceUpdated;
@@ -170,7 +175,10 @@
         BranchInfo info = new BranchInfo();
         info.ref = ref;
         info.revision = revid.getName();
-        info.canDelete = refControl.canDelete() ? true : null;
+        info.canDelete =
+            permissionBackend.user(identifiedUser).ref(name).testOrFalse(RefPermission.DELETE)
+                ? true
+                : null;
         return info;
       } catch (IOException err) {
         log.error("Cannot create branch \"" + name + "\"", err);
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 ff7e31e..199f5c0 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
@@ -55,6 +55,7 @@
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.git.RepositoryCaseMismatchException;
 import com.google.gerrit.server.group.GroupsCollection;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
@@ -148,7 +149,8 @@
   @Override
   public Response<ProjectInfo> apply(TopLevelResource resource, ProjectInput input)
       throws BadRequestException, UnprocessableEntityException, ResourceConflictException,
-          ResourceNotFoundException, IOException, ConfigInvalidException {
+          ResourceNotFoundException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     if (input == null) {
       input = new ProjectInput();
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateTag.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateTag.java
index f674d17..6b1c0e9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateTag.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateTag.java
@@ -31,6 +31,9 @@
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.TagCache;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.RefUtil.InvalidRevisionException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -56,6 +59,7 @@
     CreateTag create(String ref);
   }
 
+  private final PermissionBackend permissionBackend;
   private final Provider<IdentifiedUser> identifiedUser;
   private final GitRepositoryManager repoManager;
   private final TagCache tagCache;
@@ -64,11 +68,13 @@
 
   @Inject
   CreateTag(
+      PermissionBackend permissionBackend,
       Provider<IdentifiedUser> identifiedUser,
       GitRepositoryManager repoManager,
       TagCache tagCache,
       GitReferenceUpdated referenceUpdated,
       @Assisted String ref) {
+    this.permissionBackend = permissionBackend;
     this.identifiedUser = identifiedUser;
     this.repoManager = repoManager;
     this.tagCache = tagCache;
@@ -78,7 +84,7 @@
 
   @Override
   public TagInfo apply(ProjectResource resource, TagInput input)
-      throws RestApiException, IOException {
+      throws RestApiException, IOException, PermissionBackendException {
     if (input == null) {
       input = new TagInput();
     }
@@ -92,6 +98,9 @@
     ref = RefUtil.normalizeTagRef(ref);
 
     RefControl refControl = resource.getControl().controlForRef(ref);
+    PermissionBackend.ForRef perm =
+        permissionBackend.user(identifiedUser).project(resource.getNameKey()).ref(ref);
+
     try (Repository repo = repoManager.openRepository(resource.getNameKey())) {
       ObjectId revid = RefUtil.parseBaseRevision(repo, resource.getNameKey(), input.revision);
       RevWalk rw = RefUtil.verifyConnected(repo, revid);
@@ -103,8 +112,8 @@
         throw new MethodNotAllowedException("Cannot create signed tag \"" + ref + "\"");
       } else if (isAnnotated && !refControl.canPerform(Permission.CREATE_TAG)) {
         throw new AuthException("Cannot create annotated tag \"" + ref + "\"");
-      } else if (!refControl.canPerform(Permission.CREATE)) {
-        throw new AuthException("Cannot create tag \"" + ref + "\"");
+      } else {
+        perm.check(RefPermission.CREATE);
       }
       if (repo.getRefDatabase().exactRef(ref) != null) {
         throw new ResourceConflictException("tag \"" + ref + "\" already exists");
@@ -134,7 +143,7 @@
             result.getObjectId(),
             identifiedUser.get().getAccount());
         try (RevWalk w = new RevWalk(repo)) {
-          return ListTags.createTagInfo(result, w, refControl);
+          return ListTags.createTagInfo(perm, result, w);
         }
       }
     } catch (InvalidRevisionException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DefaultPermissionBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DefaultPermissionBackend.java
new file mode 100644
index 0000000..07be8fe
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DefaultPermissionBackend.java
@@ -0,0 +1,102 @@
+// Copyright (C) 2017 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.project;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.FailedPermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.Set;
+
+@Singleton
+public class DefaultPermissionBackend extends PermissionBackend {
+  private final ProjectCache projectCache;
+
+  @Inject
+  DefaultPermissionBackend(ProjectCache projectCache) {
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  public WithUser user(CurrentUser user) {
+    return new WithUserImpl(checkNotNull(user, "user"));
+  }
+
+  class WithUserImpl extends WithUser {
+    private final CurrentUser user;
+
+    WithUserImpl(CurrentUser user) {
+      this.user = checkNotNull(user, "user");
+    }
+
+    @Override
+    public ForProject project(Project.NameKey project) {
+      try {
+        ProjectState state = projectCache.checkedGet(project);
+        if (state != null) {
+          return state.controlFor(user).asForProject().database(db);
+        }
+        return FailedPermissionBackend.project("not found");
+      } catch (IOException e) {
+        return FailedPermissionBackend.project("unavailable", e);
+      }
+    }
+
+    @Override
+    public void check(GlobalOrPluginPermission perm)
+        throws AuthException, PermissionBackendException {
+      if (!can(perm)) {
+        throw new AuthException(perm.describeForException() + " not permitted");
+      }
+    }
+
+    @Override
+    public <T extends GlobalOrPluginPermission> Set<T> test(Collection<T> permSet)
+        throws PermissionBackendException {
+      Set<T> ok = newSet(permSet);
+      for (T perm : permSet) {
+        if (can(perm)) {
+          ok.add(perm);
+        }
+      }
+      return ok;
+    }
+
+    private boolean can(GlobalOrPluginPermission perm) throws PermissionBackendException {
+      return user.getCapabilities().doCanForDefaultPermissionBackend(perm);
+    }
+  }
+
+  private static <T extends GlobalOrPluginPermission> Set<T> newSet(Collection<T> permSet) {
+    if (permSet instanceof EnumSet) {
+      @SuppressWarnings({"unchecked", "rawtypes"})
+      Set<T> s = ((EnumSet) permSet).clone();
+      s.clear();
+      return s;
+    }
+    return Sets.newHashSetWithExpectedSize(permSet.size());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DefaultPermissionBackendModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DefaultPermissionBackendModule.java
new file mode 100644
index 0000000..4916353
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DefaultPermissionBackendModule.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2017 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.project;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.inject.AbstractModule;
+import com.google.inject.Scopes;
+
+/** Binds the default {@link PermissionBackend}. */
+public class DefaultPermissionBackendModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    bind(PermissionBackend.class).to(DefaultPermissionBackend.class).in(Scopes.SINGLETON);
+    install(new LegacyControlsModule());
+  }
+
+  /** Binds legacy ProjectControl, RefControl, ChangeControl. */
+  public static class LegacyControlsModule extends FactoryModule {
+    @Override
+    protected void configure() {
+      // TODO(sop) Hide ProjectControl, RefControl, ChangeControl related bindings.
+      bind(ProjectControl.GenericFactory.class);
+      factory(ProjectControl.AssistedFactory.class);
+      bind(ChangeControl.GenericFactory.class);
+      bind(ChangeControl.Factory.class);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranch.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranch.java
index 4601bfe..2fad19b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranch.java
@@ -14,11 +14,14 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.DeleteBranch.Input;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
@@ -33,19 +36,25 @@
 
   private final Provider<InternalChangeQuery> queryProvider;
   private final DeleteRef.Factory deleteRefFactory;
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
 
   @Inject
-  DeleteBranch(Provider<InternalChangeQuery> queryProvider, DeleteRef.Factory deleteRefFactory) {
+  DeleteBranch(
+      Provider<InternalChangeQuery> queryProvider,
+      DeleteRef.Factory deleteRefFactory,
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend) {
     this.queryProvider = queryProvider;
     this.deleteRefFactory = deleteRefFactory;
+    this.user = user;
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
   public Response<?> apply(BranchResource rsrc, Input input)
-      throws RestApiException, OrmException, IOException {
-    if (!rsrc.getControl().controlForRef(rsrc.getBranchKey()).canDelete()) {
-      throw new AuthException("Cannot delete branch");
-    }
+      throws RestApiException, OrmException, IOException, PermissionBackendException {
+    permissionBackend.user(user).ref(rsrc.getBranchKey()).check(RefPermission.DELETE);
 
     if (!queryProvider.get().setLimit(1).byBranchOpen(rsrc.getBranchKey()).isEmpty()) {
       throw new ResourceConflictException("branch " + rsrc.getBranchKey() + " has open changes");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranches.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranches.java
index f5e55b1..4b45a41 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranches.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranches.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -35,12 +36,10 @@
 
   @Override
   public Response<?> apply(ProjectResource project, DeleteBranchesInput input)
-      throws OrmException, IOException, RestApiException {
-
+      throws OrmException, IOException, RestApiException, PermissionBackendException {
     if (input == null || input.branches == null || input.branches.isEmpty()) {
       throw new BadRequestException("branches must be specified");
     }
-
     deleteRefFactory.create(project).refs(input.branches).delete();
     return Response.none();
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteRef.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteRef.java
index 1fadef6..9b6538c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteRef.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteRef.java
@@ -19,16 +19,20 @@
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 import static org.eclipse.jgit.transport.ReceiveCommand.Type.DELETE;
 
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
@@ -52,6 +56,7 @@
   private static final long SLEEP_ON_LOCK_FAILURE_MS = 15;
 
   private final Provider<IdentifiedUser> identifiedUser;
+  private final PermissionBackend permissionBackend;
   private final GitRepositoryManager repoManager;
   private final GitReferenceUpdated referenceUpdated;
   private final RefValidationHelper refDeletionValidator;
@@ -64,15 +69,17 @@
     DeleteRef create(ProjectResource r);
   }
 
-  @AssistedInject
+  @Inject
   DeleteRef(
       Provider<IdentifiedUser> identifiedUser,
+      PermissionBackend permissionBackend,
       GitRepositoryManager repoManager,
       GitReferenceUpdated referenceUpdated,
       RefValidationHelper.Factory refDeletionValidatorFactory,
       Provider<InternalChangeQuery> queryProvider,
       @Assisted ProjectResource resource) {
     this.identifiedUser = identifiedUser;
+    this.permissionBackend = permissionBackend;
     this.repoManager = repoManager;
     this.referenceUpdated = referenceUpdated;
     this.refDeletionValidator = refDeletionValidatorFactory.create(DELETE);
@@ -96,7 +103,8 @@
     return this;
   }
 
-  public void delete() throws OrmException, IOException, ResourceConflictException {
+  public void delete()
+      throws OrmException, IOException, ResourceConflictException, PermissionBackendException {
     if (!refsToDelete.isEmpty()) {
       try (Repository r = repoManager.openRepository(resource.getNameKey())) {
         if (refsToDelete.size() == 1) {
@@ -168,8 +176,9 @@
   }
 
   private void deleteMultipleRefs(Repository r)
-      throws OrmException, IOException, ResourceConflictException {
+      throws OrmException, IOException, ResourceConflictException, PermissionBackendException {
     BatchRefUpdate batchUpdate = r.getRefDatabase().newBatchUpdate();
+    batchUpdate.setAtomic(false);
     List<String> refs =
         prefix == null
             ? refsToDelete
@@ -197,7 +206,7 @@
   }
 
   private ReceiveCommand createDeleteCommand(ProjectResource project, Repository r, String refName)
-      throws OrmException, IOException, ResourceConflictException {
+      throws OrmException, IOException, ResourceConflictException, PermissionBackendException {
     Ref ref = r.getRefDatabase().getRef(refName);
     ReceiveCommand command;
     if (ref == null) {
@@ -209,7 +218,13 @@
     }
     command = new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), ref.getName());
 
-    if (!project.getControl().controlForRef(refName).canDelete()) {
+    try {
+      permissionBackend
+          .user(identifiedUser)
+          .project(project.getNameKey())
+          .ref(refName)
+          .check(RefPermission.DELETE);
+    } catch (AuthException denied) {
       command.setResult(
           Result.REJECTED_OTHER_REASON,
           "it doesn't exist or you do not have permission to delete it");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTag.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTag.java
index f26d40f..a05fa2e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTag.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTag.java
@@ -14,36 +14,46 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 
 @Singleton
 public class DeleteTag implements RestModifyView<TagResource, DeleteTag.Input> {
-  private final DeleteRef.Factory deleteRefFactory;
-
   public static class Input {}
 
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+  private final DeleteRef.Factory deleteRefFactory;
+
   @Inject
-  DeleteTag(DeleteRef.Factory deleteRefFactory) {
+  DeleteTag(
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      DeleteRef.Factory deleteRefFactory) {
+    this.permissionBackend = permissionBackend;
+    this.user = user;
     this.deleteRefFactory = deleteRefFactory;
   }
 
   @Override
   public Response<?> apply(TagResource resource, Input input)
-      throws OrmException, RestApiException, IOException {
+      throws OrmException, RestApiException, IOException, PermissionBackendException {
     String tag = RefUtil.normalizeTagRef(resource.getTagInfo().ref);
-    RefControl refControl = resource.getControl().controlForRef(tag);
-
-    if (!refControl.canDelete()) {
-      throw new AuthException("Cannot delete tag");
-    }
-
+    permissionBackend
+        .user(user)
+        .project(resource.getNameKey())
+        .ref(tag)
+        .check(RefPermission.DELETE);
     deleteRefFactory.create(resource).ref(tag).delete();
     return Response.none();
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTags.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTags.java
index 75cf03f..c020351 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTags.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTags.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -37,12 +38,10 @@
 
   @Override
   public Response<?> apply(ProjectResource project, DeleteTagsInput input)
-      throws OrmException, RestApiException, IOException {
-
+      throws OrmException, RestApiException, IOException, PermissionBackendException {
     if (input == null || input.tags == null || input.tags.isEmpty()) {
       throw new BadRequestException("tags must be specified");
     }
-
     deleteRefFactory.create(project).refs(input.tags).prefix(R_TAGS).delete();
     return Response.none();
   }
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 b464f68..997239d 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
@@ -95,7 +95,7 @@
   public ProjectAccessInfo apply(Project.NameKey nameKey)
       throws ResourceNotFoundException, ResourceConflictException, IOException {
     try {
-      return this.apply(new ProjectResource(projectControlFactory.controlFor(nameKey, self.get())));
+      return apply(new ProjectResource(projectControlFactory.controlFor(nameKey, self.get())));
     } catch (NoSuchProjectException e) {
       throw new ResourceNotFoundException(nameKey.get());
     }
@@ -111,7 +111,7 @@
     Project.NameKey projectName = rsrc.getNameKey();
     ProjectAccessInfo info = new ProjectAccessInfo();
     ProjectConfig config;
-    ProjectControl pc = open(projectName);
+    ProjectControl pc = createProjectControl(projectName);
     RefControl metaConfigControl = pc.controlForRef(RefNames.REFS_CONFIG);
     try (MetaDataUpdate md = metaDataUpdateFactory.create(projectName)) {
       config = ProjectConfig.read(md);
@@ -120,11 +120,11 @@
         md.setMessage("Update group names\n");
         config.commit(md);
         projectCache.evict(config.getProject());
-        pc = open(projectName);
+        pc = createProjectControl(projectName);
       } else if (config.getRevision() != null
           && !config.getRevision().equals(pc.getProjectState().getConfig().getRevision())) {
         projectCache.evict(config.getProject());
-        pc = open(projectName);
+        pc = createProjectControl(projectName);
       }
     } catch (ConfigInvalidException e) {
       throw new ResourceConflictException(e.getMessage());
@@ -252,11 +252,10 @@
     return accessSectionInfo;
   }
 
-  private ProjectControl open(Project.NameKey projectName)
-      throws ResourceNotFoundException, IOException {
+  private ProjectControl createProjectControl(Project.NameKey projectName)
+      throws IOException, ResourceNotFoundException {
     try {
-      return projectControlFactory.validateFor(
-          projectName, ProjectControl.OWNER | ProjectControl.VISIBLE, self.get());
+      return projectControlFactory.controlFor(projectName, self.get());
     } catch (NoSuchProjectException e) {
       throw new ResourceNotFoundException(projectName.get());
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java
index 8192e29..b1ba281 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
+import com.google.gerrit.server.extensions.webui.UiActions;
 import com.google.gerrit.server.git.TransferConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -33,6 +34,7 @@
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
   private final PluginConfigFactory cfgFactory;
   private final AllProjectsName allProjects;
+  private final UiActions uiActions;
   private final DynamicMap<RestView<ProjectResource>> views;
 
   @Inject
@@ -42,12 +44,14 @@
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
+      UiActions uiActions,
       DynamicMap<RestView<ProjectResource>> views) {
     this.serverEnableSignedPush = serverEnableSignedPush;
     this.config = config;
     this.pluginConfigEntries = pluginConfigEntries;
     this.allProjects = allProjects;
     this.cfgFactory = cfgFactory;
+    this.uiActions = uiActions;
     this.views = views;
   }
 
@@ -60,6 +64,7 @@
         pluginConfigEntries,
         cfgFactory,
         allProjects,
+        uiActions,
         views);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetContent.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetContent.java
index 10da990f..387c966 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetContent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetContent.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -32,8 +33,9 @@
   }
 
   @Override
-  public BinaryResult apply(FileResource rsrc) throws ResourceNotFoundException, IOException {
+  public BinaryResult apply(FileResource rsrc)
+      throws ResourceNotFoundException, BadRequestException, IOException {
     return fileContentUtil.getContent(
-        rsrc.getProject().getProjectState(), rsrc.getRev(), rsrc.getPath());
+        rsrc.getProject().getProjectState(), rsrc.getRev(), rsrc.getPath(), null);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListBranches.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListBranches.java
index a5b6458..ecb3ea6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListBranches.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListBranches.java
@@ -26,11 +26,14 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.extensions.webui.UiActions;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.inject.Inject;
-import com.google.inject.util.Providers;
+import com.google.inject.Provider;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -47,7 +50,10 @@
 
 public class ListBranches implements RestReadView<ProjectResource> {
   private final GitRepositoryManager repoManager;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
   private final DynamicMap<RestView<BranchResource>> branchViews;
+  private final UiActions uiActions;
   private final WebLinks webLinks;
 
   @Option(
@@ -98,10 +104,16 @@
   @Inject
   public ListBranches(
       GitRepositoryManager repoManager,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
       DynamicMap<RestView<BranchResource>> branchViews,
+      UiActions uiActions,
       WebLinks webLinks) {
     this.repoManager = repoManager;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
     this.branchViews = branchViews;
+    this.uiActions = uiActions;
     this.webLinks = webLinks;
   }
 
@@ -138,6 +150,8 @@
       }
     }
 
+    ProjectControl pctl = rsrc.getControl();
+    PermissionBackend.ForProject perm = permissionBackend.user(user).project(rsrc.getNameKey());
     List<BranchInfo> branches = new ArrayList<>(refs.size());
     for (Ref ref : refs) {
       if (ref.isSymbolic()) {
@@ -145,7 +159,7 @@
         // showing the resolved value, show the name it references.
         //
         String target = ref.getTarget().getName();
-        RefControl targetRefControl = rsrc.getControl().controlForRef(target);
+        RefControl targetRefControl = pctl.controlForRef(target);
         if (!targetRefControl.isVisible()) {
           continue;
         }
@@ -159,14 +173,13 @@
         branches.add(b);
 
         if (!Constants.HEAD.equals(ref.getName())) {
-          b.canDelete = targetRefControl.canDelete() ? true : null;
+          b.canDelete = perm.ref(ref.getName()).testOrFalse(RefPermission.DELETE) ? true : null;
         }
         continue;
       }
 
-      RefControl refControl = rsrc.getControl().controlForRef(ref.getName());
-      if (refControl.isVisible()) {
-        branches.add(createBranchInfo(ref, refControl, targets));
+      if (pctl.controlForRef(ref.getName()).isVisible()) {
+        branches.add(createBranchInfo(perm.ref(ref.getName()), ref, pctl, targets));
       }
     }
     Collections.sort(branches, new BranchComparator());
@@ -192,24 +205,23 @@
     }
   }
 
-  private BranchInfo createBranchInfo(Ref ref, RefControl refControl, Set<String> targets) {
+  private BranchInfo createBranchInfo(
+      PermissionBackend.ForRef perm, Ref ref, ProjectControl pctl, Set<String> targets) {
     BranchInfo info = new BranchInfo();
     info.ref = ref.getName();
     info.revision = ref.getObjectId() != null ? ref.getObjectId().name() : null;
-    info.canDelete = !targets.contains(ref.getName()) && refControl.canDelete() ? true : null;
-    for (UiAction.Description d :
-        UiActions.from(
-            branchViews,
-            new BranchResource(refControl.getProjectControl(), info),
-            Providers.of(refControl.getUser()))) {
+    info.canDelete =
+        !targets.contains(ref.getName()) && perm.testOrFalse(RefPermission.DELETE) ? true : null;
+
+    BranchResource rsrc = new BranchResource(pctl, info);
+    for (UiAction.Description d : uiActions.from(branchViews, rsrc)) {
       if (info.actions == null) {
         info.actions = new TreeMap<>();
       }
       info.actions.put(d.getId(), new ActionInfo(d));
     }
-    List<WebLinkInfo> links =
-        webLinks.getBranchLinks(
-            refControl.getProjectControl().getProject().getName(), ref.getName());
+
+    List<WebLinkInfo> links = webLinks.getBranchLinks(pctl.getProject().getName(), ref.getName());
     info.webLinks = links.isEmpty() ? null : links;
     return info;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListChildProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListChildProjects.java
index c14ade6..23a4417 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListChildProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListChildProjects.java
@@ -14,14 +14,21 @@
 
 package com.google.gerrit.server.project;
 
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.Multimap;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -33,20 +40,23 @@
   private boolean recursive;
 
   private final ProjectCache projectCache;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
   private final AllProjectsName allProjects;
   private final ProjectJson json;
-  private final ProjectNode.Factory projectNodeFactory;
 
   @Inject
   ListChildProjects(
       ProjectCache projectCache,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
       AllProjectsName allProjectsName,
-      ProjectJson json,
-      ProjectNode.Factory projectNodeFactory) {
+      ProjectJson json) {
     this.projectCache = projectCache;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
     this.allProjects = allProjectsName;
     this.json = json;
-    this.projectNodeFactory = projectNodeFactory;
   }
 
   public void setRecursive(boolean recursive) {
@@ -54,60 +64,82 @@
   }
 
   @Override
-  public List<ProjectInfo> apply(ProjectResource rsrc) {
+  public List<ProjectInfo> apply(ProjectResource rsrc) throws PermissionBackendException {
     if (recursive) {
-      return getChildProjectsRecursively(rsrc.getNameKey(), rsrc.getControl().getUser());
+      return recursiveChildProjects(rsrc.getNameKey());
     }
-    return getDirectChildProjects(rsrc.getNameKey());
+    return directChildProjects(rsrc.getNameKey());
   }
 
-  private List<ProjectInfo> getDirectChildProjects(Project.NameKey parent) {
-    List<ProjectInfo> childProjects = new ArrayList<>();
-    for (Project.NameKey projectName : projectCache.all()) {
-      ProjectState e = projectCache.get(projectName);
-      if (e == null) {
-        // If we can't get it from the cache, pretend it's not present.
-        continue;
-      }
-      if (parent.equals(e.getProject().getParent(allProjects))) {
-        childProjects.add(json.format(e.getProject()));
-      }
-    }
-    return childProjects;
-  }
-
-  private List<ProjectInfo> getChildProjectsRecursively(Project.NameKey parent, CurrentUser user) {
-    Map<Project.NameKey, ProjectNode> projects = new HashMap<>();
+  private List<ProjectInfo> directChildProjects(Project.NameKey parent)
+      throws PermissionBackendException {
+    Map<Project.NameKey, Project> children = new HashMap<>();
     for (Project.NameKey name : projectCache.all()) {
-      ProjectState p = projectCache.get(name);
-      if (p == null) {
-        // If we can't get it from the cache, pretend it's not present.
-        continue;
-      }
-      projects.put(name, projectNodeFactory.create(p.getProject(), p.controlFor(user).isVisible()));
-    }
-    for (ProjectNode key : projects.values()) {
-      ProjectNode node = projects.get(key.getParentName());
-      if (node != null) {
-        node.addChild(key);
+      ProjectState c = projectCache.get(name);
+      if (c != null && parent.equals(c.getProject().getParent(allProjects))) {
+        children.put(c.getProject().getNameKey(), c.getProject());
       }
     }
-
-    ProjectNode n = projects.get(parent);
-    if (n != null) {
-      return getChildProjectsRecursively(n);
-    }
-    return Collections.emptyList();
+    return permissionBackend
+        .user(user)
+        .filter(ProjectPermission.ACCESS, children.keySet())
+        .stream()
+        .sorted()
+        .map((p) -> json.format(children.get(p)))
+        .collect(toList());
   }
 
-  private List<ProjectInfo> getChildProjectsRecursively(ProjectNode p) {
-    List<ProjectInfo> allChildren = new ArrayList<>();
-    for (ProjectNode c : p.getChildren()) {
-      if (c.isVisible()) {
-        allChildren.add(json.format(c.getProject()));
-        allChildren.addAll(getChildProjectsRecursively(c));
+  private List<ProjectInfo> recursiveChildProjects(Project.NameKey parent)
+      throws PermissionBackendException {
+    Map<Project.NameKey, Project> projects = readAllProjects();
+    Multimap<Project.NameKey, Project.NameKey> children = parentToChildren(projects);
+    PermissionBackend.WithUser perm = permissionBackend.user(user);
+
+    List<ProjectInfo> results = new ArrayList<>();
+    depthFirstFormat(results, perm, projects, children, parent);
+    return results;
+  }
+
+  private Map<Project.NameKey, Project> readAllProjects() {
+    Map<Project.NameKey, Project> projects = new HashMap<>();
+    for (Project.NameKey name : projectCache.all()) {
+      ProjectState c = projectCache.get(name);
+      if (c != null) {
+        projects.put(c.getProject().getNameKey(), c.getProject());
       }
     }
-    return allChildren;
+    return projects;
+  }
+
+  /** Map of parent project to direct child. */
+  private Multimap<Project.NameKey, Project.NameKey> parentToChildren(
+      Map<Project.NameKey, Project> projects) {
+    Multimap<Project.NameKey, Project.NameKey> m = ArrayListMultimap.create();
+    for (Map.Entry<Project.NameKey, Project> e : projects.entrySet()) {
+      if (!allProjects.equals(e.getKey())) {
+        m.put(e.getValue().getParent(allProjects), e.getKey());
+      }
+    }
+    return m;
+  }
+
+  private void depthFirstFormat(
+      List<ProjectInfo> results,
+      PermissionBackend.WithUser perm,
+      Map<Project.NameKey, Project> projects,
+      Multimap<Project.NameKey, Project.NameKey> children,
+      Project.NameKey parent)
+      throws PermissionBackendException {
+    List<Project.NameKey> canSee =
+        perm.filter(ProjectPermission.ACCESS, children.get(parent))
+            .stream()
+            .sorted()
+            .collect(toList());
+    children.removeAll(parent); // removing all entries prevents cycles.
+
+    for (Project.NameKey c : canSee) {
+      results.add(json.format(projects.get(c)));
+      depthFirstFormat(results, perm, projects, children, c);
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListDashboards.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListDashboards.java
index 4ea5a8f..d60a4a7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListDashboards.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListDashboards.java
@@ -19,12 +19,20 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.DashboardsCollection.DashboardInfo;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.BlobBasedConfig;
@@ -41,42 +49,56 @@
   private static final Logger log = LoggerFactory.getLogger(ListDashboards.class);
 
   private final GitRepositoryManager gitManager;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
 
   @Option(name = "--inherited", usage = "include inherited dashboards")
   private boolean inherited;
 
   @Inject
-  ListDashboards(GitRepositoryManager gitManager) {
+  ListDashboards(
+      GitRepositoryManager gitManager,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user) {
     this.gitManager = gitManager;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
   }
 
   @Override
-  public List<?> apply(ProjectResource resource) throws ResourceNotFoundException, IOException {
-    ProjectControl ctl = resource.getControl();
-    String project = ctl.getProject().getName();
+  public List<?> apply(ProjectResource rsrc)
+      throws ResourceNotFoundException, IOException, PermissionBackendException {
+    String project = rsrc.getName();
     if (!inherited) {
-      return scan(resource.getControl(), project, true);
+      return scan(rsrc.getControl(), project, true);
     }
 
     List<List<DashboardInfo>> all = new ArrayList<>();
     boolean setDefault = true;
-    for (ProjectState ps : ctl.getProjectState().tree()) {
-      ctl = ps.controlFor(ctl.getUser());
-      if (ctl.isVisible()) {
-        List<DashboardInfo> list = scan(ctl, project, setDefault);
-        for (DashboardInfo d : list) {
-          if (d.isDefault != null && Boolean.TRUE.equals(d.isDefault)) {
-            setDefault = false;
-          }
+    for (ProjectState ps : tree(rsrc)) {
+      List<DashboardInfo> list = scan(ps.controlFor(user.get()), project, setDefault);
+      for (DashboardInfo d : list) {
+        if (d.isDefault != null && Boolean.TRUE.equals(d.isDefault)) {
+          setDefault = false;
         }
-        if (!list.isEmpty()) {
-          all.add(list);
-        }
+      }
+      if (!list.isEmpty()) {
+        all.add(list);
       }
     }
     return all;
   }
 
+  private Collection<ProjectState> tree(ProjectResource rsrc) throws PermissionBackendException {
+    Map<Project.NameKey, ProjectState> tree = new LinkedHashMap<>();
+    for (ProjectState ps : rsrc.getProjectState().tree()) {
+      tree.put(ps.getProject().getNameKey(), ps);
+    }
+    tree.keySet()
+        .retainAll(permissionBackend.user(user).filter(ProjectPermission.ACCESS, tree.keySet()));
+    return tree.values();
+  }
+
   private List<DashboardInfo> scan(ProjectControl ctl, String project, boolean setDefault)
       throws ResourceNotFoundException, IOException {
     Project.NameKey projectName = ctl.getProject().getNameKey();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java
index a84fefd..7018097 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java
@@ -14,15 +14,20 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.gerrit.extensions.client.ProjectState.HIDDEN;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
 
 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.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
+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.RestReadView;
@@ -38,6 +43,9 @@
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.group.GroupsCollection;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.util.RegexListSearcher;
 import com.google.gerrit.server.util.TreeFormatter;
 import com.google.gson.reflect.TypeToken;
@@ -50,6 +58,8 @@
 import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
@@ -79,12 +89,22 @@
       boolean matches(Repository git) throws IOException {
         return !PERMISSIONS.matches(git);
       }
+
+      @Override
+      boolean useMatch() {
+        return true;
+      }
     },
     PARENT_CANDIDATES {
       @Override
       boolean matches(Repository git) {
         return true;
       }
+
+      @Override
+      boolean useMatch() {
+        return false;
+      }
     },
     PERMISSIONS {
       @Override
@@ -94,15 +114,27 @@
             && head.isSymbolic()
             && RefNames.REFS_CONFIG.equals(head.getLeaf().getName());
       }
+
+      @Override
+      boolean useMatch() {
+        return true;
+      }
     },
     ALL {
       @Override
       boolean matches(Repository git) {
         return true;
       }
+
+      @Override
+      boolean useMatch() {
+        return false;
+      }
     };
 
     abstract boolean matches(Repository git) throws IOException;
+
+    abstract boolean useMatch();
   }
 
   private final CurrentUser currentUser;
@@ -110,6 +142,7 @@
   private final GroupsCollection groupsCollection;
   private final GroupControl.Factory groupControlFactory;
   private final GitRepositoryManager repoManager;
+  private final PermissionBackend permissionBackend;
   private final ProjectNode.Factory projectNodeFactory;
   private final WebLinks webLinks;
 
@@ -229,6 +262,7 @@
       GroupsCollection groupsCollection,
       GroupControl.Factory groupControlFactory,
       GitRepositoryManager repoManager,
+      PermissionBackend permissionBackend,
       ProjectNode.Factory projectNodeFactory,
       WebLinks webLinks) {
     this.currentUser = currentUser;
@@ -236,6 +270,7 @@
     this.groupsCollection = groupsCollection;
     this.groupControlFactory = groupControlFactory;
     this.repoManager = repoManager;
+    this.permissionBackend = permissionBackend;
     this.projectNodeFactory = projectNodeFactory;
     this.webLinks = webLinks;
   }
@@ -262,7 +297,8 @@
   }
 
   @Override
-  public Object apply(TopLevelResource resource) throws BadRequestException {
+  public Object apply(TopLevelResource resource)
+      throws BadRequestException, PermissionBackendException {
     if (format == OutputFormat.TEXT) {
       ByteArrayOutputStream buf = new ByteArrayOutputStream();
       display(buf);
@@ -273,141 +309,123 @@
     return apply();
   }
 
-  public SortedMap<String, ProjectInfo> apply() throws BadRequestException {
+  public SortedMap<String, ProjectInfo> apply()
+      throws BadRequestException, PermissionBackendException {
     format = OutputFormat.JSON;
     return display(null);
   }
 
-  public SortedMap<String, ProjectInfo> display(OutputStream displayOutputStream)
-      throws BadRequestException {
+  public SortedMap<String, ProjectInfo> display(@Nullable OutputStream displayOutputStream)
+      throws BadRequestException, PermissionBackendException {
+    if (groupUuid != null) {
+      try {
+        if (!groupControlFactory.controlFor(groupUuid).isVisible()) {
+          return Collections.emptySortedMap();
+        }
+      } catch (NoSuchGroupException ex) {
+        return Collections.emptySortedMap();
+      }
+    }
+
     PrintWriter stdout = null;
     if (displayOutputStream != null) {
       stdout =
           new PrintWriter(new BufferedWriter(new OutputStreamWriter(displayOutputStream, UTF_8)));
     }
 
+    if (type == FilterType.PARENT_CANDIDATES) {
+      // Historically, PARENT_CANDIDATES implied showDescription.
+      showDescription = true;
+    }
+
     int foundIndex = 0;
     int found = 0;
     TreeMap<String, ProjectInfo> output = new TreeMap<>();
     Map<String, String> hiddenNames = new HashMap<>();
-    Set<String> rejected = new HashSet<>();
-
+    Map<Project.NameKey, Boolean> accessibleParents = new HashMap<>();
+    PermissionBackend.WithUser perm = permissionBackend.user(currentUser);
     final TreeMap<Project.NameKey, ProjectNode> treeMap = new TreeMap<>();
     try {
-      for (final Project.NameKey projectName : scan()) {
+      for (final Project.NameKey projectName : filter(perm)) {
         final ProjectState e = projectCache.get(projectName);
-        if (e == null) {
+        if (e == null || (!all && e.getProject().getState() == HIDDEN)) {
           // If we can't get it from the cache, pretend its not present.
-          //
+          // If all wasn't selected, and its HIDDEN, pretend its not present.
           continue;
         }
 
         final ProjectControl pctl = e.controlFor(currentUser);
-        if (groupUuid != null) {
-          try {
-            if (!groupControlFactory.controlFor(groupUuid).isVisible()) {
-              break;
-            }
-          } catch (NoSuchGroupException ex) {
-            break;
-          }
-          if (!pctl.getLocalGroups()
-              .contains(GroupReference.forGroup(groupsCollection.parseId(groupUuid.get())))) {
-            continue;
-          }
+        if (groupUuid != null
+            && !pctl.getLocalGroups()
+                .contains(GroupReference.forGroup(groupsCollection.parseId(groupUuid.get())))) {
+          continue;
         }
 
         ProjectInfo info = new ProjectInfo();
-        if (type == FilterType.PARENT_CANDIDATES) {
-          ProjectState parentState = Iterables.getFirst(e.parents(), null);
-          if (parentState != null
-              && !output.keySet().contains(parentState.getProject().getName())
-              && !rejected.contains(parentState.getProject().getName())) {
-            ProjectControl parentCtrl = parentState.controlFor(currentUser);
-            if (parentCtrl.isVisible() || parentCtrl.isOwner()) {
-              info.name = parentState.getProject().getName();
-              info.description = Strings.emptyToNull(parentState.getProject().getDescription());
-              info.state = parentState.getProject().getState();
+        if (showTree && !format.isJson()) {
+          treeMap.put(projectName, projectNodeFactory.create(pctl.getProject(), true));
+          continue;
+        }
+
+        info.name = projectName.get();
+        if (showTree && format.isJson()) {
+          ProjectState parent = Iterables.getFirst(e.parents(), null);
+          if (parent != null) {
+            if (isParentAccessible(accessibleParents, perm, parent)) {
+              info.parent = parent.getProject().getName();
             } else {
-              rejected.add(parentState.getProject().getName());
-              continue;
-            }
-          } else {
-            continue;
-          }
-
-        } else {
-          final boolean isVisible = pctl.isVisible() || (all && pctl.isOwner());
-          if (showTree && !format.isJson()) {
-            treeMap.put(projectName, projectNodeFactory.create(pctl.getProject(), isVisible));
-            continue;
-          }
-
-          if (!isVisible && !(showTree && pctl.isOwner())) {
-            // Require the project itself to be visible to the user.
-            //
-            continue;
-          }
-
-          info.name = projectName.get();
-          if (showTree && format.isJson()) {
-            ProjectState parent = Iterables.getFirst(e.parents(), null);
-            if (parent != null) {
-              ProjectControl parentCtrl = parent.controlFor(currentUser);
-              if (parentCtrl.isVisible() || parentCtrl.isOwner()) {
-                info.parent = parent.getProject().getName();
-              } else {
-                info.parent = hiddenNames.get(parent.getProject().getName());
-                if (info.parent == null) {
-                  info.parent = "?-" + (hiddenNames.size() + 1);
-                  hiddenNames.put(parent.getProject().getName(), info.parent);
-                }
+              info.parent = hiddenNames.get(parent.getProject().getName());
+              if (info.parent == null) {
+                info.parent = "?-" + (hiddenNames.size() + 1);
+                hiddenNames.put(parent.getProject().getName(), info.parent);
               }
             }
           }
-          if (showDescription) {
-            info.description = Strings.emptyToNull(e.getProject().getDescription());
-          }
+        }
 
-          info.state = e.getProject().getState();
+        if (showDescription) {
+          info.description = Strings.emptyToNull(e.getProject().getDescription());
+        }
+        info.state = e.getProject().getState();
 
-          try {
-            if (!showBranch.isEmpty()) {
-              try (Repository git = repoManager.openRepository(projectName)) {
-                if (!type.matches(git)) {
-                  continue;
-                }
+        try {
+          if (!showBranch.isEmpty()) {
+            try (Repository git = repoManager.openRepository(projectName)) {
+              if (!type.matches(git)) {
+                continue;
+              }
 
-                List<Ref> refs = getBranchRefs(projectName, pctl);
-                if (!hasValidRef(refs)) {
-                  continue;
-                }
+              List<Ref> refs = getBranchRefs(projectName, pctl);
+              if (!hasValidRef(refs)) {
+                continue;
+              }
 
-                for (int i = 0; i < showBranch.size(); i++) {
-                  Ref ref = refs.get(i);
-                  if (ref != null && ref.getObjectId() != null) {
-                    if (info.branches == null) {
-                      info.branches = new LinkedHashMap<>();
-                    }
-                    info.branches.put(showBranch.get(i), ref.getObjectId().name());
+              for (int i = 0; i < showBranch.size(); i++) {
+                Ref ref = refs.get(i);
+                if (ref != null && ref.getObjectId() != null) {
+                  if (info.branches == null) {
+                    info.branches = new LinkedHashMap<>();
                   }
-                }
-              }
-            } else if (!showTree && type != FilterType.ALL) {
-              try (Repository git = repoManager.openRepository(projectName)) {
-                if (!type.matches(git)) {
-                  continue;
+                  info.branches.put(showBranch.get(i), ref.getObjectId().name());
                 }
               }
             }
-
-          } catch (RepositoryNotFoundException err) {
-            // If the Git repository is gone, the project doesn't actually exist anymore.
-            continue;
-          } catch (IOException err) {
-            log.warn("Unexpected error reading " + projectName, err);
-            continue;
+          } else if (!showTree && type.useMatch()) {
+            try (Repository git = repoManager.openRepository(projectName)) {
+              if (!type.matches(git)) {
+                continue;
+              }
+            }
           }
+        } catch (RepositoryNotFoundException err) {
+          // If the Git repository is gone, the project doesn't actually exist anymore.
+          continue;
+        } catch (IOException err) {
+          log.warn("Unexpected error reading " + projectName, err);
+          continue;
+        }
+
+        if (type != FilterType.PARENT_CANDIDATES) {
           List<WebLinkInfo> links = webLinks.getProjectLinks(projectName.get());
           info.webLinks = links.isEmpty() ? null : links;
         }
@@ -415,7 +433,6 @@
         if (foundIndex++ < start) {
           continue;
         }
-
         if (limit > 0 && ++found > limit) {
           break;
         }
@@ -467,6 +484,53 @@
     }
   }
 
+  private Collection<Project.NameKey> filter(PermissionBackend.WithUser perm)
+      throws BadRequestException, PermissionBackendException {
+    Collection<Project.NameKey> matches = Lists.newArrayList(scan());
+    if (type == FilterType.PARENT_CANDIDATES) {
+      matches = parentsOf(matches);
+    }
+    return perm.filter(ProjectPermission.ACCESS, matches).stream().sorted().collect(toList());
+  }
+
+  private Collection<Project.NameKey> parentsOf(Collection<Project.NameKey> matches) {
+    Set<Project.NameKey> parents = new HashSet<>();
+    for (Project.NameKey p : matches) {
+      ProjectState ps = projectCache.get(p);
+      if (ps != null) {
+        Project.NameKey parent = ps.getProject().getParent();
+        if (parent != null) {
+          if (projectCache.get(parent) != null) {
+            parents.add(parent);
+          } else {
+            log.warn(
+                String.format(
+                    "parent project %s of project %s not found",
+                    parent.get(), ps.getProject().getName()));
+          }
+        }
+      }
+    }
+    return parents;
+  }
+
+  private boolean isParentAccessible(
+      Map<Project.NameKey, Boolean> checked, PermissionBackend.WithUser perm, ProjectState p)
+      throws PermissionBackendException {
+    Project.NameKey name = p.getProject().getNameKey();
+    Boolean b = checked.get(name);
+    if (b == null) {
+      try {
+        perm.project(name).check(ProjectPermission.ACCESS);
+        b = true;
+      } catch (AuthException denied) {
+        b = false;
+      }
+      checked.put(name, b);
+    }
+    return b;
+  }
+
   private Iterable<Project.NameKey> scan() throws BadRequestException {
     if (matchPrefix != null) {
       checkMatchOptions(matchSubstring == null && matchRegex == null);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java
index 7f1ee60..7cbea47 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java
@@ -24,11 +24,14 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommonConverters;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.VisibleRefFilter;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -50,6 +53,8 @@
 
 public class ListTags implements RestReadView<ProjectResource> {
   private final GitRepositoryManager repoManager;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
   private final Provider<ReviewDb> dbProvider;
   private final TagCache tagCache;
   private final ChangeNotes.Factory changeNotesFactory;
@@ -103,11 +108,15 @@
   @Inject
   public ListTags(
       GitRepositoryManager repoManager,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
       Provider<ReviewDb> dbProvider,
       TagCache tagCache,
       ChangeNotes.Factory changeNotesFactory,
       @Nullable SearchingChangeCacheImpl changeCache) {
     this.repoManager = repoManager;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
     this.dbProvider = dbProvider;
     this.tagCache = tagCache;
     this.changeNotesFactory = changeNotesFactory;
@@ -119,13 +128,14 @@
       throws IOException, ResourceNotFoundException, BadRequestException {
     List<TagInfo> tags = new ArrayList<>();
 
+    PermissionBackend.ForProject perm = permissionBackend.user(user).project(resource.getNameKey());
     try (Repository repo = getRepository(resource.getNameKey());
         RevWalk rw = new RevWalk(repo)) {
-      ProjectControl control = resource.getControl();
+      ProjectControl pctl = resource.getControl();
       Map<String, Ref> all =
-          visibleTags(control, repo, repo.getRefDatabase().getRefs(Constants.R_TAGS));
+          visibleTags(pctl, repo, repo.getRefDatabase().getRefs(Constants.R_TAGS));
       for (Ref ref : all.values()) {
-        tags.add(createTagInfo(ref, rw, control.controlForRef(ref.getName())));
+        tags.add(createTagInfo(perm.ref(ref.getName()), ref, rw));
       }
     }
 
@@ -158,15 +168,22 @@
       ProjectControl control = resource.getControl();
       if (ref != null
           && !visibleTags(control, repo, ImmutableMap.of(ref.getName(), ref)).isEmpty()) {
-        return createTagInfo(ref, rw, control.controlForRef(ref.getName()));
+        return createTagInfo(
+            permissionBackend
+                .user(control.getUser())
+                .project(resource.getNameKey())
+                .ref(ref.getName()),
+            ref,
+            rw);
       }
     }
     throw new ResourceNotFoundException(id);
   }
 
-  public static TagInfo createTagInfo(Ref ref, RevWalk rw, RefControl control)
+  public static TagInfo createTagInfo(PermissionBackend.ForRef perm, Ref ref, RevWalk rw)
       throws MissingObjectException, IOException {
     RevObject object = rw.parseAny(ref.getObjectId());
+    boolean canDelete = perm.testOrFalse(RefPermission.DELETE);
     if (object instanceof RevTag) {
       // Annotated or signed tag
       RevTag tag = (RevTag) object;
@@ -177,10 +194,10 @@
           tag.getObject().getName(),
           tag.getFullMessage().trim(),
           tagger != null ? CommonConverters.toGitPerson(tag.getTaggerIdent()) : null,
-          control.canDelete());
+          canDelete);
     }
     // Lightweight tag
-    return new TagInfo(ref.getName(), ref.getObjectId().getName(), control.canDelete());
+    return new TagInfo(ref.getName(), ref.getObjectId().getName(), canDelete);
   }
 
   private Repository getRepository(Project.NameKey project)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java
index d7af195..11f3805 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java
@@ -24,6 +24,7 @@
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.server.change.CherryPickCommit;
 
 public class Module extends RestApiModule {
   @Override
@@ -96,6 +97,8 @@
     get(PROJECT_KIND, "config").to(GetConfig.class);
     put(PROJECT_KIND, "config").to(PutConfig.class);
 
+    post(COMMIT_KIND, "cherrypick").to(CherryPickCommit.class);
+
     factory(DeleteRef.Factory.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PermissionCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PermissionCollection.java
index 4bf3e47..9febb3f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PermissionCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PermissionCollection.java
@@ -98,7 +98,6 @@
           perUser = true;
           if (sm.match(ref, user)) {
             sectionToProject.put(sm.section, sm.project);
-            break;
           }
         } else if (sm.match(ref, null)) {
           sectionToProject.put(sm.section, sm.project);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheWarmer.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
index 5e0ba28..16a3b6b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
@@ -46,7 +46,7 @@
   public void start() {
     int cpus = Runtime.getRuntime().availableProcessors();
     if (config.getBoolean("cache", "projects", "loadOnStartup", false)) {
-      final ThreadPoolExecutor pool =
+      ThreadPoolExecutor pool =
           new ScheduledThreadPoolExecutor(
               config.getInt("cache", "projects", "loadThreads", cpus),
               new ThreadFactoryBuilder().setNameFormat("ProjectCacheLoader-%d").build());
@@ -54,25 +54,19 @@
 
       log.info("Loading project cache");
       scheduler.execute(
-          new Runnable() {
-            @Override
-            public void run() {
-              for (final Project.NameKey name : cache.all()) {
-                pool.execute(
-                    new Runnable() {
-                      @Override
-                      public void run() {
-                        cache.get(name);
-                      }
-                    });
-              }
-              pool.shutdown();
-              try {
-                pool.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS);
-                log.info("Finished loading project cache");
-              } catch (InterruptedException e) {
-                log.warn("Interrupted while waiting for project cache to load");
-              }
+          () -> {
+            for (final Project.NameKey name : cache.all()) {
+              pool.execute(
+                  () -> {
+                    cache.get(name);
+                  });
+            }
+            pool.shutdown();
+            try {
+              pool.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS);
+              log.info("Finished loading project cache");
+            } catch (InterruptedException e) {
+              log.warn("Interrupted while waiting for project cache to load");
             }
           });
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
index e9976c5..8773bad 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.common.base.Preconditions.checkArgument;
+
 import com.google.common.collect.Maps;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
@@ -25,6 +27,7 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.metrics.Counter0;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.MetricMaker;
@@ -45,6 +48,12 @@
 import com.google.gerrit.server.git.VisibleRefFilter;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.FailedPermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
+import com.google.gerrit.server.permissions.PermissionBackend.ForProject;
+import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
@@ -56,6 +65,7 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -70,9 +80,6 @@
 
 /** Access control management for a user accessing a project's data. */
 public class ProjectControl {
-  public static final int VISIBLE = 1 << 0;
-  public static final int OWNER = 1 << 1;
-
   private static final Logger log = LoggerFactory.getLogger(ProjectControl.class);
 
   public static class GenericFactory {
@@ -91,18 +98,6 @@
       }
       return p.controlFor(user);
     }
-
-    public ProjectControl validateFor(Project.NameKey nameKey, int need, CurrentUser user)
-        throws NoSuchProjectException, IOException {
-      final ProjectControl c = controlFor(nameKey, user);
-      if ((need & VISIBLE) == VISIBLE && c.isVisible()) {
-        return c;
-      }
-      if ((need & OWNER) == OWNER && c.isOwner()) {
-        return c;
-      }
-      throw new NoSuchProjectException(nameKey);
-    }
   }
 
   public static class Factory {
@@ -116,26 +111,6 @@
     public ProjectControl controlFor(final Project.NameKey nameKey) throws NoSuchProjectException {
       return userCache.get().get(nameKey);
     }
-
-    public ProjectControl validateFor(final Project.NameKey nameKey) throws NoSuchProjectException {
-      return validateFor(nameKey, VISIBLE);
-    }
-
-    public ProjectControl ownerFor(final Project.NameKey nameKey) throws NoSuchProjectException {
-      return validateFor(nameKey, OWNER);
-    }
-
-    public ProjectControl validateFor(final Project.NameKey nameKey, final int need)
-        throws NoSuchProjectException {
-      final ProjectControl c = controlFor(nameKey);
-      if ((need & VISIBLE) == VISIBLE && c.isVisible()) {
-        return c;
-      }
-      if ((need & OWNER) == OWNER && c.isOwner()) {
-        return c;
-      }
-      throw new NoSuchProjectException(nameKey);
-    }
   }
 
   public interface AssistedFactory {
@@ -274,21 +249,6 @@
     return getProject().getState().equals(com.google.gerrit.extensions.client.ProjectState.HIDDEN);
   }
 
-  /**
-   * Returns whether the project is readable to the current user. Note that the project could still
-   * be hidden.
-   */
-  public boolean isReadable() {
-    return (user.isInternalUser() || canPerformOnAnyRef(Permission.READ));
-  }
-
-  /**
-   * Returns whether the project is accessible to the current user, i.e. readable and not hidden.
-   */
-  public boolean isVisible() {
-    return isReadable() && !isHidden();
-  }
-
   public boolean canAddRefs() {
     return (canPerformOnAnyRef(Permission.CREATE) || isOwnerAnyRef());
   }
@@ -306,19 +266,14 @@
     return false;
   }
 
-  /** Can this user see all the refs in this projects? */
-  public boolean allRefsAreVisible() {
-    return allRefsAreVisible(Collections.<String>emptySet());
-  }
-
   public boolean allRefsAreVisible(Set<String> ignore) {
     return user.isInternalUser() || canPerformOnAllRefs(Permission.READ, ignore);
   }
 
-  /** Is this user a project owner? Ownership does not imply {@link #isVisible()} */
+  /** Is this user a project owner? */
   public boolean isOwner() {
     return (isDeclaredOwner() && !controlForRef("refs/*").isBlocked(Permission.OWNER))
-        || user.getCapabilities().canAdministrateServer();
+        || user.getCapabilities().isAdmin_DoNotUse();
   }
 
   private boolean isDeclaredOwner() {
@@ -331,7 +286,7 @@
 
   /** Does this user have ownership on at least one reference name? */
   public boolean isOwnerAnyRef() {
-    return canPerformOnAnyRef(Permission.OWNER) || user.getCapabilities().canAdministrateServer();
+    return canPerformOnAnyRef(Permission.OWNER) || user.getCapabilities().isAdmin_DoNotUse();
   }
 
   /** @return true if the user can upload to at least one reference */
@@ -565,4 +520,76 @@
     Map<String, Ref> refs = filter.filter(m, true);
     return !refs.isEmpty() && IncludedInResolver.includedInOne(repo, rw, commit, refs.values());
   }
+
+  ForProject asForProject() {
+    return new ForProjectImpl();
+  }
+
+  private class ForProjectImpl extends ForProject {
+    @Override
+    public ForProject user(CurrentUser user) {
+      return forUser(user).asForProject().database(db);
+    }
+
+    @Override
+    public ForRef ref(String ref) {
+      return controlForRef(ref).asForRef().database(db);
+    }
+
+    @Override
+    public ForChange change(ChangeData cd) {
+      try {
+        checkProject(cd.change());
+        return super.change(cd);
+      } catch (OrmException e) {
+        return FailedPermissionBackend.change("unavailable", e);
+      }
+    }
+
+    @Override
+    public ForChange change(ChangeNotes notes) {
+      checkProject(notes.getChange());
+      return super.change(notes);
+    }
+
+    private void checkProject(Change change) {
+      Project.NameKey project = getProject().getNameKey();
+      checkArgument(
+          project.equals(change.getProject()),
+          "expected change in project %s, not %s",
+          project,
+          change.getProject());
+    }
+
+    @Override
+    public void check(ProjectPermission perm) throws AuthException, PermissionBackendException {
+      if (!can(perm)) {
+        throw new AuthException(perm.describeForException() + " not permitted");
+      }
+    }
+
+    @Override
+    public Set<ProjectPermission> test(Collection<ProjectPermission> permSet)
+        throws PermissionBackendException {
+      EnumSet<ProjectPermission> ok = EnumSet.noneOf(ProjectPermission.class);
+      for (ProjectPermission perm : permSet) {
+        if (can(perm)) {
+          ok.add(perm);
+        }
+      }
+      return ok;
+    }
+
+    private boolean can(ProjectPermission perm) throws PermissionBackendException {
+      switch (perm) {
+        case ACCESS:
+          return (!isHidden() && (user.isInternalUser() || canPerformOnAnyRef(Permission.READ)))
+              || isOwner();
+
+        case READ:
+          return !isHidden() && allRefsAreVisible(Collections.emptySet());
+      }
+      throw new PermissionBackendException(perm + " unsupported");
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectResource.java
index b8830a0..2601a4a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectResource.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Project;
@@ -42,8 +41,8 @@
     return control.getProject().getNameKey();
   }
 
-  public ProjectState getState() {
-    return control.getProject().getState();
+  public ProjectState getProjectState() {
+    return control.getProjectState();
   }
 
   public ProjectControl getControl() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
index 8b8745e..32dc41f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
@@ -394,6 +394,10 @@
     return getInheritableBoolean(Project::getRejectImplicitMerges);
   }
 
+  public boolean isEnableReviewerByEmail() {
+    return getInheritableBoolean(Project::getEnableReviewerByEmail);
+  }
+
   public LabelTypes getLabelTypes() {
     Map<String, LabelType> types = new LinkedHashMap<>();
     for (ProjectState s : treeInOrder()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectsCollection.java
index dcb3404..d461a7d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectsCollection.java
@@ -14,8 +14,10 @@
 
 package com.google.gerrit.server.project;
 
+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;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestCollection;
@@ -25,6 +27,9 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.OutputFormat;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -37,6 +42,7 @@
   private final DynamicMap<RestView<ProjectResource>> views;
   private final Provider<ListProjects> list;
   private final ProjectControl.GenericFactory controlFactory;
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> user;
   private final CreateProject.Factory createProjectFactory;
 
@@ -45,11 +51,13 @@
       DynamicMap<RestView<ProjectResource>> views,
       Provider<ListProjects> list,
       ProjectControl.GenericFactory controlFactory,
+      PermissionBackend permissionBackend,
       CreateProject.Factory factory,
       Provider<CurrentUser> user) {
     this.views = views;
     this.list = list;
     this.controlFactory = controlFactory;
+    this.permissionBackend = permissionBackend;
     this.user = user;
     this.createProjectFactory = factory;
   }
@@ -61,7 +69,7 @@
 
   @Override
   public ProjectResource parse(TopLevelResource parent, IdString id)
-      throws ResourceNotFoundException, IOException {
+      throws ResourceNotFoundException, IOException, PermissionBackendException {
     ProjectResource rsrc = _parse(id.get(), true);
     if (rsrc == null) {
       throw new ResourceNotFoundException(id);
@@ -77,8 +85,10 @@
    * @throws UnprocessableEntityException thrown if the project ID cannot be resolved or if the
    *     project is not visible to the calling user
    * @throws IOException thrown when there is an error.
+   * @throws PermissionBackendException
    */
-  public ProjectResource parse(String id) throws UnprocessableEntityException, IOException {
+  public ProjectResource parse(String id)
+      throws UnprocessableEntityException, IOException, PermissionBackendException {
     return parse(id, true);
   }
 
@@ -86,33 +96,43 @@
    * Parses a project ID from a request body and returns the project.
    *
    * @param id ID of the project, can be a project name
-   * @param checkVisibility Whether to check or not that project is visible to the calling user
+   * @param checkAccess if true, check the project is accessible by the current user
    * @return the project
    * @throws UnprocessableEntityException thrown if the project ID cannot be resolved or if the
    *     project is not visible to the calling user and checkVisibility is true.
    * @throws IOException thrown when there is an error.
+   * @throws PermissionBackendException
    */
-  public ProjectResource parse(String id, boolean checkVisibility)
-      throws UnprocessableEntityException, IOException {
-    ProjectResource rsrc = _parse(id, checkVisibility);
+  public ProjectResource parse(String id, boolean checkAccess)
+      throws UnprocessableEntityException, IOException, PermissionBackendException {
+    ProjectResource rsrc = _parse(id, checkAccess);
     if (rsrc == null) {
       throw new UnprocessableEntityException(String.format("Project Not Found: %s", id));
     }
     return rsrc;
   }
 
-  private ProjectResource _parse(String id, boolean checkVisibility) throws IOException {
+  @Nullable
+  private ProjectResource _parse(String id, boolean checkAccess)
+      throws IOException, PermissionBackendException {
     if (id.endsWith(Constants.DOT_GIT_EXT)) {
       id = id.substring(0, id.length() - Constants.DOT_GIT_EXT.length());
     }
+
+    Project.NameKey nameKey = new Project.NameKey(id);
     ProjectControl ctl;
     try {
-      ctl = controlFactory.controlFor(new Project.NameKey(id), user.get());
+      ctl = controlFactory.controlFor(nameKey, user.get());
     } catch (NoSuchProjectException e) {
       return null;
     }
-    if (checkVisibility && !ctl.isVisible() && !ctl.isOwner()) {
-      return null;
+
+    if (checkAccess) {
+      try {
+        permissionBackend.user(user).project(nameKey).check(ProjectPermission.ACCESS);
+      } catch (AuthException e) {
+        return null; // Pretend like not found on access denied.
+      }
     }
     return new ProjectResource(ctl);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java
index c5ded54..806c01a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java
@@ -22,9 +22,11 @@
 import com.google.gerrit.extensions.api.projects.ConfigValue;
 import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
 import com.google.gerrit.extensions.registration.DynamicMap;
+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.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Project;
@@ -34,6 +36,7 @@
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
+import com.google.gerrit.server.extensions.webui.UiActions;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.git.TransferConfig;
@@ -62,6 +65,7 @@
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
   private final PluginConfigFactory cfgFactory;
   private final AllProjectsName allProjects;
+  private final UiActions uiActions;
   private final DynamicMap<RestView<ProjectResource>> views;
   private final Provider<CurrentUser> user;
 
@@ -75,6 +79,7 @@
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
+      UiActions uiActions,
       DynamicMap<RestView<ProjectResource>> views,
       Provider<CurrentUser> user) {
     this.serverEnableSignedPush = serverEnableSignedPush;
@@ -85,15 +90,15 @@
     this.pluginConfigEntries = pluginConfigEntries;
     this.cfgFactory = cfgFactory;
     this.allProjects = allProjects;
+    this.uiActions = uiActions;
     this.views = views;
     this.user = user;
   }
 
   @Override
-  public ConfigInfo apply(ProjectResource rsrc, ConfigInput input)
-      throws ResourceNotFoundException, BadRequestException, ResourceConflictException {
+  public ConfigInfo apply(ProjectResource rsrc, ConfigInput input) throws RestApiException {
     if (!rsrc.getControl().isOwner()) {
-      throw new ResourceNotFoundException(rsrc.getName());
+      throw new AuthException("restricted to project owner");
     }
     return apply(rsrc.getControl(), input);
   }
@@ -154,6 +159,10 @@
         p.setState(input.state);
       }
 
+      if (input.enableReviewerByEmail != null) {
+        p.setEnableReviewerByEmail(input.enableReviewerByEmail);
+      }
+
       if (input.pluginConfigValues != null) {
         setPluginConfigValues(ctrl.getProjectState(), projectConfig, input.pluginConfigValues);
       }
@@ -180,6 +189,7 @@
           pluginConfigEntries,
           cfgFactory,
           allProjects,
+          uiActions,
           views);
     } catch (RepositoryNotFoundException notFound) {
       throw new ResourceNotFoundException(projectName.get());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
index 8413b5a9..bf53ab1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
@@ -14,17 +14,32 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.common.base.Preconditions.checkArgument;
+
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.FailedPermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
+import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
 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.HashSet;
 import java.util.List;
@@ -102,11 +117,18 @@
   /** Can this user see this reference exists? */
   public boolean isVisible() {
     if (isVisible == null) {
-      isVisible = (getUser().isInternalUser() || canPerform(Permission.READ)) && canRead();
+      isVisible =
+          (getUser().isInternalUser() || canPerform(Permission.READ))
+              && isProjectStatePermittingRead();
     }
     return isVisible;
   }
 
+  /** Can this user see other users change edits? */
+  public boolean isEditVisible() {
+    return canViewPrivateChanges();
+  }
+
   /** True if this reference is visible by all REGISTERED_USERS */
   public boolean isVisibleByRegisteredUsers() {
     List<PermissionRule> access = relevant.getPermission(Permission.READ);
@@ -136,15 +158,15 @@
    */
   public boolean canUpload() {
     return projectControl.controlForRef("refs/for/" + getRefName()).canPerform(Permission.PUSH)
-        && canWrite();
+        && isProjectStatePermittingWrite();
   }
 
   /** @return true if this user can add a new patch set to this ref */
-  public boolean canAddPatchSet() {
+  boolean canAddPatchSet() {
     return projectControl
             .controlForRef("refs/for/" + getRefName())
             .canPerform(Permission.ADD_PATCH_SET)
-        && canWrite();
+        && isProjectStatePermittingWrite();
   }
 
   /** @return true if this user can submit merge patch sets to this ref */
@@ -152,12 +174,12 @@
     return projectControl
             .controlForRef("refs/for/" + getRefName())
             .canPerform(Permission.PUSH_MERGE)
-        && canWrite();
+        && isProjectStatePermittingWrite();
   }
 
   /** @return true if this user can rebase changes on this ref */
-  public boolean canRebase() {
-    return canPerform(Permission.REBASE) && canWrite();
+  boolean canRebase() {
+    return canPerform(Permission.REBASE) && isProjectStatePermittingWrite();
   }
 
   /** @return true if this user can submit patch sets to this ref */
@@ -170,16 +192,11 @@
       // granting of powers beyond submitting to the configuration.
       return projectControl.isOwner();
     }
-    return canPerform(Permission.SUBMIT, isChangeOwner) && canWrite();
-  }
-
-  /** @return true if this user was granted submitAs to this ref */
-  public boolean canSubmitAs() {
-    return canPerform(Permission.SUBMIT_AS);
+    return canPerform(Permission.SUBMIT, isChangeOwner) && isProjectStatePermittingWrite();
   }
 
   /** @return true if the user can update the reference as a fast-forward. */
-  public boolean canUpdate() {
+  private boolean canUpdate() {
     if (RefNames.REFS_CONFIG.equals(refName) && !projectControl.isOwner()) {
       // Pushing requires being at least project owner, in addition to push.
       // Pushing configuration changes modifies the access control
@@ -191,16 +208,16 @@
       // this why for the AllProjects project we allow administrators to push
       // configuration changes if they have push without being project owner.
       if (!(projectControl.getProjectState().isAllProjects()
-          && getUser().getCapabilities().canAdministrateServer())) {
+          && getUser().getCapabilities().isAdmin_DoNotUse())) {
         return false;
       }
     }
-    return canPerform(Permission.PUSH) && canWrite();
+    return canPerform(Permission.PUSH) && isProjectStatePermittingWrite();
   }
 
   /** @return true if the user can rewind (force push) the reference. */
-  public boolean canForceUpdate() {
-    if (!canWrite()) {
+  private boolean canForceUpdate() {
+    if (!isProjectStatePermittingWrite()) {
       return false;
     }
 
@@ -218,21 +235,23 @@
       case UNKNOWN:
       case WEB_BROWSER:
       default:
-        return getUser().getCapabilities().canAdministrateServer()
+        return getUser().getCapabilities().isAdmin_DoNotUse()
             || (isOwner() && !isForceBlocked(Permission.PUSH));
     }
   }
 
-  public boolean canWrite() {
+  private boolean isProjectStatePermittingWrite() {
     return getProjectControl().getProject().getState().equals(ProjectState.ACTIVE);
   }
 
-  public boolean canRead() {
-    return getProjectControl().getProject().getState().equals(ProjectState.READ_ONLY) || canWrite();
+  private boolean isProjectStatePermittingRead() {
+    return getProjectControl().getProject().getState().equals(ProjectState.READ_ONLY)
+        || isProjectStatePermittingWrite();
   }
 
   private boolean canPushWithForce() {
-    if (!canWrite() || (RefNames.REFS_CONFIG.equals(refName) && !projectControl.isOwner())) {
+    if (!isProjectStatePermittingWrite()
+        || (RefNames.REFS_CONFIG.equals(refName) && !projectControl.isOwner())) {
       // Pushing requires being at least project owner, in addition to push.
       // Pushing configuration changes modifies the access control
       // rules. Allowing this to be done by a non-project-owner opens
@@ -252,7 +271,7 @@
    * @return {@code true} if the user specified can create a new Git ref
    */
   public boolean canCreate(ReviewDb db, Repository repo, RevObject object) {
-    if (!canWrite()) {
+    if (!isProjectStatePermittingWrite()) {
       return false;
     }
 
@@ -344,8 +363,8 @@
    *
    * @return {@code true} if the user specified can delete a Git ref.
    */
-  public boolean canDelete() {
-    if (!canWrite() || (RefNames.REFS_CONFIG.equals(refName))) {
+  private boolean canDelete() {
+    if (!isProjectStatePermittingWrite() || (RefNames.REFS_CONFIG.equals(refName))) {
       // Never allow removal of the refs/meta/config branch.
       // Deleting the branch would destroy all Gerrit specific
       // metadata about the project, including its access rules.
@@ -364,7 +383,7 @@
       case UNKNOWN:
       case WEB_BROWSER:
       default:
-        return getUser().getCapabilities().canAdministrateServer()
+        return getUser().getCapabilities().isAdmin_DoNotUse()
             || (isOwner() && !isForceBlocked(Permission.PUSH))
             || canPushWithForce()
             || canPerform(Permission.DELETE);
@@ -393,7 +412,7 @@
   }
 
   /** @return true if this user can abandon a change for this ref */
-  public boolean canAbandon() {
+  boolean canAbandon() {
     return canPerform(Permission.ABANDON);
   }
 
@@ -403,46 +422,51 @@
   }
 
   /** @return true if this user can view draft changes. */
-  public boolean canViewDrafts() {
+  boolean canViewDrafts() {
     return canPerform(Permission.VIEW_DRAFTS);
   }
 
+  /** @return true if this user can view private changes. */
+  boolean canViewPrivateChanges() {
+    return canPerform(Permission.VIEW_PRIVATE_CHANGES);
+  }
+
   /** @return true if this user can publish draft changes. */
-  public boolean canPublishDrafts() {
+  boolean canPublishDrafts() {
     return canPerform(Permission.PUBLISH_DRAFTS);
   }
 
   /** @return true if this user can delete draft changes. */
-  public boolean canDeleteDrafts() {
+  boolean canDeleteDrafts() {
     return canPerform(Permission.DELETE_DRAFTS);
   }
 
   /** @return true if this user can delete their own changes. */
-  public boolean canDeleteOwnChanges() {
+  boolean canDeleteOwnChanges() {
     return canPerform(Permission.DELETE_OWN_CHANGES);
   }
 
   /** @return true if this user can edit topic names. */
-  public boolean canEditTopicName() {
+  boolean canEditTopicName() {
     return canPerform(Permission.EDIT_TOPIC_NAME);
   }
 
   /** @return true if this user can edit hashtag names. */
-  public boolean canEditHashtags() {
+  boolean canEditHashtags() {
     return canPerform(Permission.EDIT_HASHTAGS);
   }
 
-  public boolean canEditAssignee() {
+  boolean canEditAssignee() {
     return canPerform(Permission.EDIT_ASSIGNEE);
   }
 
   /** @return true if this user can force edit topic names. */
-  public boolean canForceEditTopicName() {
+  boolean canForceEditTopicName() {
     return canForcePerform(Permission.EDIT_TOPIC_NAME);
   }
 
   /** All value ranges of any allowed label permission. */
-  public List<PermissionRange> getLabelRanges(boolean isChangeOwner) {
+  List<PermissionRange> getLabelRanges(boolean isChangeOwner) {
     List<PermissionRange> r = new ArrayList<>();
     for (Map.Entry<String, List<PermissionRule>> e : relevant.getDeclaredPermissions()) {
       if (Permission.isLabel(e.getKey())) {
@@ -463,12 +487,12 @@
   }
 
   /** The range of permitted values associated with a label permission. */
-  public PermissionRange getRange(String permission) {
+  PermissionRange getRange(String permission) {
     return getRange(permission, false);
   }
 
   /** The range of permitted values associated with a label permission. */
-  public PermissionRange getRange(String permission, boolean isChangeOwner) {
+  PermissionRange getRange(String permission, boolean isChangeOwner) {
     if (Permission.hasRange(permission)) {
       return toRange(permission, access(permission, isChangeOwner));
     }
@@ -642,4 +666,80 @@
     effective.put(permissionName, mine);
     return mine;
   }
+
+  ForRef asForRef() {
+    return new ForRefImpl();
+  }
+
+  private class ForRefImpl extends ForRef {
+    @Override
+    public ForRef user(CurrentUser user) {
+      return forUser(user).asForRef().database(db);
+    }
+
+    @Override
+    public ForChange change(ChangeData cd) {
+      try {
+        return cd.changeControl().forUser(getUser()).asForChange(cd, db);
+      } catch (OrmException e) {
+        return FailedPermissionBackend.change("unavailable", e);
+      }
+    }
+
+    @Override
+    public ForChange change(ChangeNotes notes) {
+      Project.NameKey project = getProjectControl().getProject().getNameKey();
+      Change change = notes.getChange();
+      checkArgument(
+          project.equals(change.getProject()),
+          "expected change in project %s, not %s",
+          project,
+          change.getProject());
+      return getProjectControl().controlFor(notes).asForChange(null, db);
+    }
+
+    @Override
+    public void check(RefPermission perm) throws AuthException, PermissionBackendException {
+      if (!can(perm)) {
+        throw new AuthException(perm.describeForException() + " not permitted");
+      }
+    }
+
+    @Override
+    public Set<RefPermission> test(Collection<RefPermission> permSet)
+        throws PermissionBackendException {
+      EnumSet<RefPermission> ok = EnumSet.noneOf(RefPermission.class);
+      for (RefPermission perm : permSet) {
+        if (can(perm)) {
+          ok.add(perm);
+        }
+      }
+      return ok;
+    }
+
+    private boolean can(RefPermission perm) throws PermissionBackendException {
+      switch (perm) {
+        case READ:
+          return isVisible();
+        case CREATE:
+          // TODO This isn't an accurate test.
+          return canPerform(perm.permissionName().get());
+        case DELETE:
+          return canDelete();
+        case UPDATE:
+          return canUpdate();
+        case FORCE_UPDATE:
+          return canForceUpdate();
+        case FORGE_AUTHOR:
+          return canForgeAuthor();
+        case FORGE_COMMITTER:
+          return canForgeCommitter();
+        case FORGE_SERVER:
+          return canForgeGerritServerIdentity();
+        case CREATE_CHANGE:
+          return canUpload();
+      }
+      throw new PermissionBackendException(perm + " unsupported");
+    }
+  }
 }
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 c74efc6..6c55c37 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
@@ -41,6 +41,9 @@
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.group.GroupsCollection;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -54,6 +57,7 @@
 @Singleton
 public class SetAccess implements RestModifyView<ProjectResource, ProjectAccessInput> {
   protected final GroupBackend groupBackend;
+  private final PermissionBackend permissionBackend;
   private final GroupsCollection groupsCollection;
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final AllProjectsName allProjects;
@@ -65,6 +69,7 @@
   @Inject
   private SetAccess(
       GroupBackend groupBackend,
+      PermissionBackend permissionBackend,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
       AllProjectsName allProjects,
       Provider<SetParent> setParent,
@@ -73,6 +78,7 @@
       GetAccess getAccess,
       Provider<IdentifiedUser> identifiedUser) {
     this.groupBackend = groupBackend;
+    this.permissionBackend = permissionBackend;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.allProjects = allProjects;
     this.setParent = setParent;
@@ -85,7 +91,7 @@
   @Override
   public ProjectAccessInfo apply(ProjectResource rsrc, ProjectAccessInput input)
       throws ResourceNotFoundException, ResourceConflictException, IOException, AuthException,
-          BadRequestException, UnprocessableEntityException {
+          BadRequestException, UnprocessableEntityException, PermissionBackendException {
     List<AccessSection> removals = getAccessSections(input.remove);
     List<AccessSection> additions = getAccessSections(input.add);
     MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
@@ -269,16 +275,11 @@
   }
 
   private void checkGlobalCapabilityPermissions(Project.NameKey projectName)
-      throws BadRequestException, AuthException {
-
+      throws BadRequestException, AuthException, PermissionBackendException {
     if (!allProjects.equals(projectName)) {
       throw new BadRequestException(
           "Cannot edit global capabilities for projects other than " + allProjects.get());
     }
-
-    if (!identifiedUser.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException(
-          "Editing global capabilities requires " + GlobalCapability.ADMINISTRATE_SERVER);
-    }
+    permissionBackend.user(identifiedUser).check(GlobalPermission.ADMINISTRATE_SERVER);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java
index f8d649b..7ec8706 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java
@@ -30,6 +30,9 @@
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.SetParent.Input;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -45,12 +48,18 @@
   }
 
   private final ProjectCache cache;
+  private final PermissionBackend permissionBackend;
   private final MetaDataUpdate.Server updateFactory;
   private final AllProjectsName allProjects;
 
   @Inject
-  SetParent(ProjectCache cache, MetaDataUpdate.Server updateFactory, AllProjectsName allProjects) {
+  SetParent(
+      ProjectCache cache,
+      PermissionBackend permissionBackend,
+      MetaDataUpdate.Server updateFactory,
+      AllProjectsName allProjects) {
     this.cache = cache;
+    this.permissionBackend = permissionBackend;
     this.updateFactory = updateFactory;
     this.allProjects = allProjects;
   }
@@ -58,13 +67,13 @@
   @Override
   public String apply(ProjectResource rsrc, Input input)
       throws AuthException, ResourceConflictException, ResourceNotFoundException,
-          UnprocessableEntityException, IOException {
+          UnprocessableEntityException, IOException, PermissionBackendException {
     return apply(rsrc, input, true);
   }
 
   public String apply(ProjectResource rsrc, Input input, boolean checkIfAdmin)
       throws AuthException, ResourceConflictException, ResourceNotFoundException,
-          UnprocessableEntityException, IOException {
+          UnprocessableEntityException, IOException, PermissionBackendException {
     ProjectControl ctl = rsrc.getControl();
     String parentName =
         MoreObjects.firstNonNull(Strings.emptyToNull(input.parent), allProjects.get());
@@ -97,10 +106,11 @@
   }
 
   public void validateParentUpdate(final ProjectControl ctl, String newParent, boolean checkIfAdmin)
-      throws AuthException, ResourceConflictException, UnprocessableEntityException {
+      throws AuthException, ResourceConflictException, UnprocessableEntityException,
+          PermissionBackendException {
     IdentifiedUser user = ctl.getUser().asIdentifiedUser();
-    if (checkIfAdmin && !user.getCapabilities().canAdministrateServer()) {
-      throw new AuthException("not administrator");
+    if (checkIfAdmin) {
+      permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
 
     if (ctl.getProject().getNameKey().equals(allProjects)) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SuggestParentCandidates.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SuggestParentCandidates.java
index 9d3005c..7c7f8a5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SuggestParentCandidates.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SuggestParentCandidates.java
@@ -14,65 +14,61 @@
 
 package com.google.gerrit.server.project;
 
+import static java.util.stream.Collectors.toList;
+
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.Comparator;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
-import java.util.TreeSet;
 
 @Singleton
 public class SuggestParentCandidates {
-  private final ProjectControl.Factory projectControlFactory;
   private final ProjectCache projectCache;
-  private final AllProjectsName allProject;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+  private final AllProjectsName allProjects;
 
   @Inject
   SuggestParentCandidates(
-      final ProjectControl.Factory projectControlFactory,
-      final ProjectCache projectCache,
-      final AllProjectsName allProject) {
-    this.projectControlFactory = projectControlFactory;
+      ProjectCache projectCache,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      AllProjectsName allProjects) {
     this.projectCache = projectCache;
-    this.allProject = allProject;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
+    this.allProjects = allProjects;
   }
 
-  public List<Project.NameKey> getNameKeys() throws NoSuchProjectException {
-    List<Project> pList = getProjects();
-    final List<Project.NameKey> nameKeys = new ArrayList<>(pList.size());
-    for (Project p : pList) {
-      nameKeys.add(p.getNameKey());
-    }
-    return nameKeys;
+  public List<Project.NameKey> getNameKeys() throws PermissionBackendException {
+    return permissionBackend
+        .user(user)
+        .filter(ProjectPermission.ACCESS, parents())
+        .stream()
+        .sorted()
+        .collect(toList());
   }
 
-  public List<Project> getProjects() throws NoSuchProjectException {
-    Set<Project> projects =
-        new TreeSet<>(
-            new Comparator<Project>() {
-              @Override
-              public int compare(Project o1, Project o2) {
-                return o1.getName().compareTo(o2.getName());
-              }
-            });
+  private Set<Project.NameKey> parents() {
+    Set<Project.NameKey> parents = new HashSet<>();
     for (Project.NameKey p : projectCache.all()) {
-      try {
-        final ProjectControl control = projectControlFactory.controlFor(p);
-        final Project.NameKey parentK = control.getProject().getParent();
-        if (parentK != null) {
-          ProjectControl pControl = projectControlFactory.controlFor(parentK);
-          if (pControl.isVisible() || pControl.isOwner()) {
-            projects.add(pControl.getProject());
-          }
+      ProjectState ps = projectCache.get(p);
+      if (ps != null) {
+        Project.NameKey parent = ps.getProject().getParent();
+        if (parent != null) {
+          parents.add(parent);
         }
-      } catch (NoSuchProjectException e) {
-        continue;
       }
     }
-    projects.add(projectControlFactory.controlFor(allProject).getProject());
-    return new ArrayList<>(projects);
+    parents.add(allProjects);
+    return parents;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/IntPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/IntPredicate.java
index 6627687..2abcd58 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/IntPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/IntPredicate.java
@@ -16,25 +16,25 @@
 
 /** Predicate to filter a field by matching integer value. */
 public abstract class IntPredicate<T> extends OperatorPredicate<T> {
-  private final int value;
+  private final int intValue;
 
   public IntPredicate(final String name, final String value) {
     super(name, value);
-    this.value = Integer.parseInt(value);
+    this.intValue = Integer.parseInt(value);
   }
 
-  public IntPredicate(final String name, final int value) {
-    super(name, String.valueOf(value));
-    this.value = value;
+  public IntPredicate(final String name, final int intValue) {
+    super(name, String.valueOf(intValue));
+    this.intValue = intValue;
   }
 
   public int intValue() {
-    return value;
+    return intValue;
   }
 
   @Override
   public int hashCode() {
-    return getOperator().hashCode() * 31 + value;
+    return getOperator().hashCode() * 31 + intValue;
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/OperatorPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/OperatorPredicate.java
index 96a30ee..9413c5d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/OperatorPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/OperatorPredicate.java
@@ -18,10 +18,10 @@
 
 /** Predicate to filter a field by matching value. */
 public abstract class OperatorPredicate<T> extends Predicate<T> {
-  private final String name;
-  private final String value;
+  protected final String name;
+  protected final String value;
 
-  protected OperatorPredicate(final String name, final String value) {
+  public OperatorPredicate(final String name, final String value) {
     this.name = name;
     this.value = value;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
index 0a74647..e5ed44d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
@@ -20,9 +20,9 @@
 import com.google.gwtorm.server.OrmException;
 
 public class AccountIsVisibleToPredicate extends IsVisibleToPredicate<AccountState> {
-  private final AccountControl accountControl;
+  protected final AccountControl accountControl;
 
-  AccountIsVisibleToPredicate(AccountControl accountControl) {
+  public AccountIsVisibleToPredicate(AccountControl accountControl) {
     super(AccountQueryBuilder.FIELD_VISIBLETO, describe(accountControl.getUser()));
     this.accountControl = accountControl;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java
index 796539b..9bb5515 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java
@@ -55,6 +55,13 @@
         AccountField.EMAIL, AccountQueryBuilder.FIELD_EMAIL, email.toLowerCase());
   }
 
+  static Predicate<AccountState> preferredEmail(String email) {
+    return new AccountPredicate(
+        AccountField.PREFERRED_EMAIL,
+        AccountQueryBuilder.FIELD_PREFERRED_EMAIL,
+        email.toLowerCase());
+  }
+
   static Predicate<AccountState> equalsName(String name) {
     return new AccountPredicate(
         AccountField.NAME_PART, AccountQueryBuilder.FIELD_NAME, name.toLowerCase());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
index 5ae6a67..8891af5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
@@ -36,6 +36,7 @@
   public static final String FIELD_EMAIL = "email";
   public static final String FIELD_LIMIT = "limit";
   public static final String FIELD_NAME = "name";
+  public static final String FIELD_PREFERRED_EMAIL = "preferredemail";
   public static final String FIELD_USERNAME = "username";
   public static final String FIELD_VISIBLETO = "visibleto";
 
@@ -123,7 +124,7 @@
   @Override
   protected Predicate<AccountState> defaultField(String query) {
     Predicate<AccountState> defaultPredicate = AccountPredicates.defaultPredicate(query);
-    if ("self".equalsIgnoreCase(query)) {
+    if ("self".equalsIgnoreCase(query) || "me".equalsIgnoreCase(query)) {
       try {
         return Predicate.or(defaultPredicate, AccountPredicates.id(self()));
       } catch (QueryParseException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
index c2b92aa..70d8484 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.index.IndexConfig;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.query.InternalQuery;
@@ -68,10 +68,6 @@
     return query(AccountPredicates.defaultPredicate(query));
   }
 
-  public List<AccountState> byEmailPrefix(String emailPrefix) throws OrmException {
-    return query(AccountPredicates.email(emailPrefix));
-  }
-
   public List<AccountState> byExternalId(String scheme, String id) throws OrmException {
     return byExternalId(ExternalId.Key.create(scheme, id));
   }
@@ -106,6 +102,10 @@
     return query(AccountPredicates.fullName(fullName));
   }
 
+  public List<AccountState> byPreferredEmail(String email) throws OrmException {
+    return query(AccountPredicates.preferredEmail(email));
+  }
+
   public List<AccountState> byWatchedProject(Project.NameKey project) throws OrmException {
     return query(AccountPredicates.watchedProject(project));
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AddedPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AddedPredicate.java
index b3cdd6a..05bf24bd2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AddedPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AddedPredicate.java
@@ -19,7 +19,7 @@
 import com.google.gwtorm.server.OrmException;
 
 public class AddedPredicate extends IntegerRangeChangePredicate {
-  AddedPredicate(String value) throws QueryParseException {
+  public AddedPredicate(String value) throws QueryParseException {
     super(ChangeField.ADDED, value);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AfterPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AfterPredicate.java
index 7d51217..b9c4694 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AfterPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AfterPredicate.java
@@ -20,9 +20,9 @@
 import java.util.Date;
 
 public class AfterPredicate extends TimestampRangeChangePredicate {
-  private final Date cut;
+  protected final Date cut;
 
-  AfterPredicate(String value) throws QueryParseException {
+  public AfterPredicate(String value) throws QueryParseException {
     super(ChangeField.UPDATED, ChangeQueryBuilder.FIELD_BEFORE, value);
     cut = parse(value);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AgePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AgePredicate.java
index 0cd76bb..a5f4965 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AgePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AgePredicate.java
@@ -25,9 +25,9 @@
 import java.sql.Timestamp;
 
 public class AgePredicate extends TimestampRangeChangePredicate {
-  private final long cut;
+  protected final long cut;
 
-  AgePredicate(String value) {
+  public AgePredicate(String value) {
     super(ChangeField.UPDATED, ChangeQueryBuilder.FIELD_AGE, value);
 
     long s = ConfigUtil.getTimeUnit(getValue(), 0, SECONDS);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AssigneePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AssigneePredicate.java
index 38622ed..848fd09 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AssigneePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AssigneePredicate.java
@@ -18,10 +18,10 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class AssigneePredicate extends ChangeIndexPredicate {
-  private final Account.Id id;
+public class AssigneePredicate extends ChangeIndexPredicate {
+  protected final Account.Id id;
 
-  AssigneePredicate(Account.Id id) {
+  public AssigneePredicate(Account.Id id) {
     super(ChangeField.ASSIGNEE, id.toString());
     this.id = id;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AuthorPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AuthorPredicate.java
index dccd17e..3ee3352 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AuthorPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AuthorPredicate.java
@@ -22,7 +22,7 @@
 import java.io.IOException;
 
 public class AuthorPredicate extends ChangeIndexPredicate {
-  AuthorPredicate(String value) {
+  public AuthorPredicate(String value) {
     super(AUTHOR, FIELD_AUTHOR, value.toLowerCase());
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BeforePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BeforePredicate.java
index 9e443c9..bc57f15 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BeforePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BeforePredicate.java
@@ -20,9 +20,9 @@
 import java.util.Date;
 
 public class BeforePredicate extends TimestampRangeChangePredicate {
-  private final Date cut;
+  protected final Date cut;
 
-  BeforePredicate(String value) throws QueryParseException {
+  public BeforePredicate(String value) throws QueryParseException {
     super(ChangeField.UPDATED, ChangeQueryBuilder.FIELD_BEFORE, value);
     cut = parse(value);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergeablePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BooleanPredicate.java
similarity index 80%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergeablePredicate.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/query/change/BooleanPredicate.java
index d998fa3..31e3ee1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergeablePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BooleanPredicate.java
@@ -14,15 +14,15 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.server.index.FieldDef;
 import com.google.gerrit.server.index.FieldDef.FillArgs;
-import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class IsMergeablePredicate extends ChangeIndexPredicate {
-  private final FillArgs args;
+public class BooleanPredicate extends ChangeIndexPredicate {
+  protected final FillArgs args;
 
-  IsMergeablePredicate(FillArgs args) {
-    super(ChangeField.MERGEABLE, "1");
+  public BooleanPredicate(FieldDef<ChangeData, String> field, FillArgs args) {
+    super(field, "1");
     this.args = args;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
index b62e3eb..677999f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -33,6 +33,7 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.reviewdb.client.Account;
@@ -52,6 +53,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.StarredChangesUtil;
@@ -352,6 +354,7 @@
   private StarsOf starsOf;
   private ImmutableMap<Account.Id, StarRef> starRefs;
   private ReviewerSet reviewers;
+  private ReviewerByEmailSet reviewersByEmail;
   private List<ReviewerStatusUpdate> reviewerUpdates;
   private PersonIdent author;
   private PersonIdent committer;
@@ -755,6 +758,10 @@
     return change;
   }
 
+  public LabelTypes getLabelTypes() throws OrmException {
+    return changeControl().getLabelTypes();
+  }
+
   public ChangeNotes notes() throws OrmException {
     if (notes == null) {
       if (!lazyLoad) {
@@ -954,6 +961,24 @@
     return reviewers;
   }
 
+  public ReviewerByEmailSet reviewersByEmail() throws OrmException {
+    if (reviewersByEmail == null) {
+      if (!lazyLoad) {
+        return ReviewerByEmailSet.empty();
+      }
+      reviewersByEmail = notes().getReviewersByEmail();
+    }
+    return reviewersByEmail;
+  }
+
+  public void setReviewersByEmail(ReviewerByEmailSet reviewersByEmail) {
+    this.reviewersByEmail = reviewersByEmail;
+  }
+
+  public ReviewerByEmailSet getReviewersByEmail() {
+    return reviewersByEmail;
+  }
+
   public List<ReviewerStatusUpdate> reviewerUpdates() throws OrmException {
     if (reviewerUpdates == null) {
       if (!lazyLoad) {
@@ -1065,6 +1090,8 @@
         mergeable = true;
       } else if (c.getStatus() == Change.Status.ABANDONED) {
         return null;
+      } else if (c.isWorkInProgress()) {
+        return null;
       } else {
         if (!lazyLoad) {
           return null;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
index 85d433a..d541d18 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
@@ -19,8 +19,8 @@
 import com.google.gwtorm.server.OrmException;
 
 /** Predicate over Change-Id strings (aka Change.Key). */
-class ChangeIdPredicate extends ChangeIndexPredicate {
-  ChangeIdPredicate(String id) {
+public class ChangeIdPredicate extends ChangeIndexPredicate {
+  public ChangeIdPredicate(String id) {
     super(ChangeField.ID, ChangeQueryBuilder.FIELD_CHANGE, id);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
index 0604f8b..0362c85 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
@@ -14,9 +14,12 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.FieldDef;
 import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.query.Matchable;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
 
 public abstract class ChangeIndexPredicate extends IndexPredicate<ChangeData>
     implements Matchable<ChangeData> {
@@ -27,4 +30,11 @@
   protected ChangeIndexPredicate(FieldDef<ChangeData, ?> def, String name, String value) {
     super(def, name, value);
   }
+
+  protected static Predicate<ChangeData> create(Arguments args, Predicate<ChangeData> p) {
+    if (!args.allowsDrafts) {
+      return Predicate.and(p, Predicate.not(new ChangeStatusPredicate(Change.Status.DRAFT)));
+    }
+    return p;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
index 8db62a7..632ec04 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
@@ -24,13 +24,13 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Provider;
 
-class ChangeIsVisibleToPredicate extends IsVisibleToPredicate<ChangeData> {
-  private final Provider<ReviewDb> db;
-  private final ChangeNotes.Factory notesFactory;
-  private final ChangeControl.GenericFactory changeControl;
-  private final CurrentUser user;
+public class ChangeIsVisibleToPredicate extends IsVisibleToPredicate<ChangeData> {
+  protected final Provider<ReviewDb> db;
+  protected final ChangeNotes.Factory notesFactory;
+  protected final ChangeControl.GenericFactory changeControl;
+  protected final CurrentUser user;
 
-  ChangeIsVisibleToPredicate(
+  public ChangeIsVisibleToPredicate(
       Provider<ReviewDb> db,
       ChangeNotes.Factory notesFactory,
       ChangeControl.GenericFactory changeControlFactory,
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 af2cb60..af0a3b8 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
@@ -57,13 +57,17 @@
 import com.google.gerrit.server.index.FieldDef;
 import com.google.gerrit.server.index.IndexConfig;
 import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.index.SchemaUtil;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ListChildProjects;
 import com.google.gerrit.server.project.ProjectCache;
@@ -84,7 +88,9 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.function.Function;
 import java.util.regex.Pattern;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
@@ -115,6 +121,8 @@
   private static final Pattern DEF_CHANGE =
       Pattern.compile("^(?:[1-9][0-9]*|(?:[^~]+~[^~]+~)?[iI][0-9a-f]{4,}.*)$");
 
+  static final int MAX_ACCOUNTS_PER_DEFAULT_FIELD = 10;
+
   // NOTE: As new search operations are added, please keep the
   // SearchSuggestOracle up to date.
 
@@ -122,6 +130,7 @@
   public static final String FIELD_AGE = "age";
   public static final String FIELD_ASSIGNEE = "assignee";
   public static final String FIELD_AUTHOR = "author";
+  public static final String FIELD_EXACTAUTHOR = "exactauthor";
   public static final String FIELD_BEFORE = "before";
   public static final String FIELD_CHANGE = "change";
   public static final String FIELD_CHANGE_ID = "change_id";
@@ -129,6 +138,7 @@
   public static final String FIELD_COMMENTBY = "commentby";
   public static final String FIELD_COMMIT = "commit";
   public static final String FIELD_COMMITTER = "committer";
+  public static final String FIELD_EXACTCOMMITTER = "exactcommitter";
   public static final String FIELD_CONFLICTS = "conflicts";
   public static final String FIELD_DELETED = "deleted";
   public static final String FIELD_DELTA = "delta";
@@ -149,6 +159,7 @@
   public static final String FIELD_OWNERIN = "ownerin";
   public static final String FIELD_PARENTPROJECT = "parentproject";
   public static final String FIELD_PATH = "path";
+  public static final String FIELD_PRIVATE = "private";
   public static final String FIELD_PROJECT = "project";
   public static final String FIELD_PROJECTS = "projects";
   public static final String FIELD_REF = "ref";
@@ -164,6 +175,7 @@
   public static final String FIELD_UNRESOLVED_COMMENT_COUNT = "unresolved";
   public static final String FIELD_VISIBLETO = "visibleto";
   public static final String FIELD_WATCHEDBY = "watchedby";
+  public static final String FIELD_WIP = "wip";
 
   public static final String ARG_ID_USER = "user";
   public static final String ARG_ID_GROUP = "group";
@@ -179,6 +191,7 @@
     final AccountResolver accountResolver;
     final AllProjectsName allProjectsName;
     final AllUsersName allUsersName;
+    final PermissionBackend permissionBackend;
     final CapabilityControl.Factory capabilityControlFactory;
     final ChangeControl.GenericFactory changeControlGenericFactory;
     final ChangeData.Factory changeDataFactory;
@@ -218,6 +231,7 @@
         DynamicMap<ChangeHasOperandFactory> hasOperands,
         IdentifiedUser.GenericFactory userFactory,
         Provider<CurrentUser> self,
+        PermissionBackend permissionBackend,
         CapabilityControl.Factory capabilityControlFactory,
         ChangeControl.GenericFactory changeControlGenericFactory,
         ChangeNotes.Factory notesFactory,
@@ -250,6 +264,7 @@
           hasOperands,
           userFactory,
           self,
+          permissionBackend,
           capabilityControlFactory,
           changeControlGenericFactory,
           notesFactory,
@@ -284,6 +299,7 @@
         DynamicMap<ChangeHasOperandFactory> hasOperands,
         IdentifiedUser.GenericFactory userFactory,
         Provider<CurrentUser> self,
+        PermissionBackend permissionBackend,
         CapabilityControl.Factory capabilityControlFactory,
         ChangeControl.GenericFactory changeControlGenericFactory,
         ChangeNotes.Factory notesFactory,
@@ -314,6 +330,7 @@
       this.opFactories = opFactories;
       this.userFactory = userFactory;
       this.self = self;
+      this.permissionBackend = permissionBackend;
       this.capabilityControlFactory = capabilityControlFactory;
       this.notesFactory = notesFactory;
       this.changeControlGenericFactory = changeControlGenericFactory;
@@ -350,6 +367,7 @@
           hasOperands,
           userFactory,
           Providers.of(otherUser),
+          permissionBackend,
           capabilityControlFactory,
           changeControlGenericFactory,
           notesFactory,
@@ -561,6 +579,11 @@
     }
 
     if ("reviewer".equalsIgnoreCase(value)) {
+      if (args.getSchema().hasField(ChangeField.WIP)) {
+        return Predicate.and(
+            Predicate.not(new BooleanPredicate(ChangeField.WIP, args.fillArgs)),
+            ReviewerPredicate.reviewer(args, self()));
+      }
       return ReviewerPredicate.reviewer(args, self());
     }
 
@@ -569,7 +592,11 @@
     }
 
     if ("mergeable".equalsIgnoreCase(value)) {
-      return new IsMergeablePredicate(args.fillArgs);
+      return new BooleanPredicate(ChangeField.MERGEABLE, args.fillArgs);
+    }
+
+    if ("private".equalsIgnoreCase(value)) {
+      return new BooleanPredicate(ChangeField.PRIVATE, args.fillArgs);
     }
 
     if ("assigned".equalsIgnoreCase(value)) {
@@ -584,6 +611,17 @@
       return new SubmittablePredicate(SubmitRecord.Status.OK);
     }
 
+    if ("ignored".equalsIgnoreCase(value)) {
+      return star("ignore");
+    }
+
+    if ("wip".equalsIgnoreCase(value)) {
+      if (args.getSchema().hasField(ChangeField.WIP)) {
+        return new BooleanPredicate(ChangeField.WIP, args.fillArgs);
+      }
+      throw new QueryParseException("'is:wip' operator is not supported by change index version");
+    }
+
     try {
       return status(value);
     } catch (IllegalArgumentException e) {
@@ -853,9 +891,13 @@
     return new HasDraftByPredicate(who);
   }
 
+  private boolean isSelf(String who) {
+    return "self".equals(who) || "me".equals(who);
+  }
+
   @Operator
   public Predicate<ChangeData> visibleto(String who) throws QueryParseException, OrmException {
-    if ("self".equals(who)) {
+    if (isSelf(who)) {
       return is_visible();
     }
     Set<Account.Id> m = args.accountResolver.findAll(args.db.get(), who);
@@ -908,6 +950,15 @@
     return Predicate.or(p);
   }
 
+  private Predicate<ChangeData> ownerDefaultField(String who)
+      throws QueryParseException, OrmException {
+    Set<Account.Id> accounts = parseAccount(who);
+    if (accounts.size() > MAX_ACCOUNTS_PER_DEFAULT_FIELD) {
+      return Predicate.any();
+    }
+    return owner(accounts);
+  }
+
   @Operator
   public Predicate<ChangeData> assignee(String who) throws QueryParseException, OrmException {
     return assignee(parseAccount(who));
@@ -937,17 +988,31 @@
 
   @Operator
   public Predicate<ChangeData> reviewer(String who) throws QueryParseException, OrmException {
-    return Predicate.or(
-        parseAccount(who)
-            .stream()
-            .map(id -> ReviewerPredicate.reviewer(args, id))
-            .collect(toList()));
+    return reviewer(who, false);
+  }
+
+  private Predicate<ChangeData> reviewerDefaultField(String who)
+      throws QueryParseException, OrmException {
+    return reviewer(who, true);
+  }
+
+  private Predicate<ChangeData> reviewer(String who, boolean forDefaultField)
+      throws QueryParseException, OrmException {
+    Predicate<ChangeData> byState =
+        reviewerByState(who, ReviewerStateInternal.REVIEWER, forDefaultField);
+    if (byState == Predicate.<ChangeData>any()) {
+      return Predicate.any();
+    }
+    if (args.getSchema().hasField(ChangeField.WIP)) {
+      return Predicate.and(
+          Predicate.not(new BooleanPredicate(ChangeField.WIP, args.fillArgs)), byState);
+    }
+    return byState;
   }
 
   @Operator
   public Predicate<ChangeData> cc(String who) throws QueryParseException, OrmException {
-    return Predicate.or(
-        parseAccount(who).stream().map(id -> ReviewerPredicate.cc(args, id)).collect(toList()));
+    return reviewerByState(who, ReviewerStateInternal.CC, false);
   }
 
   @Operator
@@ -1059,13 +1124,21 @@
   }
 
   @Operator
-  public Predicate<ChangeData> author(String who) {
-    return new AuthorPredicate(who);
+  public Predicate<ChangeData> author(String who) throws QueryParseException {
+    if (args.getSchema().hasField(ChangeField.EXACT_AUTHOR)) {
+      return getAuthorOrCommitterPredicate(
+          who.trim(), ExactAuthorPredicate::new, AuthorPredicate::new);
+    }
+    return getAuthorOrCommitterFullTextPredicate(who.trim(), AuthorPredicate::new);
   }
 
   @Operator
-  public Predicate<ChangeData> committer(String who) {
-    return new CommitterPredicate(who);
+  public Predicate<ChangeData> committer(String who) throws QueryParseException {
+    if (args.getSchema().hasField(ChangeField.EXACT_COMMITTER)) {
+      return getAuthorOrCommitterPredicate(
+          who.trim(), ExactCommitterPredicate::new, CommitterPredicate::new);
+    }
+    return getAuthorOrCommitterFullTextPredicate(who.trim(), CommitterPredicate::new);
   }
 
   @Operator
@@ -1106,12 +1179,18 @@
     // Adapt the capacity of this list when adding more default predicates.
     List<Predicate<ChangeData>> predicates = Lists.newArrayListWithCapacity(11);
     try {
-      predicates.add(owner(query));
+      Predicate<ChangeData> p = ownerDefaultField(query);
+      if (p != Predicate.<ChangeData>any()) {
+        predicates.add(p);
+      }
     } catch (OrmException | QueryParseException e) {
       // Skip.
     }
     try {
-      predicates.add(reviewer(query));
+      Predicate<ChangeData> p = reviewerDefaultField(query);
+      if (p != Predicate.<ChangeData>any()) {
+        predicates.add(p);
+      }
     } catch (OrmException | QueryParseException e) {
       // Skip.
     }
@@ -1133,8 +1212,32 @@
     return Predicate.or(predicates);
   }
 
+  private Predicate<ChangeData> getAuthorOrCommitterPredicate(
+      String who,
+      Function<String, Predicate<ChangeData>> exactPredicateFunc,
+      Function<String, Predicate<ChangeData>> fullPredicateFunc)
+      throws QueryParseException {
+    if (Address.tryParse(who) != null) {
+      return exactPredicateFunc.apply(who);
+    }
+    return getAuthorOrCommitterFullTextPredicate(who, fullPredicateFunc);
+  }
+
+  private Predicate<ChangeData> getAuthorOrCommitterFullTextPredicate(
+      String who, Function<String, Predicate<ChangeData>> fullPredicateFunc)
+      throws QueryParseException {
+    Set<String> parts = SchemaUtil.getNameParts(who);
+    if (parts.isEmpty()) {
+      throw error("invalid value");
+    }
+
+    List<Predicate<ChangeData>> predicates =
+        parts.stream().map(fullPredicateFunc).collect(Collectors.toList());
+    return Predicate.and(predicates);
+  }
+
   private Set<Account.Id> parseAccount(String who) throws QueryParseException, OrmException {
-    if ("self".equals(who)) {
+    if (isSelf(who)) {
       return Collections.singleton(self());
     }
     Set<Account.Id> matches = args.accountResolver.findAll(args.db.get(), who);
@@ -1176,4 +1279,44 @@
   private Account.Id self() throws QueryParseException {
     return args.getIdentifiedUser().getAccountId();
   }
+
+  public Predicate<ChangeData> reviewerByState(
+      String who, ReviewerStateInternal state, boolean forDefaultField)
+      throws QueryParseException, OrmException {
+    Predicate<ChangeData> reviewerByEmailPredicate = null;
+    if (args.index.getSchema().hasField(ChangeField.REVIEWER_BY_EMAIL)) {
+      Address address = Address.tryParse(who);
+      if (address != null) {
+        reviewerByEmailPredicate = ReviewerByEmailPredicate.forState(args, address, state);
+      }
+    }
+
+    Predicate<ChangeData> reviewerPredicate = null;
+    try {
+      Set<Account.Id> accounts = parseAccount(who);
+      if (!forDefaultField || accounts.size() <= MAX_ACCOUNTS_PER_DEFAULT_FIELD) {
+        reviewerPredicate =
+            Predicate.or(
+                accounts
+                    .stream()
+                    .map(id -> ReviewerPredicate.forState(args, id, state))
+                    .collect(toList()));
+      }
+    } catch (QueryParseException e) {
+      // Propagate this exception only if we can't use 'who' to query by email
+      if (reviewerByEmailPredicate == null) {
+        throw e;
+      }
+    }
+
+    if (reviewerPredicate != null && reviewerByEmailPredicate != null) {
+      return Predicate.or(reviewerPredicate, reviewerByEmailPredicate);
+    } else if (reviewerPredicate != null) {
+      return reviewerPredicate;
+    } else if (reviewerByEmailPredicate != null) {
+      return reviewerByEmailPredicate;
+    } else {
+      return Predicate.any();
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
index 91a37d5..efe44fa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
@@ -17,6 +17,8 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_LIMIT;
 
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.index.IndexConfig;
@@ -32,12 +34,26 @@
 import com.google.gerrit.server.query.QueryProcessor;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Set;
 
-public class ChangeQueryProcessor extends QueryProcessor<ChangeData> {
+public class ChangeQueryProcessor extends QueryProcessor<ChangeData>
+    implements PluginDefinedAttributesFactory {
+  /**
+   * Register a ChangeAttributeFactory in a config Module like this:
+   *
+   * <p>bind(ChangeAttributeFactory.class) .annotatedWith(Exports.named("export-name"))
+   * .to(YourClass.class);
+   */
+  public interface ChangeAttributeFactory {
+    PluginDefinedInfo create(ChangeData a, ChangeQueryProcessor qp, String plugin);
+  }
+
   private final Provider<ReviewDb> db;
   private final ChangeControl.GenericFactory changeControlFactory;
   private final ChangeNotes.Factory notesFactory;
+  private final DynamicMap<ChangeAttributeFactory> attributeFactories;
 
   static {
     // It is assumed that basic rewrites do not touch visibleto predicates.
@@ -55,7 +71,8 @@
       ChangeIndexRewriter rewriter,
       Provider<ReviewDb> db,
       ChangeControl.GenericFactory changeControlFactory,
-      ChangeNotes.Factory notesFactory) {
+      ChangeNotes.Factory notesFactory,
+      DynamicMap<ChangeAttributeFactory> attributeFactories) {
     super(
         userProvider,
         metrics,
@@ -67,6 +84,7 @@
     this.db = db;
     this.changeControlFactory = changeControlFactory;
     this.notesFactory = notesFactory;
+    this.attributeFactories = attributeFactories;
   }
 
   @Override
@@ -82,6 +100,30 @@
   }
 
   @Override
+  public List<PluginDefinedInfo> create(ChangeData cd) {
+    List<PluginDefinedInfo> plugins = new ArrayList<>(attributeFactories.plugins().size());
+    for (String plugin : attributeFactories.plugins()) {
+      for (Provider<ChangeAttributeFactory> provider :
+          attributeFactories.byPlugin(plugin).values()) {
+        PluginDefinedInfo pda = null;
+        try {
+          pda = provider.get().create(cd, this, plugin);
+        } catch (RuntimeException e) {
+          /* Eat runtime exceptions so that queries don't fail. */
+        }
+        if (pda != null) {
+          pda.name = plugin;
+          plugins.add(pda);
+        }
+      }
+    }
+    if (plugins.isEmpty()) {
+      plugins = null;
+    }
+    return plugins;
+  }
+
+  @Override
   protected Predicate<ChangeData> enforceVisibility(Predicate<ChangeData> pred) {
     return new AndChangeSource(
         pred,
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 9c16777..562608e 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
@@ -36,9 +36,9 @@
  * <p>Status names are looked up by prefix case-insensitively.
  */
 public final class ChangeStatusPredicate extends ChangeIndexPredicate {
-  private static final TreeMap<String, Predicate<ChangeData>> PREDICATES;
-  private static final Predicate<ChangeData> CLOSED;
-  private static final Predicate<ChangeData> OPEN;
+  protected static final TreeMap<String, Predicate<ChangeData>> PREDICATES;
+  protected static final Predicate<ChangeData> CLOSED;
+  protected static final Predicate<ChangeData> OPEN;
 
   static {
     PREDICATES = new TreeMap<>();
@@ -84,9 +84,9 @@
     return CLOSED;
   }
 
-  private final Change.Status status;
+  protected final Change.Status status;
 
-  ChangeStatusPredicate(Change.Status status) {
+  public ChangeStatusPredicate(Change.Status status) {
     super(ChangeField.STATUS, canonicalize(status));
     this.status = status;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java
index 668c6f2..7ad7afe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java
@@ -21,10 +21,10 @@
 import com.google.gwtorm.server.OrmException;
 import java.util.Objects;
 
-class CommentByPredicate extends ChangeIndexPredicate {
-  private final Account.Id id;
+public class CommentByPredicate extends ChangeIndexPredicate {
+  protected final Account.Id id;
 
-  CommentByPredicate(Account.Id id) {
+  public CommentByPredicate(Account.Id id) {
     super(ChangeField.COMMENTBY, id.toString());
     this.id = id;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java
index 4779a16..85efe90 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java
@@ -21,10 +21,10 @@
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
-class CommentPredicate extends ChangeIndexPredicate {
-  private final ChangeIndex index;
+public class CommentPredicate extends ChangeIndexPredicate {
+  protected final ChangeIndex index;
 
-  CommentPredicate(ChangeIndex index, String value) {
+  public CommentPredicate(ChangeIndex index, String value) {
     super(ChangeField.COMMENT, value);
     this.index = index;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitPredicate.java
index 1188d5d..3fac217 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitPredicate.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.server.index.FieldDef;
 import com.google.gwtorm.server.OrmException;
 
-class CommitPredicate extends ChangeIndexPredicate {
+public class CommitPredicate extends ChangeIndexPredicate {
   static FieldDef<ChangeData, ?> commitField(String id) {
     if (id.length() == OBJECT_ID_STRING_LENGTH) {
       return EXACT_COMMIT;
@@ -30,7 +30,7 @@
     return COMMIT;
   }
 
-  CommitPredicate(String id) {
+  public CommitPredicate(String id) {
     super(commitField(id), id);
   }
 
@@ -45,7 +45,7 @@
     return false;
   }
 
-  private boolean equals(PatchSet p, String id) {
+  protected boolean equals(PatchSet p, String id) {
     boolean exact = getField() == EXACT_COMMIT;
     String rev = p.getRevision() != null ? p.getRevision().get() : null;
     return (exact && id.equals(rev)) || (!exact && rev != null && rev.startsWith(id));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitterPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitterPredicate.java
index cd1f3b2..797cb9d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitterPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitterPredicate.java
@@ -22,7 +22,7 @@
 import java.io.IOException;
 
 public class CommitterPredicate extends ChangeIndexPredicate {
-  CommitterPredicate(String value) {
+  public CommitterPredicate(String value) {
     super(COMMITTER, FIELD_COMMITTER, value.toLowerCase());
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
index 9b45890..4d8c6a1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
@@ -45,19 +45,19 @@
 import org.eclipse.jgit.treewalk.TreeWalk;
 import org.eclipse.jgit.treewalk.filter.TreeFilter;
 
-class ConflictsPredicate extends OrPredicate<ChangeData> {
+public class ConflictsPredicate extends OrPredicate<ChangeData> {
   // UI code may depend on this string, so use caution when changing.
-  private static final String TOO_MANY_FILES = "too many files to find conflicts";
+  protected static final String TOO_MANY_FILES = "too many files to find conflicts";
 
-  private final String value;
+  protected final String value;
 
-  ConflictsPredicate(Arguments args, String value, List<Change> changes)
+  public ConflictsPredicate(Arguments args, String value, List<Change> changes)
       throws QueryParseException, OrmException {
     super(predicates(args, value, changes));
     this.value = value;
   }
 
-  private static List<Predicate<ChangeData>> predicates(
+  public static List<Predicate<ChangeData>> predicates(
       final Arguments args, String value, List<Change> changes)
       throws QueryParseException, OrmException {
     int indexTerms = 0;
@@ -160,7 +160,7 @@
     return changePredicates;
   }
 
-  private static List<String> listFiles(Change c, Arguments args, ChangeDataCache changeDataCache)
+  public static List<String> listFiles(Change c, Arguments args, ChangeDataCache changeDataCache)
       throws OrmException {
     try (Repository repo = args.repoManager.openRepository(c.getProject());
         RevWalk rw = new RevWalk(repo)) {
@@ -200,17 +200,17 @@
     return ChangeQueryBuilder.FIELD_CONFLICTS + ":" + value;
   }
 
-  private static class ChangeDataCache {
-    private final Change change;
-    private final Provider<ReviewDb> db;
-    private final ChangeData.Factory changeDataFactory;
-    private final ProjectCache projectCache;
+  public static class ChangeDataCache {
+    protected final Change change;
+    protected final Provider<ReviewDb> db;
+    protected final ChangeData.Factory changeDataFactory;
+    protected final ProjectCache projectCache;
 
-    private ObjectId testAgainst;
-    private ProjectState projectState;
-    private Iterable<ObjectId> alreadyAccepted;
+    protected ObjectId testAgainst;
+    protected ProjectState projectState;
+    protected Iterable<ObjectId> alreadyAccepted;
 
-    ChangeDataCache(
+    public ChangeDataCache(
         Change change,
         Provider<ReviewDb> db,
         ChangeData.Factory changeDataFactory,
@@ -221,7 +221,7 @@
       this.projectCache = projectCache;
     }
 
-    ObjectId getTestAgainst() throws OrmException {
+    protected ObjectId getTestAgainst() throws OrmException {
       if (testAgainst == null) {
         testAgainst =
             ObjectId.fromString(
@@ -230,7 +230,7 @@
       return testAgainst;
     }
 
-    ProjectState getProjectState() {
+    protected ProjectState getProjectState() {
       if (projectState == null) {
         projectState = projectCache.get(change.getProject());
         if (projectState == null) {
@@ -240,7 +240,7 @@
       return projectState;
     }
 
-    Iterable<ObjectId> getAlreadyAccepted(Repository repo) throws IOException {
+    protected Iterable<ObjectId> getAlreadyAccepted(Repository repo) throws IOException {
       if (alreadyAccepted == null) {
         alreadyAccepted = SubmitDryRun.getAlreadyAccepted(repo);
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeletedPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeletedPredicate.java
index 9e49269..9c46da8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeletedPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeletedPredicate.java
@@ -19,7 +19,7 @@
 import com.google.gwtorm.server.OrmException;
 
 public class DeletedPredicate extends IntegerRangeChangePredicate {
-  DeletedPredicate(String value) throws QueryParseException {
+  public DeletedPredicate(String value) throws QueryParseException {
     super(ChangeField.DELETED, value);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeltaPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeltaPredicate.java
index ce33225..68a4b84 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeltaPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeltaPredicate.java
@@ -19,7 +19,7 @@
 import com.google.gwtorm.server.OrmException;
 
 public class DeltaPredicate extends IntegerRangeChangePredicate {
-  DeltaPredicate(String value) throws QueryParseException {
+  public DeltaPredicate(String value) throws QueryParseException {
     super(ChangeField.DELTA, value);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java
index 809e7a1..4e8d30d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java
@@ -19,10 +19,10 @@
 import com.google.gwtorm.server.OrmException;
 import java.util.Set;
 
-class DestinationPredicate extends ChangeOperatorPredicate {
-  Set<Branch.NameKey> destinations;
+public class DestinationPredicate extends ChangeOperatorPredicate {
+  protected Set<Branch.NameKey> destinations;
 
-  DestinationPredicate(Set<Branch.NameKey> destinations, String value) {
+  public DestinationPredicate(Set<Branch.NameKey> destinations, String value) {
     super(ChangeQueryBuilder.FIELD_DESTINATION, value);
     this.destinations = destinations;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EditByPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EditByPredicate.java
index 8be5235..3238dc9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EditByPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EditByPredicate.java
@@ -18,10 +18,10 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class EditByPredicate extends ChangeIndexPredicate {
-  private final Account.Id id;
+public class EditByPredicate extends ChangeIndexPredicate {
+  protected final Account.Id id;
 
-  EditByPredicate(Account.Id id) {
+  public EditByPredicate(Account.Id id) {
     super(ChangeField.EDITBY, id.toString());
     this.id = id;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
index fb6c56b..66958695 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
@@ -19,8 +19,8 @@
 import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
 import com.google.gwtorm.server.OrmException;
 
-class EqualsFilePredicate extends ChangeIndexPredicate {
-  static Predicate<ChangeData> create(Arguments args, String value) {
+public class EqualsFilePredicate extends ChangeIndexPredicate {
+  public static Predicate<ChangeData> create(Arguments args, String value) {
     Predicate<ChangeData> eqPath = new EqualsPathPredicate(ChangeQueryBuilder.FIELD_FILE, value);
     if (!args.getSchema().hasField(ChangeField.FILE_PART)) {
       return eqPath;
@@ -28,11 +28,8 @@
     return Predicate.or(eqPath, new EqualsFilePredicate(value));
   }
 
-  private final String value;
-
   private EqualsFilePredicate(String value) {
     super(ChangeField.FILE_PART, ChangeQueryBuilder.FIELD_FILE, value);
-    this.value = value;
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index 1189e87..1917d6f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
@@ -24,26 +23,28 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Provider;
 
-class EqualsLabelPredicate extends ChangeIndexPredicate {
-  private final ProjectCache projectCache;
-  private final ChangeControl.GenericFactory ccFactory;
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final Provider<ReviewDb> dbProvider;
-  private final String label;
-  private final int expVal;
-  private final Account.Id account;
-  private final AccountGroup.UUID group;
+public class EqualsLabelPredicate extends ChangeIndexPredicate {
+  protected final ProjectCache projectCache;
+  protected final PermissionBackend permissionBackend;
+  protected final IdentifiedUser.GenericFactory userFactory;
+  protected final Provider<ReviewDb> dbProvider;
+  protected final String label;
+  protected final int expVal;
+  protected final Account.Id account;
+  protected final AccountGroup.UUID group;
 
-  EqualsLabelPredicate(LabelPredicate.Args args, String label, int expVal, Account.Id account) {
-    super(args.field, ChangeField.formatLabel(label, expVal, account));
-    this.ccFactory = args.ccFactory;
+  public EqualsLabelPredicate(
+      LabelPredicate.Args args, String label, int expVal, Account.Id account) {
+    super(ChangeField.LABEL, ChangeField.formatLabel(label, expVal, account));
+    this.permissionBackend = args.permissionBackend;
     this.projectCache = args.projectCache;
     this.userFactory = args.userFactory;
     this.dbProvider = args.dbProvider;
@@ -78,7 +79,7 @@
     for (PatchSetApproval p : object.currentApprovals()) {
       if (labelType.matches(p)) {
         hasVote = true;
-        if (match(c, p.getValue(), p.getAccountId(), labelType)) {
+        if (match(object, p.getValue(), p.getAccountId(), labelType)) {
           return true;
         }
       }
@@ -91,7 +92,7 @@
     return false;
   }
 
-  private static LabelType type(LabelTypes types, String toFind) {
+  protected static LabelType type(LabelTypes types, String toFind) {
     if (types.byLabel(toFind) != null) {
       return types.byLabel(toFind);
     }
@@ -104,40 +105,28 @@
     return null;
   }
 
-  private boolean match(Change change, int value, Account.Id approver, LabelType type)
-      throws OrmException {
-    int psVal = value;
-    if (psVal == expVal) {
-      // Double check the value is still permitted for the user.
-      //
-      IdentifiedUser reviewer = userFactory.create(approver);
-      try {
-        ChangeControl cc = ccFactory.controlFor(dbProvider.get(), change, reviewer);
-        if (!cc.isVisible(dbProvider.get())) {
-          // The user can't see the change anymore.
-          //
-          return false;
-        }
-        psVal = cc.getRange(Permission.forLabel(type.getName())).squash(psVal);
-      } catch (NoSuchChangeException e) {
-        // The project has disappeared.
-        //
-        return false;
-      }
-
-      if (account != null && !account.equals(approver)) {
-        return false;
-      }
-
-      if (group != null && !reviewer.getEffectiveGroups().contains(group)) {
-        return false;
-      }
-
-      if (psVal == expVal) {
-        return true;
-      }
+  protected boolean match(ChangeData cd, short value, Account.Id approver, LabelType type) {
+    if (value != expVal) {
+      return false;
     }
-    return false;
+
+    if (account != null && !account.equals(approver)) {
+      return false;
+    }
+
+    IdentifiedUser reviewer = userFactory.create(approver);
+    if (group != null && !reviewer.getEffectiveGroups().contains(group)) {
+      return false;
+    }
+
+    // Double check the value is still permitted for the user.
+    try {
+      PermissionBackend.ForChange perm =
+          permissionBackend.user(reviewer).database(dbProvider).change(cd);
+      return perm.test(ChangePermission.READ) && expVal == perm.squashByTest(type, value);
+    } catch (PermissionBackendException e) {
+      return false;
+    }
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
index 9d841f3..56ed797 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
@@ -19,12 +19,9 @@
 import java.util.Collections;
 import java.util.List;
 
-class EqualsPathPredicate extends ChangeIndexPredicate {
-  private final String value;
-
-  EqualsPathPredicate(String fieldName, String value) {
+public class EqualsPathPredicate extends ChangeIndexPredicate {
+  public EqualsPathPredicate(String fieldName, String value) {
     super(ChangeField.PATH, fieldName, value);
-    this.value = value;
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactAuthorPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactAuthorPredicate.java
new file mode 100644
index 0000000..bca5d3b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactAuthorPredicate.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2017 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.query.change;
+
+import static com.google.gerrit.server.index.change.ChangeField.EXACT_AUTHOR;
+import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_EXACTAUTHOR;
+
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+import java.util.Locale;
+
+public class ExactAuthorPredicate extends ChangeIndexPredicate {
+  public ExactAuthorPredicate(String value) {
+    super(EXACT_AUTHOR, FIELD_EXACTAUTHOR, value.toLowerCase(Locale.US));
+  }
+
+  @Override
+  public boolean match(ChangeData object) throws OrmException {
+    try {
+      return ChangeField.getAuthorNameAndEmail(object).contains(getValue());
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactCommitterPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactCommitterPredicate.java
new file mode 100644
index 0000000..3fae5e5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactCommitterPredicate.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2017 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.query.change;
+
+import static com.google.gerrit.server.index.change.ChangeField.EXACT_COMMITTER;
+import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_EXACTCOMMITTER;
+
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+import java.util.Locale;
+
+public class ExactCommitterPredicate extends ChangeIndexPredicate {
+  public ExactCommitterPredicate(String value) {
+    super(EXACT_COMMITTER, FIELD_EXACTCOMMITTER, value.toLowerCase(Locale.US));
+  }
+
+  @Override
+  public boolean match(ChangeData object) throws OrmException {
+    try {
+      return ChangeField.getCommitterNameAndEmail(object).contains(getValue());
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
index 510910e..dc85ece 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
@@ -19,8 +19,8 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gwtorm.server.OrmException;
 
-class ExactTopicPredicate extends ChangeIndexPredicate {
-  ExactTopicPredicate(String topic) {
+public class ExactTopicPredicate extends ChangeIndexPredicate {
+  public ExactTopicPredicate(String topic) {
     super(EXACT_TOPIC, topic);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
index 5651544..5f3b621 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
@@ -24,10 +24,10 @@
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
-class FuzzyTopicPredicate extends ChangeIndexPredicate {
-  private final ChangeIndex index;
+public class FuzzyTopicPredicate extends ChangeIndexPredicate {
+  protected final ChangeIndex index;
 
-  FuzzyTopicPredicate(String topic, ChangeIndex index) {
+  public FuzzyTopicPredicate(String topic, ChangeIndex index) {
     super(FUZZY_TOPIC, topic);
     this.index = index;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java
index 54e1c97..d2645dc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java
@@ -19,8 +19,8 @@
 import com.google.gwtorm.server.OrmException;
 import java.util.List;
 
-class GroupPredicate extends ChangeIndexPredicate {
-  GroupPredicate(String group) {
+public class GroupPredicate extends ChangeIndexPredicate {
+  public GroupPredicate(String group) {
     super(ChangeField.GROUP, group);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
index 244589c..e422b74 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
@@ -18,10 +18,10 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class HasDraftByPredicate extends ChangeIndexPredicate {
-  private final Account.Id accountId;
+public class HasDraftByPredicate extends ChangeIndexPredicate {
+  protected final Account.Id accountId;
 
-  HasDraftByPredicate(Account.Id accountId) {
+  public HasDraftByPredicate(Account.Id accountId) {
     super(ChangeField.DRAFTBY, accountId.toString());
     this.accountId = accountId;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasStarsPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasStarsPredicate.java
index eb3a137..b17fffd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasStarsPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasStarsPredicate.java
@@ -19,9 +19,9 @@
 import com.google.gwtorm.server.OrmException;
 
 public class HasStarsPredicate extends ChangeIndexPredicate {
-  private final Account.Id accountId;
+  protected final Account.Id accountId;
 
-  HasStarsPredicate(Account.Id accountId) {
+  public HasStarsPredicate(Account.Id accountId) {
     super(ChangeField.STARBY, accountId.toString());
     this.accountId = accountId;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HashtagPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HashtagPredicate.java
index 4fd4156..a348d48 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HashtagPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HashtagPredicate.java
@@ -18,8 +18,8 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class HashtagPredicate extends ChangeIndexPredicate {
-  HashtagPredicate(String hashtag) {
+public class HashtagPredicate extends ChangeIndexPredicate {
+  public HashtagPredicate(String hashtag) {
     super(ChangeField.HASHTAG, HashtagsUtil.cleanupHashtag(hashtag));
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergePredicate.java
index 50e5bd9..28fb7cf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergePredicate.java
@@ -24,7 +24,7 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 
 public class IsMergePredicate extends ChangeOperatorPredicate {
-  private final Arguments args;
+  protected final Arguments args;
 
   public IsMergePredicate(Arguments args, String value) {
     super(ChangeQueryBuilder.FIELD_MERGE, value);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
index 92de09a..8b6c8e6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
@@ -25,14 +25,14 @@
 import java.util.List;
 import java.util.Set;
 
-class IsReviewedPredicate extends ChangeIndexPredicate {
-  private static final Account.Id NOT_REVIEWED = new Account.Id(ChangeField.NOT_REVIEWED);
+public class IsReviewedPredicate extends ChangeIndexPredicate {
+  protected static final Account.Id NOT_REVIEWED = new Account.Id(ChangeField.NOT_REVIEWED);
 
-  static Predicate<ChangeData> create() {
+  public static Predicate<ChangeData> create() {
     return Predicate.not(new IsReviewedPredicate(NOT_REVIEWED));
   }
 
-  static Predicate<ChangeData> create(Collection<Account.Id> ids) {
+  public static Predicate<ChangeData> create(Collection<Account.Id> ids) {
     List<Predicate<ChangeData>> predicates = new ArrayList<>(ids.size());
     for (Account.Id id : ids) {
       predicates.add(new IsReviewedPredicate(id));
@@ -40,7 +40,7 @@
     return Predicate.or(predicates);
   }
 
-  private final Account.Id id;
+  protected final Account.Id id;
 
   private IsReviewedPredicate(Account.Id id) {
     super(REVIEWEDBY, Integer.toString(id.get()));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java
index 17a6347..e9b2899 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java
@@ -19,11 +19,11 @@
 import com.google.gwtorm.server.OrmException;
 
 public class IsUnresolvedPredicate extends IntegerRangeChangePredicate {
-  IsUnresolvedPredicate() throws QueryParseException {
+  public IsUnresolvedPredicate() throws QueryParseException {
     this(">0");
   }
 
-  IsUnresolvedPredicate(String value) throws QueryParseException {
+  public IsUnresolvedPredicate(String value) throws QueryParseException {
     super(ChangeField.UNRESOLVED_COMMENT_COUNT, value);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
index dda834b..a1a5070 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
@@ -26,23 +26,23 @@
 import java.util.Collections;
 import java.util.List;
 
-class IsWatchedByPredicate extends AndPredicate<ChangeData> {
-  private static String describe(CurrentUser user) {
+public class IsWatchedByPredicate extends AndPredicate<ChangeData> {
+  protected static String describe(CurrentUser user) {
     if (user.isIdentifiedUser()) {
       return user.getAccountId().toString();
     }
     return user.toString();
   }
 
-  private final CurrentUser user;
+  protected final CurrentUser user;
 
-  IsWatchedByPredicate(ChangeQueryBuilder.Arguments args, boolean checkIsVisible)
+  public IsWatchedByPredicate(ChangeQueryBuilder.Arguments args, boolean checkIsVisible)
       throws QueryParseException {
     super(filters(args, checkIsVisible));
     this.user = args.getUser();
   }
 
-  private static List<Predicate<ChangeData>> filters(
+  protected static List<Predicate<ChangeData>> filters(
       ChangeQueryBuilder.Arguments args, boolean checkIsVisible) throws QueryParseException {
     List<Predicate<ChangeData>> r = new ArrayList<>();
     ChangeQueryBuilder builder = new ChangeQueryBuilder(args);
@@ -89,7 +89,7 @@
     }
   }
 
-  private static Collection<ProjectWatchKey> getWatches(ChangeQueryBuilder.Arguments args)
+  protected static Collection<ProjectWatchKey> getWatches(ChangeQueryBuilder.Arguments args)
       throws QueryParseException {
     CurrentUser user = args.getUser();
     if (user.isIdentifiedUser()) {
@@ -98,7 +98,7 @@
     return Collections.<ProjectWatchKey>emptySet();
   }
 
-  private static List<Predicate<ChangeData>> none() {
+  protected static List<Predicate<ChangeData>> none() {
     Predicate<ChangeData> any = any();
     return ImmutableList.of(not(any));
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LabelPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LabelPredicate.java
index 2fbaa1e..bd342d7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LabelPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LabelPredicate.java
@@ -19,8 +19,7 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.index.FieldDef;
-import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.OrPredicate;
@@ -34,29 +33,29 @@
 import java.util.Set;
 
 public class LabelPredicate extends OrPredicate<ChangeData> {
-  private static final int MAX_LABEL_VALUE = 4;
+  protected static final int MAX_LABEL_VALUE = 4;
 
-  static class Args {
-    final FieldDef<ChangeData, ?> field;
-    final ProjectCache projectCache;
-    final ChangeControl.GenericFactory ccFactory;
-    final IdentifiedUser.GenericFactory userFactory;
-    final Provider<ReviewDb> dbProvider;
-    final String value;
-    final Set<Account.Id> accounts;
-    final AccountGroup.UUID group;
+  protected static class Args {
+    protected final ProjectCache projectCache;
+    protected final PermissionBackend permissionBackend;
+    protected final ChangeControl.GenericFactory ccFactory;
+    protected final IdentifiedUser.GenericFactory userFactory;
+    protected final Provider<ReviewDb> dbProvider;
+    protected final String value;
+    protected final Set<Account.Id> accounts;
+    protected final AccountGroup.UUID group;
 
-    private Args(
-        FieldDef<ChangeData, ?> field,
+    protected Args(
         ProjectCache projectCache,
+        PermissionBackend permissionBackend,
         ChangeControl.GenericFactory ccFactory,
         IdentifiedUser.GenericFactory userFactory,
         Provider<ReviewDb> dbProvider,
         String value,
         Set<Account.Id> accounts,
         AccountGroup.UUID group) {
-      this.field = field;
       this.projectCache = projectCache;
+      this.permissionBackend = permissionBackend;
       this.ccFactory = ccFactory;
       this.userFactory = userFactory;
       this.dbProvider = dbProvider;
@@ -66,22 +65,21 @@
     }
   }
 
-  private static class Parsed {
-    private final String label;
-    private final String test;
-    private final int expVal;
+  protected static class Parsed {
+    protected final String label;
+    protected final String test;
+    protected final int expVal;
 
-    private Parsed(String label, String test, int expVal) {
+    protected Parsed(String label, String test, int expVal) {
       this.label = label;
       this.test = test;
       this.expVal = expVal;
     }
   }
 
-  private final String value;
+  protected final String value;
 
-  @SuppressWarnings("deprecation")
-  LabelPredicate(
+  public LabelPredicate(
       ChangeQueryBuilder.Arguments a,
       String value,
       Set<Account.Id> accounts,
@@ -89,8 +87,8 @@
     super(
         predicates(
             new Args(
-                a.getSchema().getField(ChangeField.LABEL2, ChangeField.LABEL).get(),
                 a.projectCache,
+                a.permissionBackend,
                 a.changeControlGenericFactory,
                 a.userFactory,
                 a.db,
@@ -100,7 +98,7 @@
     this.value = value;
   }
 
-  private static List<Predicate<ChangeData>> predicates(Args args) {
+  protected static List<Predicate<ChangeData>> predicates(Args args) {
     String v = args.value;
     Parsed parsed = null;
 
@@ -140,14 +138,14 @@
     return r;
   }
 
-  private static Predicate<ChangeData> onePredicate(Args args, String label, int expVal) {
+  protected static Predicate<ChangeData> onePredicate(Args args, String label, int expVal) {
     if (expVal != 0) {
       return equalsLabelPredicate(args, label, expVal);
     }
     return noLabelQuery(args, label);
   }
 
-  private static Predicate<ChangeData> noLabelQuery(Args args, String label) {
+  protected static Predicate<ChangeData> noLabelQuery(Args args, String label) {
     List<Predicate<ChangeData>> r = Lists.newArrayListWithCapacity(2 * MAX_LABEL_VALUE);
     for (int i = 1; i <= MAX_LABEL_VALUE; i++) {
       r.add(equalsLabelPredicate(args, label, i));
@@ -156,7 +154,7 @@
     return not(or(r));
   }
 
-  private static Predicate<ChangeData> equalsLabelPredicate(Args args, String label, int expVal) {
+  protected static Predicate<ChangeData> equalsLabelPredicate(Args args, String label, int expVal) {
     if (args.accounts == null || args.accounts.isEmpty()) {
       return new EqualsLabelPredicate(args, label, expVal, null);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
index f7f98d5..7cc8b31 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
@@ -20,7 +20,7 @@
 
 /** Predicate over change number (aka legacy ID or Change.Id). */
 public class LegacyChangeIdPredicate extends ChangeIndexPredicate {
-  private final Change.Id id;
+  protected final Change.Id id;
 
   public LegacyChangeIdPredicate(Change.Id id) {
     super(LEGACY_ID, ChangeQueryBuilder.FIELD_CHANGE, id.toString());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java
index 9e525c2..92d1ed3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java
@@ -22,10 +22,10 @@
 import com.google.gwtorm.server.OrmException;
 
 /** Predicate to match changes that contains specified text in commit messages body. */
-class MessagePredicate extends ChangeIndexPredicate {
-  private final ChangeIndex index;
+public class MessagePredicate extends ChangeIndexPredicate {
+  protected final ChangeIndex index;
 
-  MessagePredicate(ChangeIndex index, String value) {
+  public MessagePredicate(ChangeIndex index, String value) {
     super(ChangeField.COMMIT_MESSAGE, value);
     this.index = index;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index cd98087..0d12132 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -313,6 +313,7 @@
       eventFactory.addDependencies(rw, c, d.change(), d.currentPatchSet());
     }
 
+    c.plugins = queryProcessor.create(d);
     return c;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerPredicate.java
index dfaac08..5fd1ca0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerPredicate.java
@@ -19,15 +19,15 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class OwnerPredicate extends ChangeIndexPredicate {
-  private final Account.Id id;
+public class OwnerPredicate extends ChangeIndexPredicate {
+  protected final Account.Id id;
 
-  OwnerPredicate(Account.Id id) {
+  public OwnerPredicate(Account.Id id) {
     super(ChangeField.OWNER, id.toString());
     this.id = id;
   }
 
-  Account.Id getAccountId() {
+  protected Account.Id getAccountId() {
     return id;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerinPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
index f3239af..f828970 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
@@ -19,17 +19,17 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gwtorm.server.OrmException;
 
-class OwnerinPredicate extends ChangeOperatorPredicate {
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final AccountGroup.UUID uuid;
+public class OwnerinPredicate extends ChangeOperatorPredicate {
+  protected final IdentifiedUser.GenericFactory userFactory;
+  protected final AccountGroup.UUID uuid;
 
-  OwnerinPredicate(IdentifiedUser.GenericFactory userFactory, AccountGroup.UUID uuid) {
+  public OwnerinPredicate(IdentifiedUser.GenericFactory userFactory, AccountGroup.UUID uuid) {
     super(ChangeQueryBuilder.FIELD_OWNERIN, uuid.toString());
     this.userFactory = userFactory;
     this.uuid = uuid;
   }
 
-  AccountGroup.UUID getAccountGroupUUID() {
+  protected AccountGroup.UUID getAccountGroupUUID() {
     return uuid;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
index d3a3f20..3b00c0a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ListChildProjects;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectResource;
@@ -27,11 +28,15 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
-class ParentProjectPredicate extends OrPredicate<ChangeData> {
-  private final String value;
+public class ParentProjectPredicate extends OrPredicate<ChangeData> {
+  private static final Logger log = LoggerFactory.getLogger(ParentProjectPredicate.class);
 
-  ParentProjectPredicate(
+  protected final String value;
+
+  public ParentProjectPredicate(
       ProjectCache projectCache,
       Provider<ListChildProjects> listChildProjects,
       Provider<CurrentUser> self,
@@ -40,7 +45,7 @@
     this.value = value;
   }
 
-  private static List<Predicate<ChangeData>> predicates(
+  protected static List<Predicate<ChangeData>> predicates(
       ProjectCache projectCache,
       Provider<ListChildProjects> listChildProjects,
       Provider<CurrentUser> self,
@@ -52,10 +57,15 @@
 
     List<Predicate<ChangeData>> r = new ArrayList<>();
     r.add(new ProjectPredicate(projectState.getProject().getName()));
-    ListChildProjects children = listChildProjects.get();
-    children.setRecursive(true);
-    for (ProjectInfo p : children.apply(new ProjectResource(projectState.controlFor(self.get())))) {
-      r.add(new ProjectPredicate(p.name));
+    try {
+      ProjectResource proj = new ProjectResource(projectState.controlFor(self.get()));
+      ListChildProjects children = listChildProjects.get();
+      children.setRecursive(true);
+      for (ProjectInfo p : children.apply(proj)) {
+        r.add(new ProjectPredicate(p.name));
+      }
+    } catch (PermissionBackendException e) {
+      log.warn("cannot check permissions to expand child projects", e);
     }
     return r;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/PluginDefinedAttributesFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/PluginDefinedAttributesFactory.java
new file mode 100644
index 0000000..a795025
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/PluginDefinedAttributesFactory.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.query.change;
+
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import java.util.List;
+
+public interface PluginDefinedAttributesFactory {
+  List<PluginDefinedInfo> create(ChangeData cd);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPredicate.java
index 644870d..ef25ddb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPredicate.java
@@ -19,12 +19,12 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class ProjectPredicate extends ChangeIndexPredicate {
-  ProjectPredicate(String id) {
+public class ProjectPredicate extends ChangeIndexPredicate {
+  public ProjectPredicate(String id) {
     super(ChangeField.PROJECT, id);
   }
 
-  Project.NameKey getValueKey() {
+  protected Project.NameKey getValueKey() {
     return new Project.NameKey(getValue());
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
index 4c06d1b..28b1302 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
@@ -18,8 +18,8 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class ProjectPrefixPredicate extends ChangeIndexPredicate {
-  ProjectPrefixPredicate(String prefix) {
+public class ProjectPrefixPredicate extends ChangeIndexPredicate {
+  public ProjectPrefixPredicate(String prefix) {
     super(ChangeField.PROJECTS, prefix);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java
index 7eccf45..f0ef40d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java
@@ -137,13 +137,18 @@
 
     int cnt = queries.size();
     List<QueryResult<ChangeData>> results = imp.query(qb.parse(queries));
+
     boolean requireLazyLoad =
         containsAnyOf(options, ImmutableSet.of(DETAILED_LABELS, LABELS))
             && !qb.getArgs().getSchema().hasField(ChangeField.STORED_SUBMIT_RECORD_LENIENT);
+
+    ChangeJson cjson = json.create(options);
+    cjson.setPluginDefinedAttributesFactory(this.imp);
     List<List<ChangeInfo>> res =
-        json.create(options)
+        cjson
             .lazyLoad(requireLazyLoad || containsAnyOf(options, ChangeJson.REQUIRE_LAZY_LOAD))
             .formatQueryResults(results);
+
     for (int n = 0; n < cnt; n++) {
       List<ChangeInfo> info = res.get(n);
       if (results.get(n).more()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RefPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RefPredicate.java
index 491aed9..b8bece9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RefPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RefPredicate.java
@@ -18,8 +18,8 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class RefPredicate extends ChangeIndexPredicate {
-  RefPredicate(String ref) {
+public class RefPredicate extends ChangeIndexPredicate {
+  public RefPredicate(String ref) {
     super(ChangeField.REF, ref);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexPathPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
index 5b9774c..ca21247 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
@@ -19,8 +19,8 @@
 import com.google.gwtorm.server.OrmException;
 import java.util.List;
 
-class RegexPathPredicate extends ChangeRegexPredicate {
-  RegexPathPredicate(String re) {
+public class RegexPathPredicate extends ChangeRegexPredicate {
+  public RegexPathPredicate(String re) {
     super(ChangeField.PATH, re);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
index 1284e88..cf78c57 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
@@ -21,10 +21,10 @@
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 
-class RegexProjectPredicate extends ChangeRegexPredicate {
-  private final RunAutomaton pattern;
+public class RegexProjectPredicate extends ChangeRegexPredicate {
+  protected final RunAutomaton pattern;
 
-  RegexProjectPredicate(String re) {
+  public RegexProjectPredicate(String re) {
     super(ChangeField.PROJECT, re);
 
     if (re.startsWith("^")) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexRefPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
index 671d4cc..ac7af9b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
@@ -20,10 +20,10 @@
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 
-class RegexRefPredicate extends ChangeRegexPredicate {
-  private final RunAutomaton pattern;
+public class RegexRefPredicate extends ChangeRegexPredicate {
+  protected final RunAutomaton pattern;
 
-  RegexRefPredicate(String re) {
+  public RegexRefPredicate(String re) {
     super(ChangeField.REF, re);
 
     if (re.startsWith("^")) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java
index a4ba059..8a9f8cd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java
@@ -21,10 +21,10 @@
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 
-class RegexTopicPredicate extends ChangeRegexPredicate {
-  private final RunAutomaton pattern;
+public class RegexTopicPredicate extends ChangeRegexPredicate {
+  protected final RunAutomaton pattern;
 
-  RegexTopicPredicate(String re) {
+  public RegexTopicPredicate(String re) {
     super(EXACT_TOPIC, re);
 
     if (re.startsWith("^")) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
new file mode 100644
index 0000000..a040e18
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2017 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.query.change;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
+import com.google.gwtorm.server.OrmException;
+
+class ReviewerByEmailPredicate extends ChangeIndexPredicate {
+
+  static Predicate<ChangeData> forState(Arguments args, Address adr, ReviewerStateInternal state) {
+    checkArgument(state != ReviewerStateInternal.REMOVED, "can't query by removed reviewer");
+    return create(args, new ReviewerByEmailPredicate(state, adr));
+  }
+
+  private final ReviewerStateInternal state;
+  private final Address adr;
+
+  private ReviewerByEmailPredicate(ReviewerStateInternal state, Address adr) {
+    super(ChangeField.REVIEWER_BY_EMAIL, ChangeField.getReviewerByEmailFieldValue(state, adr));
+    this.state = state;
+    this.adr = adr;
+  }
+
+  Address getAddress() {
+    return adr;
+  }
+
+  @Override
+  public boolean match(ChangeData cd) throws OrmException {
+    return cd.reviewersByEmail().asTable().get(state, adr) != null;
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
index 6ce02fb..f3a8619 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.query.change;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static java.util.stream.Collectors.toList;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.query.Predicate;
@@ -25,8 +25,14 @@
 import com.google.gwtorm.server.OrmException;
 import java.util.stream.Stream;
 
-class ReviewerPredicate extends ChangeIndexPredicate {
-  static Predicate<ChangeData> reviewer(Arguments args, Account.Id id) {
+public class ReviewerPredicate extends ChangeIndexPredicate {
+  protected static Predicate<ChangeData> forState(
+      Arguments args, Account.Id id, ReviewerStateInternal state) {
+    checkArgument(state != ReviewerStateInternal.REMOVED, "can't query by removed reviewer");
+    return create(args, new ReviewerPredicate(state, id));
+  }
+
+  protected static Predicate<ChangeData> reviewer(Arguments args, Account.Id id) {
     Predicate<ChangeData> p;
     if (args.notesMigration.readChanges()) {
       // With NoteDb, Reviewer/CC are clearly distinct states, so only choose reviewer.
@@ -39,14 +45,14 @@
     return create(args, p);
   }
 
-  static Predicate<ChangeData> cc(Arguments args, Account.Id id) {
+  protected static Predicate<ChangeData> cc(Arguments args, Account.Id id) {
     // As noted above, CC is nebulous without NoteDb, but it certainly doesn't make sense to return
     // Reviewers for cc:foo. Most likely this will just not match anything, but let the index sort
     // it out.
     return create(args, new ReviewerPredicate(ReviewerStateInternal.CC, id));
   }
 
-  private static Predicate<ChangeData> anyReviewerState(Account.Id id) {
+  protected static Predicate<ChangeData> anyReviewerState(Account.Id id) {
     return Predicate.or(
         Stream.of(ReviewerStateInternal.values())
             .filter(s -> s != ReviewerStateInternal.REMOVED)
@@ -54,17 +60,8 @@
             .collect(toList()));
   }
 
-  private static Predicate<ChangeData> create(Arguments args, Predicate<ChangeData> p) {
-    if (!args.allowsDrafts) {
-      // TODO(dborowitz): This really belongs much higher up e.g. QueryProcessor. Also, why are we
-      // even doing this?
-      return Predicate.and(p, Predicate.not(new ChangeStatusPredicate(Change.Status.DRAFT)));
-    }
-    return p;
-  }
-
-  private final ReviewerStateInternal state;
-  private final Account.Id id;
+  protected final ReviewerStateInternal state;
+  protected final Account.Id id;
 
   private ReviewerPredicate(ReviewerStateInternal state, Account.Id id) {
     super(ChangeField.REVIEWER, ChangeField.getReviewerFieldValue(state, id));
@@ -72,7 +69,7 @@
     this.id = id;
   }
 
-  Account.Id getAccountId() {
+  protected Account.Id getAccountId() {
     return id;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
index 63e7859..df28de3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
@@ -19,17 +19,17 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gwtorm.server.OrmException;
 
-class ReviewerinPredicate extends ChangeOperatorPredicate {
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final AccountGroup.UUID uuid;
+public class ReviewerinPredicate extends ChangeOperatorPredicate {
+  protected final IdentifiedUser.GenericFactory userFactory;
+  protected final AccountGroup.UUID uuid;
 
-  ReviewerinPredicate(IdentifiedUser.GenericFactory userFactory, AccountGroup.UUID uuid) {
+  public ReviewerinPredicate(IdentifiedUser.GenericFactory userFactory, AccountGroup.UUID uuid) {
     super(ChangeQueryBuilder.FIELD_REVIEWERIN, uuid.toString());
     this.userFactory = userFactory;
     this.uuid = uuid;
   }
 
-  AccountGroup.UUID getAccountGroupUUID() {
+  protected AccountGroup.UUID getAccountGroupUUID() {
     return uuid;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/StarPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/StarPredicate.java
index 98965bf..12d4753 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/StarPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/StarPredicate.java
@@ -20,10 +20,10 @@
 import com.google.gwtorm.server.OrmException;
 
 public class StarPredicate extends ChangeIndexPredicate {
-  private final Account.Id accountId;
-  private final String label;
+  protected final Account.Id accountId;
+  protected final String label;
 
-  StarPredicate(Account.Id accountId, String label) {
+  public StarPredicate(Account.Id accountId, String label) {
     super(ChangeField.STAR, StarredChangesUtil.StarField.create(accountId, label).toString());
     this.accountId = accountId;
     this.label = label;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
index d8d5258..5fdeb68 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
@@ -18,9 +18,8 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class SubmissionIdPredicate extends ChangeIndexPredicate {
-
-  SubmissionIdPredicate(String changeSet) {
+public class SubmissionIdPredicate extends ChangeIndexPredicate {
+  public SubmissionIdPredicate(String changeSet) {
     super(ChangeField.SUBMISSIONID, changeSet);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
index 5b01ea2..81d64e0e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
@@ -23,8 +23,8 @@
 import com.google.gwtorm.server.OrmException;
 import java.util.Set;
 
-class SubmitRecordPredicate extends ChangeIndexPredicate {
-  static Predicate<ChangeData> create(
+public class SubmitRecordPredicate extends ChangeIndexPredicate {
+  public static Predicate<ChangeData> create(
       String label, SubmitRecord.Label.Status status, Set<Account.Id> accounts) {
     String lowerLabel = label.toLowerCase();
     if (accounts == null || accounts.isEmpty()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmittablePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
index 0812c6a..df78315 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
@@ -18,10 +18,10 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class SubmittablePredicate extends ChangeIndexPredicate {
-  private final SubmitRecord.Status status;
+public class SubmittablePredicate extends ChangeIndexPredicate {
+  protected final SubmitRecord.Status status;
 
-  SubmittablePredicate(SubmitRecord.Status status) {
+  public SubmittablePredicate(SubmitRecord.Status status) {
     super(ChangeField.SUBMIT_RECORD, status.name());
     this.status = status;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
index afaea5c..6a5f260 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
@@ -24,12 +24,12 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-class TrackingIdPredicate extends ChangeIndexPredicate {
+public class TrackingIdPredicate extends ChangeIndexPredicate {
   private static final Logger log = LoggerFactory.getLogger(TrackingIdPredicate.class);
 
-  private final TrackingFooters trackingFooters;
+  protected final TrackingFooters trackingFooters;
 
-  TrackingIdPredicate(TrackingFooters trackingFooters, String trackingId) {
+  public TrackingIdPredicate(TrackingFooters trackingFooters, String trackingId) {
     super(ChangeField.TR, trackingId);
     this.trackingFooters = trackingFooters;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
index 8f72945..3ac9c39 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
@@ -23,10 +23,11 @@
 import com.google.gwtorm.server.OrmException;
 
 public class GroupIsVisibleToPredicate extends IsVisibleToPredicate<AccountGroup> {
-  private final GroupControl.GenericFactory groupControlFactory;
-  private final CurrentUser user;
+  protected final GroupControl.GenericFactory groupControlFactory;
+  protected final CurrentUser user;
 
-  GroupIsVisibleToPredicate(GroupControl.GenericFactory groupControlFactory, CurrentUser user) {
+  public GroupIsVisibleToPredicate(
+      GroupControl.GenericFactory groupControlFactory, CurrentUser user) {
     super(AccountQueryBuilder.FIELD_VISIBLETO, describe(user));
     this.groupControlFactory = groupControlFactory;
     this.user = user;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java
index 170a5fa..c5bd140 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java
@@ -161,8 +161,8 @@
     }
   }
 
-  private void exportPoolMetrics(final BasicDataSource pool) {
-    final CallbackMetric1<Boolean, Integer> cnt =
+  private void exportPoolMetrics(BasicDataSource pool) {
+    CallbackMetric1<Boolean, Integer> cnt =
         metrics.newCallbackMetric(
             "sql/connection_pool/connections",
             Integer.class,
@@ -170,13 +170,10 @@
             Field.ofBoolean("active"));
     metrics.newTrigger(
         cnt,
-        new Runnable() {
-          @Override
-          public void run() {
-            synchronized (pool) {
-              cnt.set(true, pool.getNumActive());
-              cnt.set(false, pool.getNumIdle());
-            }
+        () -> {
+          synchronized (pool) {
+            cnt.set(true, pool.getNumActive());
+            cnt.set(false, pool.getNumIdle());
           }
         });
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java
index 43e9a3a..af55b00 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java
@@ -73,6 +73,11 @@
   }
 
   @Override
+  public boolean changesTablesEnabled() {
+    return false;
+  }
+
+  @Override
   public ChangeAccess changes() {
     return changes;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java
index b60b1f7..084d63b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java
@@ -52,7 +52,10 @@
 
   @Inject
   SchemaUpdater(
-      SchemaFactory<ReviewDb> schema, SitePaths site, SchemaCreator creator, Injector parent) {
+      @ReviewDbFactory SchemaFactory<ReviewDb> schema,
+      SitePaths site,
+      SchemaCreator creator,
+      Injector parent) {
     this.schema = schema;
     this.site = site;
     this.creator = creator;
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 a67a8a9..76decec 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
@@ -35,7 +35,7 @@
 /** A version of the database schema. */
 public abstract class SchemaVersion {
   /** The current schema version. */
-  public static final Class<Schema_142> C = Schema_142.class;
+  public static final Class<Schema_150> C = Schema_150.class;
 
   public static int getBinaryVersion() {
     return guessVersion(C);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_142.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_142.java
index df808df..e67ae2f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_142.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_142.java
@@ -14,16 +14,24 @@
 
 package com.google.gerrit.server.schema;
 
-import com.google.gerrit.reviewdb.client.AccountExternalId;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+
+import com.google.common.base.Strings;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.HashedPassword;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gwtorm.jdbc.JdbcSchema;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
 import java.sql.SQLException;
-import java.util.List;
+import java.sql.Statement;
 
 public class Schema_142 extends SchemaVersion {
+  private static final int MAX_BATCH_SIZE = 1000;
+
   @Inject
   Schema_142(Provider<Schema_141> prior) {
     super(prior);
@@ -31,19 +39,39 @@
 
   @Override
   protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    List<AccountExternalId> newIds = db.accountExternalIds().all().toList();
-    for (AccountExternalId id : newIds) {
-      if (!id.isScheme(AccountExternalId.SCHEME_USERNAME)) {
-        continue;
+    try (PreparedStatement updateStmt =
+        ((JdbcSchema) db)
+            .getConnection()
+            .prepareStatement(
+                "UPDATE account_external_ids " + "SET password = ? " + "WHERE external_id = ?")) {
+      int batchCount = 0;
+
+      try (Statement stmt = newStatement(db);
+          ResultSet rs =
+              stmt.executeQuery("SELECT external_id, password FROM account_external_ids")) {
+        while (rs.next()) {
+          String externalId = rs.getString("external_id");
+          String password = rs.getString("password");
+          if (!ExternalId.Key.parse(externalId).isScheme(SCHEME_USERNAME)
+              || Strings.isNullOrEmpty(password)) {
+            continue;
+          }
+
+          HashedPassword hashed = HashedPassword.fromPassword(password);
+          updateStmt.setString(1, hashed.encode());
+          updateStmt.setString(2, externalId);
+          updateStmt.addBatch();
+          batchCount++;
+          if (batchCount >= MAX_BATCH_SIZE) {
+            updateStmt.executeBatch();
+            batchCount = 0;
+          }
+        }
       }
 
-      String password = id.getPassword();
-      if (password != null) {
-        HashedPassword hashed = HashedPassword.fromPassword(password);
-        id.setPassword(hashed.encode());
+      if (batchCount > 0) {
+        updateStmt.executeBatch();
       }
     }
-
-    db.accountExternalIds().upsert(newIds);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_143.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_143.java
new file mode 100644
index 0000000..b190b29
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_143.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2017 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;
+
+/** Add isPrivate field to change. */
+public class Schema_143 extends SchemaVersion {
+  @Inject
+  Schema_143(Provider<Schema_142> prior) {
+    super(prior);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_144.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_144.java
new file mode 100644
index 0000000..eaa97e4d5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_144.java
@@ -0,0 +1,102 @@
+// 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.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdReader;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gwtorm.jdbc.JdbcSchema;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class Schema_144 extends SchemaVersion {
+  private static final String COMMIT_MSG = "Import external IDs from ReviewDb";
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final PersonIdent serverIdent;
+
+  @Inject
+  Schema_144(
+      Provider<Schema_143> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      @GerritPersonIdent PersonIdent serverIdent) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.serverIdent = serverIdent;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    Set<ExternalId> toAdd = new HashSet<>();
+    try (Statement stmt = ((JdbcSchema) db).getConnection().createStatement();
+        ResultSet rs =
+            stmt.executeQuery(
+                "SELECT "
+                    + "account_id, "
+                    + "email_address, "
+                    + "password, "
+                    + "external_id "
+                    + "FROM account_external_ids")) {
+      while (rs.next()) {
+        Account.Id accountId = new Account.Id(rs.getInt(1));
+        String email = rs.getString(2);
+        String password = rs.getString(3);
+        String externalId = rs.getString(4);
+
+        toAdd.add(ExternalId.create(ExternalId.Key.parse(externalId), accountId, email, password));
+      }
+    }
+
+    try {
+      try (Repository repo = repoManager.openRepository(allUsersName);
+          RevWalk rw = new RevWalk(repo);
+          ObjectInserter ins = repo.newObjectInserter()) {
+        ObjectId rev = ExternalIdReader.readRevision(repo);
+
+        NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+
+        for (ExternalId extId : toAdd) {
+          ExternalIdsUpdate.upsert(rw, ins, noteMap, extId);
+        }
+
+        ExternalIdsUpdate.commit(repo, rw, ins, rev, noteMap, COMMIT_MSG, serverIdent, serverIdent);
+      }
+    } catch (IOException | ConfigInvalidException e) {
+      throw new OrmException("Failed to migrate external IDs to NoteDb", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_145.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_145.java
new file mode 100644
index 0000000..6ccb5d8
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_145.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2017 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.gerrit.reviewdb.server.ReviewDb;
+import com.google.gwtorm.jdbc.JdbcSchema;
+import com.google.gwtorm.schema.sql.SqlDialect;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.StatementExecutor;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.sql.SQLException;
+
+/** Create account_external_ids_byEmail index. */
+public class Schema_145 extends SchemaVersion {
+
+  @Inject
+  Schema_145(Provider<Schema_144> prior) {
+    super(prior);
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    JdbcSchema schema = (JdbcSchema) db;
+    SqlDialect dialect = schema.getDialect();
+    try (StatementExecutor e = newExecutor(db)) {
+      try {
+        dialect.dropIndex(e, "account_external_ids", "account_external_ids_byEmail");
+      } catch (OrmException ex) {
+        // Ignore.  The index did not exist.
+      }
+      e.execute(
+          "CREATE INDEX account_external_ids_byEmail"
+              + " ON account_external_ids"
+              + " (email_address)");
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_146.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_146.java
new file mode 100644
index 0000000..dd11396
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_146.java
@@ -0,0 +1,156 @@
+// Copyright (C) 2017 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.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.RefUpdate.Result;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Make sure that for every account a user branch exists that has an initial empty commit with the
+ * registration date as commit time.
+ *
+ * <p>For accounts that don't have a user branch yet the user branch is created with an initial
+ * empty commit that has the registration date as commit time.
+ *
+ * <p>For accounts that already have a user branch the user branch is rewritten and an initial empty
+ * commit with the registration date as commit time is inserted (if such a commit doesn't exist
+ * yet).
+ */
+public class Schema_146 extends SchemaVersion {
+  private static final String CREATE_ACCOUNT_MSG = "Create Account";
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final PersonIdent serverIdent;
+
+  @Inject
+  Schema_146(
+      Provider<Schema_145> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      @GerritPersonIdent PersonIdent serverIdent) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.serverIdent = serverIdent;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    try (Repository repo = repoManager.openRepository(allUsersName);
+        RevWalk rw = new RevWalk(repo);
+        ObjectInserter oi = repo.newObjectInserter()) {
+      ObjectId emptyTree = emptyTree(oi);
+
+      for (Account account : db.accounts().all()) {
+        String refName = RefNames.refsUsers(account.getId());
+        Ref ref = repo.exactRef(refName);
+        if (ref != null) {
+          rewriteUserBranch(repo, rw, oi, emptyTree, ref, account);
+        } else {
+          AccountsUpdate.createUserBranch(repo, oi, serverIdent, serverIdent, account);
+        }
+      }
+    } catch (IOException e) {
+      throw new OrmException("Failed to rewrite user branches.", e);
+    }
+  }
+
+  private void rewriteUserBranch(
+      Repository repo, RevWalk rw, ObjectInserter oi, ObjectId emptyTree, Ref ref, Account account)
+      throws IOException {
+    ObjectId current = createInitialEmptyCommit(oi, emptyTree, account.getRegisteredOn());
+
+    rw.reset();
+    rw.sort(RevSort.TOPO);
+    rw.sort(RevSort.REVERSE, true);
+    rw.markStart(rw.parseCommit(ref.getObjectId()));
+
+    RevCommit c;
+    while ((c = rw.next()) != null) {
+      if (isInitialEmptyCommit(emptyTree, c)) {
+        return;
+      }
+
+      CommitBuilder cb = new CommitBuilder();
+      cb.setParentId(current);
+      cb.setTreeId(c.getTree());
+      cb.setAuthor(c.getAuthorIdent());
+      cb.setCommitter(c.getCommitterIdent());
+      cb.setMessage(c.getFullMessage());
+      cb.setEncoding(c.getEncoding());
+      current = oi.insert(cb);
+    }
+
+    oi.flush();
+
+    RefUpdate ru = repo.updateRef(ref.getName());
+    ru.setExpectedOldObjectId(ref.getObjectId());
+    ru.setNewObjectId(current);
+    ru.setForceUpdate(true);
+    ru.setRefLogIdent(serverIdent);
+    ru.setRefLogMessage(getClass().getSimpleName(), true);
+    Result result = ru.update();
+    if (result != Result.FORCED) {
+      throw new IOException(
+          String.format("Failed to update ref %s: %s", ref.getName(), result.name()));
+    }
+  }
+
+  private ObjectId createInitialEmptyCommit(
+      ObjectInserter oi, ObjectId emptyTree, Timestamp registrationDate) throws IOException {
+    PersonIdent ident = new PersonIdent(serverIdent, registrationDate);
+
+    CommitBuilder cb = new CommitBuilder();
+    cb.setTreeId(emptyTree);
+    cb.setCommitter(ident);
+    cb.setAuthor(ident);
+    cb.setMessage(CREATE_ACCOUNT_MSG);
+    return oi.insert(cb);
+  }
+
+  private boolean isInitialEmptyCommit(ObjectId emptyTree, RevCommit c) {
+    return c.getParentCount() == 0
+        && c.getTree().equals(emptyTree)
+        && c.getShortMessage().equals(CREATE_ACCOUNT_MSG);
+  }
+
+  private static ObjectId emptyTree(ObjectInserter oi) throws IOException {
+    return oi.insert(Constants.OBJ_TREE, new byte[] {});
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_147.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_147.java
new file mode 100644
index 0000000..8585988
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_147.java
@@ -0,0 +1,75 @@
+// Copyright (C) 2017 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 static java.util.stream.Collectors.toSet;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.sql.SQLException;
+import java.util.Objects;
+import java.util.Set;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+/** Delete user branches for which no account exists. */
+public class Schema_147 extends SchemaVersion {
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final PersonIdent serverIdent;
+
+  @Inject
+  Schema_147(
+      Provider<Schema_146> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      @GerritPersonIdent PersonIdent serverIdent) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.serverIdent = serverIdent;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      Set<Account.Id> accountIdsFromReviewDb =
+          db.accounts().all().toList().stream().map(a -> a.getId()).collect(toSet());
+      Set<Account.Id> accountIdsFromUserBranches =
+          repo.getRefDatabase()
+              .getRefs(RefNames.REFS_USERS)
+              .values()
+              .stream()
+              .map(r -> Account.Id.fromRef(r.getName()))
+              .filter(Objects::nonNull)
+              .collect(toSet());
+      accountIdsFromUserBranches.removeAll(accountIdsFromReviewDb);
+      for (Account.Id accountId : accountIdsFromUserBranches) {
+        AccountsUpdate.deleteUserBranch(repo, serverIdent, accountId);
+      }
+    } catch (IOException e) {
+      throw new OrmException("Failed to delete user branches for non-existing accounts.", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_148.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_148.java
new file mode 100644
index 0000000..abb3bb2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_148.java
@@ -0,0 +1,99 @@
+// Copyright (C) 2017 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 static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.primitives.Ints;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdReader;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.sql.SQLException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.Note;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class Schema_148 extends SchemaVersion {
+  private static final String COMMIT_MSG = "Make account IDs of external IDs human-readable";
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final PersonIdent serverUser;
+
+  @Inject
+  Schema_148(
+      Provider<Schema_147> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      @GerritPersonIdent PersonIdent serverUser) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.serverUser = serverUser;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    try (Repository repo = repoManager.openRepository(allUsersName);
+        RevWalk rw = new RevWalk(repo);
+        ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId rev = ExternalIdReader.readRevision(repo);
+      NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+      boolean dirty = false;
+      for (Note note : noteMap) {
+        byte[] raw =
+            rw.getObjectReader()
+                .open(note.getData(), OBJ_BLOB)
+                .getCachedBytes(ExternalIdReader.MAX_NOTE_SZ);
+        try {
+          ExternalId extId = ExternalId.parse(note.getName(), raw);
+
+          if (needsUpdate(extId)) {
+            ExternalIdsUpdate.upsert(rw, ins, noteMap, extId);
+            dirty = true;
+          }
+        } catch (ConfigInvalidException e) {
+          ui.message(
+              String.format("Warning: Ignoring invalid external ID note %s", note.getName()));
+        }
+      }
+      if (dirty) {
+        ExternalIdsUpdate.commit(repo, rw, ins, rev, noteMap, COMMIT_MSG, serverUser, serverUser);
+      }
+    } catch (IOException e) {
+      throw new OrmException("Failed to update external IDs", e);
+    }
+  }
+
+  private static boolean needsUpdate(ExternalId extId) {
+    Config cfg = new Config();
+    cfg.setInt("externalId", extId.key().get(), "accountId", extId.accountId().get());
+    return Ints.tryParse(cfg.getString("externalId", extId.key().get(), "accountId")) == null;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_149.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_149.java
new file mode 100644
index 0000000..f1ccaa6
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_149.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2017 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;
+
+/** Add workInProgress field to change. */
+public class Schema_149 extends SchemaVersion {
+  @Inject
+  Schema_149(Provider<Schema_148> prior) {
+    super(prior);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_150.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_150.java
new file mode 100644
index 0000000..456a01a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_150.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2017 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;
+
+/** Drop ACCOUNT_EXTERNAL_IDS table. */
+public class Schema_150 extends SchemaVersion {
+  @Inject
+  Schema_150(Provider<Schema_149> prior) {
+    super(prior);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdate.java
index f34c22cb..fdae8e9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -17,19 +17,31 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableMultiset.toImmutableMultiset;
 
+import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Multiset;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.NoSuchRefException;
 import com.google.gerrit.server.util.RequestId;
 import com.google.inject.Inject;
 import com.google.inject.Module;
@@ -41,12 +53,14 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.TimeZone;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.PushCertificate;
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -81,43 +95,99 @@
       @Override
       public void configure() {
         factory(ReviewDbBatchUpdate.AssistedFactory.class);
+        factory(FusedNoteDbBatchUpdate.AssistedFactory.class);
+        factory(UnfusedNoteDbBatchUpdate.AssistedFactory.class);
       }
     };
   }
 
   @Singleton
   public static class Factory {
+    private final NotesMigration migration;
     private final ReviewDbBatchUpdate.AssistedFactory reviewDbBatchUpdateFactory;
+    private final FusedNoteDbBatchUpdate.AssistedFactory fusedNoteDbBatchUpdateFactory;
+    private final UnfusedNoteDbBatchUpdate.AssistedFactory unfusedNoteDbBatchUpdateFactory;
 
+    // TODO(dborowitz): Make this non-injectable to force all callers to use RetryHelper.
     @Inject
-    Factory(ReviewDbBatchUpdate.AssistedFactory reviewDbBatchUpdateFactory) {
+    Factory(
+        NotesMigration migration,
+        ReviewDbBatchUpdate.AssistedFactory reviewDbBatchUpdateFactory,
+        FusedNoteDbBatchUpdate.AssistedFactory fusedNoteDbBatchUpdateFactory,
+        UnfusedNoteDbBatchUpdate.AssistedFactory unfusedNoteDbBatchUpdateFactory) {
+      this.migration = migration;
       this.reviewDbBatchUpdateFactory = reviewDbBatchUpdateFactory;
+      this.fusedNoteDbBatchUpdateFactory = fusedNoteDbBatchUpdateFactory;
+      this.unfusedNoteDbBatchUpdateFactory = unfusedNoteDbBatchUpdateFactory;
     }
 
     public BatchUpdate create(
         ReviewDb db, Project.NameKey project, CurrentUser user, Timestamp when) {
+      if (migration.disableChangeReviewDb()) {
+        if (migration.fuseUpdates()) {
+          return fusedNoteDbBatchUpdateFactory.create(db, project, user, when);
+        }
+        return unfusedNoteDbBatchUpdateFactory.create(db, project, user, when);
+      }
       return reviewDbBatchUpdateFactory.create(db, project, user, when);
     }
 
+    @SuppressWarnings({"rawtypes", "unchecked"})
     public void execute(
         Collection<BatchUpdate> updates,
         BatchUpdateListener listener,
         @Nullable RequestId requestId,
         boolean dryRun)
         throws UpdateException, RestApiException {
+      checkNotNull(listener);
+      checkDifferentProject(updates);
       // It's safe to downcast all members of the input collection in this case, because the only
       // way a caller could have gotten any BatchUpdates in the first place is to call the create
       // method above, which always returns instances of the type we expect. Just to be safe,
       // copy them into an ImmutableList so there is no chance the callee can pollute the input
       // collection.
-      @SuppressWarnings({"rawtypes", "unchecked"})
-      ImmutableList<ReviewDbBatchUpdate> reviewDbUpdates =
-          (ImmutableList) ImmutableList.copyOf(updates);
-      ReviewDbBatchUpdate.execute(reviewDbUpdates, listener, requestId, dryRun);
+      if (migration.disableChangeReviewDb()) {
+        if (migration.fuseUpdates()) {
+          ImmutableList<FusedNoteDbBatchUpdate> noteDbUpdates =
+              (ImmutableList) ImmutableList.copyOf(updates);
+          FusedNoteDbBatchUpdate.execute(noteDbUpdates, listener, requestId, dryRun);
+        } else {
+          ImmutableList<UnfusedNoteDbBatchUpdate> noteDbUpdates =
+              (ImmutableList) ImmutableList.copyOf(updates);
+          UnfusedNoteDbBatchUpdate.execute(noteDbUpdates, listener, requestId, dryRun);
+        }
+      } else {
+        ImmutableList<ReviewDbBatchUpdate> reviewDbUpdates =
+            (ImmutableList) ImmutableList.copyOf(updates);
+        ReviewDbBatchUpdate.execute(reviewDbUpdates, listener, requestId, dryRun);
+      }
+    }
+
+    private static void checkDifferentProject(Collection<BatchUpdate> updates) {
+      Multiset<Project.NameKey> projectCounts =
+          updates.stream().map(u -> u.project).collect(toImmutableMultiset());
+      checkArgument(
+          projectCounts.entrySet().size() == updates.size(),
+          "updates must all be for different projects, got: %s",
+          projectCounts);
     }
   }
 
-  protected static Order getOrder(Collection<? extends BatchUpdate> updates) {
+  static void setRequestIds(
+      Collection<? extends BatchUpdate> updates, @Nullable RequestId requestId) {
+    if (requestId != null) {
+      for (BatchUpdate u : updates) {
+        checkArgument(
+            u.requestId == null || u.requestId == requestId,
+            "refusing to overwrite RequestId %s in update with %s",
+            u.requestId,
+            requestId);
+        u.setRequestId(requestId);
+      }
+    }
+  }
+
+  static Order getOrder(Collection<? extends BatchUpdate> updates, BatchUpdateListener listener) {
     Order o = null;
     for (BatchUpdate u : updates) {
       if (o == null) {
@@ -126,10 +196,16 @@
         throw new IllegalArgumentException("cannot mix execution orders");
       }
     }
+    if (o != Order.REPO_BEFORE_DB) {
+      checkArgument(
+          listener == BatchUpdateListener.NONE,
+          "BatchUpdateListener not supported for order %s",
+          o);
+    }
     return o;
   }
 
-  protected static boolean getUpdateChangesInParallel(Collection<? extends BatchUpdate> updates) {
+  static boolean getUpdateChangesInParallel(Collection<? extends BatchUpdate> updates) {
     checkArgument(!updates.isEmpty());
     Boolean p = null;
     for (BatchUpdate u : updates) {
@@ -148,6 +224,28 @@
     return p;
   }
 
+  static void wrapAndThrowException(Exception e) throws UpdateException, RestApiException {
+    Throwables.throwIfUnchecked(e);
+
+    // Propagate REST API exceptions thrown by operations; they commonly throw exceptions like
+    // ResourceConflictException to indicate an atomic update failure.
+    Throwables.throwIfInstanceOf(e, UpdateException.class);
+    Throwables.throwIfInstanceOf(e, RestApiException.class);
+
+    // Convert other common non-REST exception types with user-visible messages to corresponding
+    // REST exception types
+    if (e instanceof InvalidChangeOperationException) {
+      throw new ResourceConflictException(e.getMessage(), e);
+    } else if (e instanceof NoSuchChangeException
+        || e instanceof NoSuchRefException
+        || e instanceof NoSuchProjectException) {
+      throw new ResourceNotFoundException(e.getMessage(), e);
+    }
+
+    // Otherwise, wrap in a generic UpdateException, which does not include a user-visible message.
+    throw new UpdateException(e);
+  }
+
   protected GitRepositoryManager repoManager;
 
   protected final Project.NameKey project;
@@ -160,17 +258,15 @@
   protected final Map<Change.Id, Change> newChanges = new HashMap<>();
   protected final List<RepoOnlyOp> repoOnlyOps = new ArrayList<>();
 
-  protected Repository repo;
-  protected ObjectInserter inserter;
-  protected RevWalk revWalk;
-  protected ChainedReceiveCommands commands;
+  protected RepoView repoView;
   protected BatchRefUpdate batchRefUpdate;
   protected Order order;
   protected OnSubmitValidators onSubmitValidators;
   protected RequestId requestId;
+  protected PushCertificate pushCert;
+  protected String refLogMessage;
 
   private boolean updateChangesInParallel;
-  private boolean closeRepo;
 
   protected BatchUpdate(
       GitRepositoryManager repoManager,
@@ -188,18 +284,17 @@
 
   @Override
   public void close() {
-    if (closeRepo) {
-      revWalk.getObjectReader().close();
-      revWalk.close();
-      inserter.close();
-      repo.close();
+    if (repoView != null) {
+      repoView.close();
     }
   }
 
   public abstract void execute(BatchUpdateListener listener)
       throws UpdateException, RestApiException;
 
-  public abstract void execute() throws UpdateException, RestApiException;
+  public void execute() throws UpdateException, RestApiException {
+    execute(BatchUpdateListener.NONE);
+  }
 
   protected abstract Context newContext();
 
@@ -209,12 +304,18 @@
   }
 
   public BatchUpdate setRepository(Repository repo, RevWalk revWalk, ObjectInserter inserter) {
-    checkState(this.repo == null, "repo already set");
-    closeRepo = false;
-    this.repo = checkNotNull(repo, "repo");
-    this.revWalk = checkNotNull(revWalk, "revWalk");
-    this.inserter = checkNotNull(inserter, "inserter");
-    commands = new ChainedReceiveCommands(repo);
+    checkState(this.repoView == null, "repo already set");
+    repoView = new RepoView(repo, revWalk, inserter);
+    return this;
+  }
+
+  public BatchUpdate setPushCertificate(@Nullable PushCertificate pushCert) {
+    this.pushCert = pushCert;
+    return this;
+  }
+
+  public BatchUpdate setRefLogMessage(@Nullable String refLogMessage) {
+    this.refLogMessage = refLogMessage;
     return this;
   }
 
@@ -232,43 +333,46 @@
     return this;
   }
 
-  /** Execute {@link BatchUpdateOp#updateChange(ChangeContext)} in parallel for each change. */
+  /**
+   * Execute {@link BatchUpdateOp#updateChange(ChangeContext)} in parallel for each change.
+   *
+   * <p>This improves performance of writing to multiple changes in separate ReviewDb transactions.
+   * When only NoteDb is used, updates to all changes are written in a single batch ref update, so
+   * parallelization is not used and this option is ignored.
+   */
   public BatchUpdate updateChangesInParallel() {
     this.updateChangesInParallel = true;
     return this;
   }
 
   protected void initRepository() throws IOException {
-    if (repo == null) {
-      this.repo = repoManager.openRepository(project);
-      closeRepo = true;
-      inserter = repo.newObjectInserter();
-      revWalk = new RevWalk(inserter.newReader());
-      commands = new ChainedReceiveCommands(repo);
+    if (repoView == null) {
+      repoView = new RepoView(repoManager, project);
     }
   }
 
+  protected RepoView getRepoView() throws IOException {
+    initRepository();
+    return repoView;
+  }
+
   protected CurrentUser getUser() {
     return user;
   }
 
-  protected Repository getRepository() throws IOException {
-    initRepository();
-    return repo;
+  protected Optional<Account> getAccount() {
+    return user.isIdentifiedUser()
+        ? Optional.of(user.asIdentifiedUser().getAccount())
+        : Optional.empty();
   }
 
   protected RevWalk getRevWalk() throws IOException {
     initRepository();
-    return revWalk;
+    return repoView.getRevWalk();
   }
 
-  protected ObjectInserter getObjectInserter() throws IOException {
-    initRepository();
-    return inserter;
-  }
-
-  public Collection<ReceiveCommand> getRefUpdates() {
-    return commands.getCommands().values();
+  public Map<String, ReceiveCommand> getRefUpdates() {
+    return repoView != null ? repoView.getCommands().getCommands() : ImmutableMap.of();
   }
 
   public BatchUpdate addOp(Change.Id id, BatchUpdateOp op) {
@@ -284,7 +388,7 @@
     return this;
   }
 
-  public BatchUpdate insertChange(InsertChangeOp op) {
+  public BatchUpdate insertChange(InsertChangeOp op) throws IOException {
     Context ctx = newContext();
     Change c = op.createChange(ctx);
     checkArgument(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateListener.java
index 765bba1..847a7ca 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateListener.java
@@ -19,6 +19,8 @@
  *
  * <p>When used during execution of multiple batch updates, the {@code after*} methods are called
  * after that phase has been completed for <em>all</em> updates.
+ *
+ * <p>Listeners are only supported for the {@link Order#REPO_BEFORE_DB} order.
  */
 public interface BatchUpdateListener {
   public static final BatchUpdateListener NONE = new BatchUpdateListener() {};
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateOp.java
index 39e25dd..87a43a3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateOp.java
@@ -22,8 +22,11 @@
  * BatchUpdate#addOp(com.google.gerrit.reviewdb.client.Change.Id, BatchUpdateOp)}.
  *
  * <p>Usually, a single {@code BatchUpdateOp} instance is only associated with a single change, i.e.
- * {@code addOp} is only called once with that instance. This allows an instance to communicate
- * between phases by storing data in private fields.
+ * {@code addOp} is only called once with that instance. Additionally, each method in {@code
+ * BatchUpdateOp} is called at most once per {@link BatchUpdate} execution.
+ *
+ * <p>Taken together, these two properties mean an instance may communicate between phases by
+ * storing data in private fields, and a single instance must not be reused.
  */
 public interface BatchUpdateOp extends RepoOnlyOp {
   /**
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeContext.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeContext.java
index d619490..ca39763 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeContext.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeContext.java
@@ -44,17 +44,24 @@
   ChangeUpdate getUpdate(PatchSet.Id psId);
 
   /**
-   * @return control for this change. The user will be the same as {@link #getUser()}, and the
-   *     change data is read within the same transaction that {@code updateChange} is executing.
+   * Get the control for this change, encapsulating the user and up-to-date change data.
+   *
+   * <p>The user will be the same as {@link #getUser()}, and the change data is read within the same
+   * transaction that {@link BatchUpdateOp#updateChange(ChangeContext)} is executing.
+   *
+   * @return control for this change.
    */
   ChangeControl getControl();
 
   /**
-   * @param bump whether to bump the value of {@link Change#getLastUpdatedOn()} field before storing
-   *     to ReviewDb. For NoteDb, the value is always incremented (assuming the update is not
-   *     otherwise a no-op).
+   * Don't bump the value of {@link Change#getLastUpdatedOn()}.
+   *
+   * <p>If called, don't bump the timestamp before storing to ReviewDb. Only has an effect in
+   * ReviewDb, and the only usage should be to match the behavior of NoteDb. Specifically, in NoteDb
+   * the timestamp is updated if and only if the change meta graph is updated, and is not updated
+   * when only drafts are modified.
    */
-  void bumpLastUpdatedOn(boolean bump);
+  void dontBumpLastUpdatedOn();
 
   /**
    * Instruct {@link BatchUpdate} to delete this change.
@@ -63,7 +70,11 @@
    */
   void deleteChange();
 
-  /** @return notes corresponding to {@link #getControl()}. */
+  /**
+   * Get notes corresponding to {@link #getControl()}.
+   *
+   * @return loaded notes instance.
+   */
   default ChangeNotes getNotes() {
     return checkNotNull(getControl().getNotes());
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/Context.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/Context.java
index 497b7ab..f33536d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/Context.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/Context.java
@@ -24,7 +24,6 @@
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.TimeZone;
-import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
@@ -33,19 +32,22 @@
  * <p>A single update may span multiple changes, but they all belong to a single repo.
  */
 public interface Context {
-  /** @return the project name this update operates on. */
+  /**
+   * Get the project name this update operates on.
+   *
+   * @return project.
+   */
   Project.NameKey getProject();
 
   /**
-   * Get an open repository instance for this project.
+   * Get a read-only view of the open repository for this project.
    *
-   * <p>Will be opened lazily if necessary; callers should not close the repo. In some phases of the
-   * update, the repository might be read-only; see {@link BatchUpdateOp} for details.
+   * <p>Will be opened lazily if necessary.
    *
    * @return repository instance.
    * @throws IOException if an error occurred opening the repo.
    */
-  Repository getRepository() throws IOException;
+  RepoView getRepoView() throws IOException;
 
   /**
    * Get a walk for this project.
@@ -57,50 +59,80 @@
    */
   RevWalk getRevWalk() throws IOException;
 
-  /** @return the timestamp at which this update takes place. */
+  /**
+   * Get the timestamp at which this update takes place.
+   *
+   * @return timestamp.
+   */
   Timestamp getWhen();
 
   /**
-   * @return the time zone in which this update takes place. In the current implementation, this is
-   *     always the time zone of the server.
+   * Get the time zone in which this update takes place.
+   *
+   * <p>In the current implementation, this is always the time zone of the server.
+   *
+   * @return time zone.
    */
   TimeZone getTimeZone();
 
   /**
-   * @return an open ReviewDb database. Callers should not manage transactions or call mutating
-   *     methods on the Changes table. Mutations on other tables (including other entities in the
-   *     change entity group) are fine.
+   * Get the ReviewDb database.
+   *
+   * <p>Callers should not manage transactions or call mutating methods on the Changes table.
+   * Mutations on other tables (including other entities in the change entity group) are fine.
+   *
+   * @return open database instance.
    */
   ReviewDb getDb();
 
   /**
-   * @return user performing the update. In the current implementation, this is always an {@link
-   *     IdentifiedUser} or {@link com.google.gerrit.server.InternalUser}.
+   * Get the user performing the update.
+   *
+   * <p>In the current implementation, this is always an {@link IdentifiedUser} or {@link
+   * com.google.gerrit.server.InternalUser}.
+   *
+   * @return user.
    */
   CurrentUser getUser();
 
-  /** @return order in which operations are executed in this update. */
+  /**
+   * Get the order in which operations are executed in this update.
+   *
+   * @return order of operations.
+   */
   Order getOrder();
 
   /**
-   * @return identified user performing the update; throws an unchecked exception if the user is not
-   *     an {@link IdentifiedUser}
+   * Get the identified user performing the update.
+   *
+   * <p>Convenience method for {@code getUser().asIdentifiedUser()}.
+   *
+   * @see CurrentUser#asIdentifiedUser()
+   * @return user.
    */
   default IdentifiedUser getIdentifiedUser() {
     return checkNotNull(getUser()).asIdentifiedUser();
   }
 
   /**
-   * @return account of the user performing the update; throws if the user is not an {@link
-   *     IdentifiedUser}
+   * Get the account of the user performing the update.
+   *
+   * <p>Convenience method for {@code getIdentifiedUser().getAccount()}.
+   *
+   * @see CurrentUser#asIdentifiedUser()
+   * @return account.
    */
   default Account getAccount() {
     return getIdentifiedUser().getAccount();
   }
 
   /**
-   * @return account ID of the user performing the update; throws if the user is not an {@link
-   *     IdentifiedUser}
+   * Get the account ID of the user performing the update.
+   *
+   * <p>Convenience method for {@code getUser().getAccountId()}
+   *
+   * @see CurrentUser#getAccountId()
+   * @return account ID.
    */
   default Account.Id getAccountId() {
     return getIdentifiedUser().getAccountId();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/FusedNoteDbBatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/FusedNoteDbBatchUpdate.java
new file mode 100644
index 0000000..f8ef5f9
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/FusedNoteDbBatchUpdate.java
@@ -0,0 +1,461 @@
+// Copyright (C) 2017 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.update;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.Comparator.comparing;
+
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.CheckedFuture;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.util.RequestId;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TimeZone;
+import java.util.TreeMap;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/**
+ * {@link BatchUpdate} implementation using only NoteDb that updates code refs and meta refs in a
+ * single {@link org.eclipse.jgit.lib.BatchRefUpdate}.
+ *
+ * <p>Used when {@code noteDb.changes.disableReviewDb=true}, at which point ReviewDb is not
+ * consulted during updates.
+ */
+class FusedNoteDbBatchUpdate extends BatchUpdate {
+  interface AssistedFactory {
+    FusedNoteDbBatchUpdate create(
+        ReviewDb db, Project.NameKey project, CurrentUser user, Timestamp when);
+  }
+
+  static void execute(
+      ImmutableList<FusedNoteDbBatchUpdate> updates,
+      BatchUpdateListener listener,
+      @Nullable RequestId requestId,
+      boolean dryrun)
+      throws UpdateException, RestApiException {
+    if (updates.isEmpty()) {
+      return;
+    }
+    setRequestIds(updates, requestId);
+
+    try {
+      List<CheckedFuture<?, IOException>> indexFutures = new ArrayList<>();
+      List<ChangesHandle> handles = new ArrayList<>(updates.size());
+      Order order = getOrder(updates, listener);
+      try {
+        switch (order) {
+          case REPO_BEFORE_DB:
+            for (FusedNoteDbBatchUpdate u : updates) {
+              u.executeUpdateRepo();
+            }
+            listener.afterUpdateRepos();
+            for (FusedNoteDbBatchUpdate u : updates) {
+              handles.add(u.executeChangeOps(dryrun));
+            }
+            for (ChangesHandle h : handles) {
+              h.execute();
+              indexFutures.addAll(h.startIndexFutures());
+            }
+            listener.afterUpdateRefs();
+            listener.afterUpdateChanges();
+            break;
+
+          case DB_BEFORE_REPO:
+            // Call updateChange for each op before updateRepo, but defer executing the
+            // NoteDbUpdateManager until after calling updateRepo. They share an inserter and
+            // BatchRefUpdate, so it will all execute as a single batch. But we have to let
+            // NoteDbUpdateManager actually execute the update, since it has to interleave it
+            // properly with All-Users updates.
+            //
+            // TODO(dborowitz): This may still result in multiple updates to All-Users, but that's
+            // currently not a big deal because multi-change batches generally aren't affecting
+            // drafts anyway.
+            for (FusedNoteDbBatchUpdate u : updates) {
+              handles.add(u.executeChangeOps(dryrun));
+            }
+            for (FusedNoteDbBatchUpdate u : updates) {
+              u.executeUpdateRepo();
+            }
+            for (ChangesHandle h : handles) {
+              // TODO(dborowitz): This isn't quite good enough: in theory updateRepo may want to
+              // see the results of change meta commands, but they aren't actually added to the
+              // BatchUpdate until the body of execute. To fix this, execute needs to be split up
+              // into a method that returns a BatchRefUpdate before execution. Not a big deal at the
+              // moment, because this order is only used for deleting changes, and those updateRepo
+              // implementations definitely don't need to observe the updated change meta refs.
+              h.execute();
+              indexFutures.addAll(h.startIndexFutures());
+            }
+            break;
+          default:
+            throw new IllegalStateException("invalid execution order: " + order);
+        }
+      } finally {
+        for (ChangesHandle h : handles) {
+          h.close();
+        }
+      }
+
+      ChangeIndexer.allAsList(indexFutures).get();
+
+      // Fire ref update events only after all mutations are finished, since callers may assume a
+      // patch set ref being created means the change was created, or a branch advancing meaning
+      // some changes were closed.
+      updates
+          .stream()
+          .filter(u -> u.batchRefUpdate != null)
+          .forEach(
+              u -> u.gitRefUpdated.fire(u.project, u.batchRefUpdate, u.getAccount().orElse(null)));
+
+      if (!dryrun) {
+        for (FusedNoteDbBatchUpdate u : updates) {
+          u.executePostOps();
+        }
+      }
+    } catch (Exception e) {
+      wrapAndThrowException(e);
+    }
+  }
+
+  class ContextImpl implements Context {
+    @Override
+    public RepoView getRepoView() throws IOException {
+      return FusedNoteDbBatchUpdate.this.getRepoView();
+    }
+
+    @Override
+    public RevWalk getRevWalk() throws IOException {
+      return getRepoView().getRevWalk();
+    }
+
+    @Override
+    public Project.NameKey getProject() {
+      return project;
+    }
+
+    @Override
+    public Timestamp getWhen() {
+      return when;
+    }
+
+    @Override
+    public TimeZone getTimeZone() {
+      return tz;
+    }
+
+    @Override
+    public ReviewDb getDb() {
+      return db;
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      return user;
+    }
+
+    @Override
+    public Order getOrder() {
+      return order;
+    }
+  }
+
+  private class RepoContextImpl extends ContextImpl implements RepoContext {
+    @Override
+    public ObjectInserter getInserter() throws IOException {
+      return getRepoView().getInserterWrapper();
+    }
+
+    @Override
+    public void addRefUpdate(ReceiveCommand cmd) throws IOException {
+      getRepoView().getCommands().add(cmd);
+    }
+  }
+
+  private class ChangeContextImpl extends ContextImpl implements ChangeContext {
+    private final ChangeControl ctl;
+    private final Map<PatchSet.Id, ChangeUpdate> updates;
+
+    private boolean deleted;
+
+    protected ChangeContextImpl(ChangeControl ctl) {
+      this.ctl = checkNotNull(ctl);
+      updates = new TreeMap<>(comparing(PatchSet.Id::get));
+    }
+
+    @Override
+    public ChangeUpdate getUpdate(PatchSet.Id psId) {
+      ChangeUpdate u = updates.get(psId);
+      if (u == null) {
+        u = changeUpdateFactory.create(ctl, when);
+        if (newChanges.containsKey(ctl.getId())) {
+          u.setAllowWriteToNewRef(true);
+        }
+        u.setPatchSetId(psId);
+        updates.put(psId, u);
+      }
+      return u;
+    }
+
+    @Override
+    public ChangeControl getControl() {
+      return ctl;
+    }
+
+    @Override
+    public void dontBumpLastUpdatedOn() {
+      // Do nothing; NoteDb effectively updates timestamp if and only if a commit was written to the
+      // change meta ref.
+    }
+
+    @Override
+    public void deleteChange() {
+      deleted = true;
+    }
+  }
+
+  /** Per-change result status from {@link #executeChangeOps}. */
+  private enum ChangeResult {
+    SKIPPED,
+    UPSERTED,
+    DELETED;
+  }
+
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final ChangeControl.GenericFactory changeControlFactory;
+  private final ChangeUpdate.Factory changeUpdateFactory;
+  private final NoteDbUpdateManager.Factory updateManagerFactory;
+  private final ChangeIndexer indexer;
+  private final GitReferenceUpdated gitRefUpdated;
+  private final ReviewDb db;
+
+  @Inject
+  FusedNoteDbBatchUpdate(
+      GitRepositoryManager repoManager,
+      @GerritPersonIdent PersonIdent serverIdent,
+      ChangeNotes.Factory changeNotesFactory,
+      ChangeControl.GenericFactory changeControlFactory,
+      ChangeUpdate.Factory changeUpdateFactory,
+      NoteDbUpdateManager.Factory updateManagerFactory,
+      ChangeIndexer indexer,
+      GitReferenceUpdated gitRefUpdated,
+      @Assisted ReviewDb db,
+      @Assisted Project.NameKey project,
+      @Assisted CurrentUser user,
+      @Assisted Timestamp when) {
+    super(repoManager, serverIdent, project, user, when);
+    checkArgument(!db.changesTablesEnabled(), "expected Change tables to be disabled on %s", db);
+    this.changeNotesFactory = changeNotesFactory;
+    this.changeControlFactory = changeControlFactory;
+    this.changeUpdateFactory = changeUpdateFactory;
+    this.updateManagerFactory = updateManagerFactory;
+    this.indexer = indexer;
+    this.gitRefUpdated = gitRefUpdated;
+    this.db = db;
+  }
+
+  @Override
+  public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
+    execute(ImmutableList.of(this), listener, requestId, false);
+  }
+
+  @Override
+  protected Context newContext() {
+    return new ContextImpl();
+  }
+
+  private void executeUpdateRepo() throws UpdateException, RestApiException {
+    try {
+      logDebug("Executing updateRepo on {} ops", ops.size());
+      RepoContextImpl ctx = new RepoContextImpl();
+      for (BatchUpdateOp op : ops.values()) {
+        op.updateRepo(ctx);
+      }
+
+      logDebug("Executing updateRepo on {} RepoOnlyOps", repoOnlyOps.size());
+      for (RepoOnlyOp op : repoOnlyOps) {
+        op.updateRepo(ctx);
+      }
+
+      if (onSubmitValidators != null && !getRefUpdates().isEmpty()) {
+        // Validation of refs has to take place here and not at the beginning of executeRefUpdates.
+        // Otherwise, failing validation in a second BatchUpdate object will happen *after* the
+        // first update's executeRefUpdates has finished, hence after first repo's refs have been
+        // updated, which is too late.
+        onSubmitValidators.validate(
+            project, ctx.getRevWalk().getObjectReader(), repoView.getCommands());
+      }
+    } catch (Exception e) {
+      Throwables.throwIfInstanceOf(e, RestApiException.class);
+      throw new UpdateException(e);
+    }
+  }
+
+  private class ChangesHandle implements AutoCloseable {
+    private final NoteDbUpdateManager manager;
+    private final boolean dryrun;
+    private final Map<Change.Id, ChangeResult> results;
+
+    ChangesHandle(NoteDbUpdateManager manager, boolean dryrun) {
+      this.manager = manager;
+      this.dryrun = dryrun;
+      results = new HashMap<>();
+    }
+
+    @Override
+    public void close() {
+      manager.close();
+    }
+
+    void setResult(Change.Id id, ChangeResult result) {
+      ChangeResult old = results.putIfAbsent(id, result);
+      checkArgument(old == null, "result for change %s already set: %s", id, old);
+    }
+
+    void execute() throws OrmException, IOException {
+      FusedNoteDbBatchUpdate.this.batchRefUpdate = manager.execute(dryrun);
+    }
+
+    List<CheckedFuture<?, IOException>> startIndexFutures() {
+      if (dryrun) {
+        return ImmutableList.of();
+      }
+      logDebug("Reindexing {} changes", results.size());
+      List<CheckedFuture<?, IOException>> indexFutures = new ArrayList<>(results.size());
+      for (Map.Entry<Change.Id, ChangeResult> e : results.entrySet()) {
+        Change.Id id = e.getKey();
+        switch (e.getValue()) {
+          case UPSERTED:
+            indexFutures.add(indexer.indexAsync(project, id));
+            break;
+          case DELETED:
+            indexFutures.add(indexer.deleteAsync(id));
+            break;
+          case SKIPPED:
+            break;
+          default:
+            throw new IllegalStateException("unexpected result: " + e.getValue());
+        }
+      }
+      return indexFutures;
+    }
+  }
+
+  private ChangesHandle executeChangeOps(boolean dryrun) throws Exception {
+    logDebug("Executing change ops");
+    initRepository();
+    Repository repo = repoView.getRepository();
+    checkState(
+        repo.getRefDatabase().performsAtomicTransactions(),
+        "cannot use noteDb.changes.fuseUpdates=true with a repository that does not support atomic"
+            + " batch ref updates: %s",
+        repo);
+
+    ChangesHandle handle =
+        new ChangesHandle(
+            updateManagerFactory
+                .create(project)
+                .setChangeRepo(
+                    repo, repoView.getRevWalk(), repoView.getInserter(), repoView.getCommands()),
+            dryrun);
+    if (user.isIdentifiedUser()) {
+      handle.manager.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, tz));
+    }
+    handle.manager.setRefLogMessage(refLogMessage);
+    handle.manager.setPushCertificate(pushCert);
+    for (Map.Entry<Change.Id, Collection<BatchUpdateOp>> e : ops.asMap().entrySet()) {
+      Change.Id id = e.getKey();
+      ChangeContextImpl ctx = newChangeContext(id);
+      boolean dirty = false;
+      logDebug("Applying {} ops for change {}", e.getValue().size(), id);
+      for (BatchUpdateOp op : e.getValue()) {
+        dirty |= op.updateChange(ctx);
+      }
+      if (!dirty) {
+        logDebug("No ops reported dirty, short-circuiting");
+        handle.setResult(id, ChangeResult.SKIPPED);
+        continue;
+      }
+      for (ChangeUpdate u : ctx.updates.values()) {
+        handle.manager.add(u);
+      }
+      if (ctx.deleted) {
+        logDebug("Change {} was deleted", id);
+        handle.manager.deleteChange(id);
+        handle.setResult(id, ChangeResult.DELETED);
+      } else {
+        handle.setResult(id, ChangeResult.UPSERTED);
+      }
+    }
+    return handle;
+  }
+
+  private ChangeContextImpl newChangeContext(Change.Id id) throws OrmException {
+    logDebug("Opening change {} for update", id);
+    Change c = newChanges.get(id);
+    boolean isNew = c != null;
+    if (!isNew) {
+      // Pass a synthetic change into ChangeNotes.Factory, which will take care of checking for
+      // existence and populating columns from the parsed notes state.
+      // TODO(dborowitz): This dance made more sense when using Reviewdb; consider a nicer way.
+      c = ChangeNotes.Factory.newNoteDbOnlyChange(project, id);
+    } else {
+      logDebug("Change {} is new", id);
+    }
+    ChangeNotes notes = changeNotesFactory.createForBatchUpdate(c, !isNew);
+    ChangeControl ctl = changeControlFactory.controlFor(notes, user);
+    return new ChangeContextImpl(ctl);
+  }
+
+  private void executePostOps() throws Exception {
+    ContextImpl ctx = new ContextImpl();
+    for (BatchUpdateOp op : ops.values()) {
+      op.postUpdate(ctx);
+    }
+
+    for (RepoOnlyOp op : repoOnlyOps) {
+      op.postUpdate(ctx);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/InsertChangeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/InsertChangeOp.java
index 1a947e6..7060059 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/InsertChangeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/InsertChangeOp.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.update;
 
 import com.google.gerrit.reviewdb.client.Change;
+import java.io.IOException;
 
 /**
  * Specialization of {@link BatchUpdateOp} for creating changes.
@@ -27,5 +28,5 @@
  * first.
  */
 public interface InsertChangeOp extends BatchUpdateOp {
-  Change createChange(Context ctx);
+  Change createChange(Context ctx) throws IOException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/ReadOnlyRepository.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/ReadOnlyRepository.java
deleted file mode 100644
index 37c1d60..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/ReadOnlyRepository.java
+++ /dev/null
@@ -1,180 +0,0 @@
-// Copyright (C) 2015 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.update;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import java.io.IOException;
-import java.util.List;
-import java.util.Map;
-import org.eclipse.jgit.attributes.AttributesNodeProvider;
-import org.eclipse.jgit.lib.BaseRepositoryBuilder;
-import org.eclipse.jgit.lib.ObjectDatabase;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
-import org.eclipse.jgit.lib.RefRename;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.ReflogReader;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.StoredConfig;
-
-class ReadOnlyRepository extends Repository {
-  private static final String MSG = "Cannot modify a " + ReadOnlyRepository.class.getSimpleName();
-
-  private static BaseRepositoryBuilder<?, ?> builder(Repository r) {
-    checkNotNull(r);
-    BaseRepositoryBuilder<?, ?> builder =
-        new BaseRepositoryBuilder<>().setFS(r.getFS()).setGitDir(r.getDirectory());
-
-    if (!r.isBare()) {
-      builder.setWorkTree(r.getWorkTree()).setIndexFile(r.getIndexFile());
-    }
-    return builder;
-  }
-
-  private final Repository delegate;
-  private final RefDb refdb;
-  private final ObjDb objdb;
-
-  ReadOnlyRepository(Repository delegate) {
-    super(builder(delegate));
-    this.delegate = delegate;
-    this.refdb = new RefDb(delegate.getRefDatabase());
-    this.objdb = new ObjDb(delegate.getObjectDatabase());
-  }
-
-  @Override
-  public void create(boolean bare) throws IOException {
-    throw new UnsupportedOperationException(MSG);
-  }
-
-  @Override
-  public ObjectDatabase getObjectDatabase() {
-    return objdb;
-  }
-
-  @Override
-  public RefDatabase getRefDatabase() {
-    return refdb;
-  }
-
-  @Override
-  public StoredConfig getConfig() {
-    return delegate.getConfig();
-  }
-
-  @Override
-  public AttributesNodeProvider createAttributesNodeProvider() {
-    return delegate.createAttributesNodeProvider();
-  }
-
-  @Override
-  public void scanForRepoChanges() throws IOException {
-    delegate.scanForRepoChanges();
-  }
-
-  @Override
-  public void notifyIndexChanged() {
-    delegate.notifyIndexChanged();
-  }
-
-  @Override
-  public ReflogReader getReflogReader(String refName) throws IOException {
-    return delegate.getReflogReader(refName);
-  }
-
-  @Override
-  public String getGitwebDescription() throws IOException {
-    return delegate.getGitwebDescription();
-  }
-
-  private static class RefDb extends RefDatabase {
-    private final RefDatabase delegate;
-
-    private RefDb(RefDatabase delegate) {
-      this.delegate = checkNotNull(delegate);
-    }
-
-    @Override
-    public void create() throws IOException {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public void close() {
-      delegate.close();
-    }
-
-    @Override
-    public boolean isNameConflicting(String name) throws IOException {
-      return delegate.isNameConflicting(name);
-    }
-
-    @Override
-    public RefUpdate newUpdate(String name, boolean detach) throws IOException {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public RefRename newRename(String fromName, String toName) throws IOException {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public Ref getRef(String name) throws IOException {
-      return delegate.getRef(name);
-    }
-
-    @Override
-    public Map<String, Ref> getRefs(String prefix) throws IOException {
-      return delegate.getRefs(prefix);
-    }
-
-    @Override
-    public List<Ref> getAdditionalRefs() throws IOException {
-      return delegate.getAdditionalRefs();
-    }
-
-    @Override
-    public Ref peel(Ref ref) throws IOException {
-      return delegate.peel(ref);
-    }
-  }
-
-  private static class ObjDb extends ObjectDatabase {
-    private final ObjectDatabase delegate;
-
-    private ObjDb(ObjectDatabase delegate) {
-      this.delegate = checkNotNull(delegate);
-    }
-
-    @Override
-    public ObjectInserter newInserter() {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ObjectReader newReader() {
-      return delegate.newReader();
-    }
-
-    @Override
-    public void close() {
-      delegate.close();
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoContext.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoContext.java
index 5009c50..9faf628 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoContext.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoContext.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.update;
 
 import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
@@ -31,11 +32,28 @@
   /**
    * Add a command to the pending list of commands.
    *
-   * <p>Callers should use this method instead of writing directly to the repository returned by
-   * {@link #getRepository()}.
+   * <p>Adding commands to the {@code RepoContext} is the only way of updating refs in the
+   * repository from a {@link BatchUpdateOp}.
    *
    * @param cmd ref update command.
    * @throws IOException if an error occurred opening the repo.
    */
   void addRefUpdate(ReceiveCommand cmd) throws IOException;
+
+  /**
+   * Add a command to the pending list of commands.
+   *
+   * <p>Adding commands to the {@code RepoContext} is the only way of updating refs in the
+   * repository from a {@link BatchUpdateOp}.
+   *
+   * @param oldId the old object ID; must not be null. Use {@link ObjectId#zeroId()} for ref
+   *     creation.
+   * @param newId the new object ID; must not be null. Use {@link ObjectId#zeroId()} for ref
+   *     deletion.
+   * @param refName the ref name.
+   * @throws IOException if an error occurred opening the repo.
+   */
+  default void addRefUpdate(ObjectId oldId, ObjectId newId, String refName) throws IOException {
+    addRefUpdate(new ReceiveCommand(oldId, newId, refName));
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoView.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoView.java
new file mode 100644
index 0000000..8839dbe
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoView.java
@@ -0,0 +1,234 @@
+// Copyright (C) 2017 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.update;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.Maps;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Restricted view of a {@link Repository} for use by {@link BatchUpdateOp} implementations.
+ *
+ * <p>This class serves two purposes in the context of {@link BatchUpdate}. First, the subset of
+ * normal Repository functionality is purely read-only, which prevents implementors from modifying
+ * the repository outside of {@link BatchUpdateOp#updateRepo}. Write operations can only be
+ * performed by calling methods on {@link RepoContext}.
+ *
+ * <p>Second, the read methods take into account any pending operations on the repository that
+ * implementations have staged using the write methods on {@link RepoContext}. Callers do not have
+ * to worry about whether operations have been performed yet, and the implementation details may
+ * differ between ReviewDb and NoteDb, but callers just don't need to care.
+ */
+public class RepoView {
+  private final Repository repo;
+  private final RevWalk rw;
+  private final ObjectInserter inserter;
+  private final ObjectInserter inserterWrapper;
+  private final ChainedReceiveCommands commands;
+  private final boolean closeRepo;
+
+  RepoView(GitRepositoryManager repoManager, Project.NameKey project) throws IOException {
+    repo = repoManager.openRepository(project);
+    inserter = repo.newObjectInserter();
+    inserterWrapper = new NonFlushingInserter(inserter);
+    rw = new RevWalk(inserter.newReader());
+    commands = new ChainedReceiveCommands(repo);
+    closeRepo = true;
+  }
+
+  RepoView(Repository repo, RevWalk rw, ObjectInserter inserter) {
+    checkArgument(
+        rw.getObjectReader().getCreatedFromInserter() == inserter,
+        "expected RevWalk %s to be created by ObjectInserter %s",
+        rw,
+        inserter);
+    this.repo = checkNotNull(repo);
+    this.rw = checkNotNull(rw);
+    this.inserter = checkNotNull(inserter);
+    inserterWrapper = new NonFlushingInserter(inserter);
+    commands = new ChainedReceiveCommands(repo);
+    closeRepo = false;
+  }
+
+  /**
+   * Get this repo's configuration.
+   *
+   * <p>This is the storage-level config you would get with {@link Repository#getConfig()}, not, for
+   * example, the Gerrit-level project config.
+   *
+   * @return a defensive copy of the config; modifications have no effect on the underlying config.
+   */
+  public Config getConfig() {
+    return new Config(repo.getConfig());
+  }
+
+  /**
+   * Get an open revwalk on the repo.
+   *
+   * <p>Guaranteed to be able to read back any objects inserted in the repository via {@link
+   * RepoContext#getInserter()}, even if objects have not been flushed to the underlying repo. In
+   * particular this includes any object returned by {@link #getRef(String)}, even taking into
+   * account not-yet-executed commands.
+   *
+   * @return revwalk.
+   */
+  public RevWalk getRevWalk() {
+    return rw;
+  }
+
+  /**
+   * Read a single ref from the repo.
+   *
+   * <p>Takes into account any ref update commands added during the course of the update using
+   * {@link RepoContext#addRefUpdate}, even if they have not yet been executed on the underlying
+   * repo.
+   *
+   * <p>The results of individual ref lookups are cached: calling this method multiple times with
+   * the same ref name will return the same result (unless a command was added in the meantime). The
+   * repo is not reread.
+   *
+   * @param name exact ref name.
+   * @return the value of the ref, if present.
+   * @throws IOException if an error occurred.
+   */
+  public Optional<ObjectId> getRef(String name) throws IOException {
+    return getCommands().get(name);
+  }
+
+  /**
+   * Look up refs by prefix.
+   *
+   * <p>Takes into account any ref update commands added during the course of the update using
+   * {@link RepoContext#addRefUpdate}, even if they have not yet been executed on the underlying
+   * repo.
+   *
+   * <p>For any ref that has previously been accessed with {@link #getRef(String)}, the value in the
+   * result map will be that same cached value. Any refs that have <em>not</em> been previously
+   * accessed are re-scanned from the repo on each call.
+   *
+   * @param prefix ref prefix; must end in '/' or else be empty.
+   * @return a map of ref suffixes to SHA-1s. The refs are all under {@code prefix} and have the
+   *     prefix stripped; this matches the behavior of {@link
+   *     org.eclipse.jgit.lib.RefDatabase#getRefs(String)}.
+   * @throws IOException if an error occurred.
+   */
+  public Map<String, ObjectId> getRefs(String prefix) throws IOException {
+    Map<String, ObjectId> result =
+        new HashMap<>(
+            Maps.transformValues(repo.getRefDatabase().getRefs(prefix), Ref::getObjectId));
+
+    // First, overwrite any cached reads from the underlying RepoRefCache. If any of these differ,
+    // it's because a ref was updated after the RepoRefCache read it. It feels a little odd to
+    // prefer the *old* value in this case, but it would be weirder to be inconsistent with getRef.
+    //
+    // Mostly this doesn't matter. If the caller was intending to write to the ref, they lost a
+    // race, and they will get a lock failure. If they just want to read, well, the JGit interface
+    // doesn't currently guarantee that any snapshot of multiple refs is consistent, so they were
+    // probably out of luck anyway.
+    commands
+        .getRepoRefCache()
+        .getCachedRefs()
+        .forEach((k, v) -> updateRefIfPrefixMatches(result, prefix, k, v));
+
+    // Second, overwrite with any pending commands.
+    commands
+        .getCommands()
+        .values()
+        .forEach(
+            c ->
+                updateRefIfPrefixMatches(result, prefix, c.getRefName(), toOptional(c.getNewId())));
+
+    return result;
+  }
+
+  private static Optional<ObjectId> toOptional(ObjectId id) {
+    return id.equals(ObjectId.zeroId()) ? Optional.empty() : Optional.of(id);
+  }
+
+  private static void updateRefIfPrefixMatches(
+      Map<String, ObjectId> map, String prefix, String fullRefName, Optional<ObjectId> maybeId) {
+    if (!fullRefName.startsWith(prefix)) {
+      return;
+    }
+    String suffix = fullRefName.substring(prefix.length());
+    if (maybeId.isPresent()) {
+      map.put(suffix, maybeId.get());
+    } else {
+      map.remove(suffix);
+    }
+  }
+
+  // Not AutoCloseable so callers can't improperly close it. Plus it's never managed with a try
+  // block anyway.
+  void close() {
+    if (closeRepo) {
+      inserter.close();
+      rw.close();
+      repo.close();
+    }
+  }
+
+  Repository getRepository() {
+    return repo;
+  }
+
+  ObjectInserter getInserter() {
+    return inserter;
+  }
+
+  ObjectInserter getInserterWrapper() {
+    return inserterWrapper;
+  }
+
+  ChainedReceiveCommands getCommands() {
+    return commands;
+  }
+
+  private static class NonFlushingInserter extends ObjectInserter.Filter {
+    private final ObjectInserter delegate;
+
+    private NonFlushingInserter(ObjectInserter delegate) {
+      this.delegate = delegate;
+    }
+
+    @Override
+    protected ObjectInserter delegate() {
+      return delegate;
+    }
+
+    @Override
+    public void flush() {
+      // Do nothing.
+    }
+
+    @Override
+    public void close() {
+      // Do nothing; the delegate is closed separately.
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/RetryHelper.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/RetryHelper.java
new file mode 100644
index 0000000..403b0a4
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/RetryHelper.java
@@ -0,0 +1,102 @@
+// Copyright (C) 2017 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.update;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import com.github.rholder.retry.RetryException;
+import com.github.rholder.retry.RetryerBuilder;
+import com.github.rholder.retry.StopStrategies;
+import com.github.rholder.retry.StopStrategy;
+import com.github.rholder.retry.WaitStrategies;
+import com.github.rholder.retry.WaitStrategy;
+import com.google.common.base.Throwables;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.LockFailureException;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class RetryHelper {
+  public interface Action<T> {
+    T call(BatchUpdate.Factory updateFactory) throws Exception;
+  }
+
+  private final NotesMigration migration;
+  private final BatchUpdate.Factory updateFactory;
+  private final StopStrategy stopStrategy;
+  private final WaitStrategy waitStrategy;
+
+  @Inject
+  RetryHelper(
+      @GerritServerConfig Config cfg,
+      NotesMigration migration,
+      ReviewDbBatchUpdate.AssistedFactory reviewDbBatchUpdateFactory,
+      FusedNoteDbBatchUpdate.AssistedFactory fusedNoteDbBatchUpdateFactory,
+      UnfusedNoteDbBatchUpdate.AssistedFactory unfusedNoteDbBatchUpdateFactory) {
+    this.migration = migration;
+    this.updateFactory =
+        new BatchUpdate.Factory(
+            migration,
+            reviewDbBatchUpdateFactory,
+            fusedNoteDbBatchUpdateFactory,
+            unfusedNoteDbBatchUpdateFactory);
+    this.stopStrategy =
+        StopStrategies.stopAfterDelay(
+            cfg.getTimeUnit("noteDb", null, "retryTimeout", SECONDS.toMillis(5), MILLISECONDS),
+            MILLISECONDS);
+    this.waitStrategy =
+        WaitStrategies.join(
+            WaitStrategies.exponentialWait(
+                cfg.getTimeUnit("noteDb", null, "retryMaxWait", SECONDS.toMillis(20), MILLISECONDS),
+                MILLISECONDS),
+            WaitStrategies.randomWait(50, MILLISECONDS));
+  }
+
+  public <T> T execute(Action<T> action) throws RestApiException, UpdateException {
+    try {
+      RetryerBuilder<T> builder = RetryerBuilder.newBuilder();
+      if (migration.disableChangeReviewDb() && migration.fuseUpdates()) {
+        builder
+            .withStopStrategy(stopStrategy)
+            .withWaitStrategy(waitStrategy)
+            .retryIfException(RetryHelper::isLockFailure);
+      } else {
+        // Either we aren't full-NoteDb, or the underlying ref storage doesn't support atomic
+        // transactions. Either way, retrying a partially-failed operation is not idempotent, so
+        // don't do it automatically. Let the end user decide whether they want to retry.
+      }
+      return builder.build().call(() -> action.call(updateFactory));
+    } catch (ExecutionException | RetryException e) {
+      if (e.getCause() != null) {
+        Throwables.throwIfInstanceOf(e.getCause(), UpdateException.class);
+        Throwables.throwIfInstanceOf(e.getCause(), RestApiException.class);
+      }
+      throw new UpdateException(e);
+    }
+  }
+
+  private static boolean isLockFailure(Throwable t) {
+    if (t instanceof UpdateException) {
+      t = t.getCause();
+    }
+    return t instanceof LockFailureException;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/RetryingRestModifyView.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/RetryingRestModifyView.java
new file mode 100644
index 0000000..e2f4a02
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/RetryingRestModifyView.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2017 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.update;
+
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestResource;
+
+public abstract class RetryingRestModifyView<R extends RestResource, I, O>
+    implements RestModifyView<R, I> {
+  private final RetryHelper retryHelper;
+
+  protected RetryingRestModifyView(RetryHelper retryHelper) {
+    this.retryHelper = retryHelper;
+  }
+
+  @Override
+  public final O apply(R resource, I input) throws Exception {
+    return retryHelper.execute((updateFactory) -> applyImpl(updateFactory, resource, input));
+  }
+
+  protected abstract O applyImpl(BatchUpdate.Factory updateFactory, R resource, I input)
+      throws Exception;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
index 2b07280..aaf7486 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.update;
 
-import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static java.util.Comparator.comparing;
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Stopwatch;
 import com.google.common.base.Throwables;
@@ -30,7 +30,6 @@
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
@@ -59,17 +58,12 @@
 import com.google.gerrit.server.notedb.NoteDbUpdateManager.MismatchedStateException;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.NoSuchRefException;
 import com.google.gerrit.server.util.RequestId;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
@@ -85,7 +79,6 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -118,19 +111,14 @@
   }
 
   class ContextImpl implements Context {
-    private Repository repoWrapper;
-
     @Override
-    public Repository getRepository() throws IOException {
-      if (repoWrapper == null) {
-        repoWrapper = new ReadOnlyRepository(ReviewDbBatchUpdate.this.getRepository());
-      }
-      return repoWrapper;
+    public RepoView getRepoView() throws IOException {
+      return ReviewDbBatchUpdate.this.getRepoView();
     }
 
     @Override
     public RevWalk getRevWalk() throws IOException {
-      return ReviewDbBatchUpdate.this.getRevWalk();
+      return getRepoView().getRevWalk();
     }
 
     @Override
@@ -166,19 +154,14 @@
 
   private class RepoContextImpl extends ContextImpl implements RepoContext {
     @Override
-    public Repository getRepository() throws IOException {
-      return ReviewDbBatchUpdate.this.getRepository();
-    }
-
-    @Override
     public ObjectInserter getInserter() throws IOException {
-      return ReviewDbBatchUpdate.this.getObjectInserter();
+      return getRepoView().getInserterWrapper();
     }
 
     @Override
     public void addRefUpdate(ReceiveCommand cmd) throws IOException {
       initRepository();
-      commands.add(cmd);
+      repoView.getCommands().add(cmd);
     }
   }
 
@@ -208,11 +191,6 @@
     }
 
     @Override
-    public Repository getRepository() {
-      return threadLocalRepo;
-    }
-
-    @Override
     public RevWalk getRevWalk() {
       return threadLocalRevWalk;
     }
@@ -238,8 +216,8 @@
     }
 
     @Override
-    public void bumpLastUpdatedOn(boolean bump) {
-      bumpLastUpdatedOn = bump;
+    public void dontBumpLastUpdatedOn() {
+      bumpLastUpdatedOn = false;
     }
 
     @Override
@@ -273,18 +251,9 @@
     if (updates.isEmpty()) {
       return;
     }
-    if (requestId != null) {
-      for (BatchUpdate u : updates) {
-        checkArgument(
-            u.requestId == null || u.requestId == requestId,
-            "refusing to overwrite RequestId %s in update with %s",
-            u.requestId,
-            requestId);
-        u.setRequestId(requestId);
-      }
-    }
+    setRequestIds(updates, requestId);
     try {
-      Order order = getOrder(updates);
+      Order order = getOrder(updates, listener);
       boolean updateChangesInParallel = getUpdateChangesInParallel(updates);
       switch (order) {
         case REPO_BEFORE_DB:
@@ -305,59 +274,37 @@
           for (ReviewDbBatchUpdate u : updates) {
             u.reindexChanges(u.executeChangeOps(updateChangesInParallel, dryrun));
           }
-          listener.afterUpdateChanges();
           for (ReviewDbBatchUpdate u : updates) {
             u.executeUpdateRepo();
           }
-          listener.afterUpdateRepos();
           for (ReviewDbBatchUpdate u : updates) {
             u.executeRefUpdates(dryrun);
           }
-          listener.afterUpdateRefs();
           break;
         default:
           throw new IllegalStateException("invalid execution order: " + order);
       }
 
-      List<CheckedFuture<?, IOException>> indexFutures = new ArrayList<>();
-      for (ReviewDbBatchUpdate u : updates) {
-        indexFutures.addAll(u.indexFutures);
-      }
-      ChangeIndexer.allAsList(indexFutures).get();
+      ChangeIndexer.allAsList(
+              updates.stream().flatMap(u -> u.indexFutures.stream()).collect(toList()))
+          .get();
 
-      for (ReviewDbBatchUpdate u : updates) {
-        if (u.batchRefUpdate != null) {
-          // Fire ref update events only after all mutations are finished, since
-          // callers may assume a patch set ref being created means the change
-          // was created, or a branch advancing meaning some changes were
-          // closed.
-          u.gitRefUpdated.fire(
-              u.project,
-              u.batchRefUpdate,
-              u.getUser().isIdentifiedUser() ? u.getUser().asIdentifiedUser().getAccount() : null);
-        }
-      }
+      // Fire ref update events only after all mutations are finished, since callers may assume a
+      // patch set ref being created means the change was created, or a branch advancing meaning
+      // some changes were closed.
+      updates
+          .stream()
+          .filter(u -> u.batchRefUpdate != null)
+          .forEach(
+              u -> u.gitRefUpdated.fire(u.project, u.batchRefUpdate, u.getAccount().orElse(null)));
+
       if (!dryrun) {
         for (ReviewDbBatchUpdate u : updates) {
           u.executePostOps();
         }
       }
-    } catch (UpdateException | RestApiException e) {
-      // Propagate REST API exceptions thrown by operations; they commonly throw
-      // exceptions like ResourceConflictException to indicate an atomic update
-      // failure.
-      throw e;
-
-      // Convert other common non-REST exception types with user-visible
-      // messages to corresponding REST exception types
-    } catch (InvalidChangeOperationException e) {
-      throw new ResourceConflictException(e.getMessage(), e);
-    } catch (NoSuchChangeException | NoSuchRefException | NoSuchProjectException e) {
-      throw new ResourceNotFoundException(e.getMessage(), e);
-
     } catch (Exception e) {
-      Throwables.throwIfUnchecked(e);
-      throw new UpdateException(e);
+      wrapAndThrowException(e);
     }
   }
 
@@ -376,7 +323,7 @@
   private final long skewMs;
   private final List<CheckedFuture<?, IOException>> indexFutures = new ArrayList<>();
 
-  @AssistedInject
+  @Inject
   ReviewDbBatchUpdate(
       @GerritServerConfig Config cfg,
       AllUsersName allUsers,
@@ -413,11 +360,6 @@
   }
 
   @Override
-  public void execute() throws UpdateException, RestApiException {
-    execute(BatchUpdateListener.NONE);
-  }
-
-  @Override
   public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
     execute(ImmutableList.of(this), listener, requestId, false);
   }
@@ -440,20 +382,18 @@
         op.updateRepo(ctx);
       }
 
-      if (onSubmitValidators != null && commands != null && !commands.isEmpty()) {
-        try (ObjectReader reader = ctx.getInserter().newReader()) {
-          // Validation of refs has to take place here and not at the beginning
-          // executeRefUpdates. Otherwise failing validation in a second BatchUpdate object will
-          // happen *after* first object's executeRefUpdates has finished, hence after first repo's
-          // refs have been updated, which is too late.
-          onSubmitValidators.validate(
-              project, new ReadOnlyRepository(getRepository()), reader, commands.getCommands());
-        }
+      if (onSubmitValidators != null && !getRefUpdates().isEmpty()) {
+        // Validation of refs has to take place here and not at the beginning of executeRefUpdates.
+        // Otherwise, failing validation in a second BatchUpdate object will happen *after* the
+        // first update's executeRefUpdates has finished, hence after first repo's refs have been
+        // updated, which is too late.
+        onSubmitValidators.validate(
+            project, ctx.getRevWalk().getObjectReader(), repoView.getCommands());
       }
 
-      if (inserter != null) {
+      if (repoView != null) {
         logDebug("Flushing inserter");
-        inserter.flush();
+        repoView.getInserter().flush();
       } else {
         logDebug("No objects to flush");
       }
@@ -464,20 +404,28 @@
   }
 
   private void executeRefUpdates(boolean dryrun) throws IOException, RestApiException {
-    if (commands == null || commands.isEmpty()) {
+    if (getRefUpdates().isEmpty()) {
       logDebug("No ref updates to execute");
       return;
     }
     // May not be opened if the caller added ref updates but no new objects.
+    // TODO(dborowitz): Really?
     initRepository();
-    batchRefUpdate = repo.getRefDatabase().newBatchUpdate();
-    commands.addTo(batchRefUpdate);
+    batchRefUpdate = repoView.getRepository().getRefDatabase().newBatchUpdate();
+    batchRefUpdate.setPushCertificate(pushCert);
+    batchRefUpdate.setRefLogMessage(refLogMessage, true);
+    batchRefUpdate.setAllowNonFastForwards(true);
+    repoView.getCommands().addTo(batchRefUpdate);
     logDebug("Executing batch of {} ref updates", batchRefUpdate.getCommands().size());
     if (dryrun) {
       return;
     }
 
-    batchRefUpdate.execute(revWalk, NullProgressMonitor.INSTANCE);
+    // Force BatchRefUpdate to read newly referenced objects using a new RevWalk, rather than one
+    // that might have access to unflushed objects.
+    try (RevWalk updateRw = new RevWalk(repoView.getRepository())) {
+      batchRefUpdate.execute(updateRw, NullProgressMonitor.INSTANCE);
+    }
     boolean ok = true;
     for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
       if (cmd.getResult() != ReceiveCommand.Result.OK) {
@@ -502,11 +450,11 @@
 
       tasks = new ArrayList<>(ops.keySet().size());
       try {
-        if (notesMigration.commitChangeWrites() && repo != null) {
+        if (notesMigration.commitChangeWrites() && repoView != null) {
           // A NoteDb change may have been rebuilt since the repo was originally
           // opened, so make sure we see that.
           logDebug("Preemptively scanning for repo changes");
-          repo.scanForRepoChanges();
+          repoView.getRepository().scanForRepoChanges();
         }
         if (!ops.isEmpty() && notesMigration.failChangeWrites()) {
           // Fail fast before attempting any writes if changes are read-only, as
@@ -574,9 +522,10 @@
     // updates on the change repo first.
     logDebug("Executing NoteDb updates for {} changes", tasks.size());
     try {
-      BatchRefUpdate changeRefUpdate = getRepository().getRefDatabase().newBatchUpdate();
+      initRepository();
+      BatchRefUpdate changeRefUpdate = repoView.getRepository().getRefDatabase().newBatchUpdate();
       boolean hasAllUsersCommands = false;
-      try (ObjectInserter ins = getRepository().newObjectInserter()) {
+      try (ObjectInserter ins = repoView.getRepository().newObjectInserter()) {
         int objs = 0;
         for (ChangeTask task : tasks) {
           if (task.noteDbResult == null) {
@@ -683,7 +632,8 @@
     public Void call() throws Exception {
       taskId = id.toString() + "-" + Thread.currentThread().getId();
       if (Thread.currentThread() == mainThread) {
-        Repository repo = getRepository();
+        initRepository();
+        Repository repo = repoView.getRepository();
         try (RevWalk rw = new RevWalk(repo)) {
           call(ReviewDbBatchUpdate.this.db, repo, rw);
         }
@@ -842,7 +792,10 @@
           updateManagerFactory
               .create(ctx.getProject())
               .setChangeRepo(
-                  ctx.getRepository(), ctx.getRevWalk(), null, new ChainedReceiveCommands(repo));
+                  ctx.threadLocalRepo,
+                  ctx.threadLocalRevWalk,
+                  null,
+                  new ChainedReceiveCommands(ctx.threadLocalRepo));
       if (ctx.getUser().isIdentifiedUser()) {
         updateManager.setRefLogIdent(
             ctx.getUser().asIdentifiedUser().newRefLogIdent(ctx.getWhen(), tz));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/UnfusedNoteDbBatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/UnfusedNoteDbBatchUpdate.java
new file mode 100644
index 0000000..ab4b701
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/UnfusedNoteDbBatchUpdate.java
@@ -0,0 +1,461 @@
+// Copyright (C) 2017 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.update;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Maps;
+import com.google.common.util.concurrent.CheckedFuture;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.util.RequestId;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.TimeZone;
+import java.util.TreeMap;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/**
+ * {@link BatchUpdate} implementation that only supports NoteDb.
+ *
+ * <p>Used when {@code noteDb.changes.disableReviewDb=true}, at which point ReviewDb is not
+ * consulted during updates.
+ */
+class UnfusedNoteDbBatchUpdate extends BatchUpdate {
+  interface AssistedFactory {
+    UnfusedNoteDbBatchUpdate create(
+        ReviewDb db, Project.NameKey project, CurrentUser user, Timestamp when);
+  }
+
+  static void execute(
+      ImmutableList<UnfusedNoteDbBatchUpdate> updates,
+      BatchUpdateListener listener,
+      @Nullable RequestId requestId,
+      boolean dryrun)
+      throws UpdateException, RestApiException {
+    if (updates.isEmpty()) {
+      return;
+    }
+    setRequestIds(updates, requestId);
+
+    try {
+      Order order = getOrder(updates, listener);
+      // TODO(dborowitz): Fuse implementations to use a single BatchRefUpdate between phases. Note
+      // that we may still need to respect the order, since op implementations may make assumptions
+      // about the order in which their methods are called.
+      switch (order) {
+        case REPO_BEFORE_DB:
+          for (UnfusedNoteDbBatchUpdate u : updates) {
+            u.executeUpdateRepo();
+          }
+          listener.afterUpdateRepos();
+          for (UnfusedNoteDbBatchUpdate u : updates) {
+            u.executeRefUpdates(dryrun);
+          }
+          listener.afterUpdateRefs();
+          for (UnfusedNoteDbBatchUpdate u : updates) {
+            u.reindexChanges(u.executeChangeOps(dryrun), dryrun);
+          }
+          listener.afterUpdateChanges();
+          break;
+        case DB_BEFORE_REPO:
+          for (UnfusedNoteDbBatchUpdate u : updates) {
+            u.reindexChanges(u.executeChangeOps(dryrun), dryrun);
+          }
+          for (UnfusedNoteDbBatchUpdate u : updates) {
+            u.executeUpdateRepo();
+          }
+          for (UnfusedNoteDbBatchUpdate u : updates) {
+            u.executeRefUpdates(dryrun);
+          }
+          break;
+        default:
+          throw new IllegalStateException("invalid execution order: " + order);
+      }
+
+      ChangeIndexer.allAsList(
+              updates.stream().flatMap(u -> u.indexFutures.stream()).collect(toList()))
+          .get();
+
+      // Fire ref update events only after all mutations are finished, since callers may assume a
+      // patch set ref being created means the change was created, or a branch advancing meaning
+      // some changes were closed.
+      updates
+          .stream()
+          .filter(u -> u.batchRefUpdate != null)
+          .forEach(
+              u -> u.gitRefUpdated.fire(u.project, u.batchRefUpdate, u.getAccount().orElse(null)));
+
+      if (!dryrun) {
+        for (UnfusedNoteDbBatchUpdate u : updates) {
+          u.executePostOps();
+        }
+      }
+    } catch (Exception e) {
+      wrapAndThrowException(e);
+    }
+  }
+
+  class ContextImpl implements Context {
+    @Override
+    public RepoView getRepoView() throws IOException {
+      return UnfusedNoteDbBatchUpdate.this.getRepoView();
+    }
+
+    @Override
+    public RevWalk getRevWalk() throws IOException {
+      return getRepoView().getRevWalk();
+    }
+
+    @Override
+    public Project.NameKey getProject() {
+      return project;
+    }
+
+    @Override
+    public Timestamp getWhen() {
+      return when;
+    }
+
+    @Override
+    public TimeZone getTimeZone() {
+      return tz;
+    }
+
+    @Override
+    public ReviewDb getDb() {
+      return db;
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      return user;
+    }
+
+    @Override
+    public Order getOrder() {
+      return order;
+    }
+  }
+
+  private class RepoContextImpl extends ContextImpl implements RepoContext {
+    @Override
+    public ObjectInserter getInserter() throws IOException {
+      return getRepoView().getInserterWrapper();
+    }
+
+    @Override
+    public void addRefUpdate(ReceiveCommand cmd) throws IOException {
+      getRepoView().getCommands().add(cmd);
+    }
+  }
+
+  private class ChangeContextImpl extends ContextImpl implements ChangeContext {
+    private final ChangeControl ctl;
+    private final Map<PatchSet.Id, ChangeUpdate> updates;
+
+    private boolean deleted;
+
+    protected ChangeContextImpl(ChangeControl ctl) {
+      this.ctl = checkNotNull(ctl);
+      updates = new TreeMap<>(comparing(PatchSet.Id::get));
+    }
+
+    @Override
+    public ChangeUpdate getUpdate(PatchSet.Id psId) {
+      ChangeUpdate u = updates.get(psId);
+      if (u == null) {
+        u = changeUpdateFactory.create(ctl, when);
+        if (newChanges.containsKey(ctl.getId())) {
+          u.setAllowWriteToNewRef(true);
+        }
+        u.setPatchSetId(psId);
+        updates.put(psId, u);
+      }
+      return u;
+    }
+
+    @Override
+    public ChangeControl getControl() {
+      return ctl;
+    }
+
+    @Override
+    public void dontBumpLastUpdatedOn() {
+      // Do nothing; NoteDb effectively updates timestamp if and only if a commit was written to the
+      // change meta ref.
+    }
+
+    @Override
+    public void deleteChange() {
+      deleted = true;
+    }
+  }
+
+  /** Per-change result status from {@link #executeChangeOps}. */
+  private enum ChangeResult {
+    SKIPPED,
+    UPSERTED,
+    DELETED;
+  }
+
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final ChangeControl.GenericFactory changeControlFactory;
+  private final ChangeUpdate.Factory changeUpdateFactory;
+  private final NoteDbUpdateManager.Factory updateManagerFactory;
+  private final ChangeIndexer indexer;
+  private final GitReferenceUpdated gitRefUpdated;
+  private final ReviewDb db;
+
+  private List<CheckedFuture<?, IOException>> indexFutures;
+
+  @Inject
+  UnfusedNoteDbBatchUpdate(
+      GitRepositoryManager repoManager,
+      @GerritPersonIdent PersonIdent serverIdent,
+      ChangeNotes.Factory changeNotesFactory,
+      ChangeControl.GenericFactory changeControlFactory,
+      ChangeUpdate.Factory changeUpdateFactory,
+      NoteDbUpdateManager.Factory updateManagerFactory,
+      ChangeIndexer indexer,
+      GitReferenceUpdated gitRefUpdated,
+      @Assisted ReviewDb db,
+      @Assisted Project.NameKey project,
+      @Assisted CurrentUser user,
+      @Assisted Timestamp when) {
+    super(repoManager, serverIdent, project, user, when);
+    checkArgument(!db.changesTablesEnabled(), "expected Change tables to be disabled on %s", db);
+    this.changeNotesFactory = changeNotesFactory;
+    this.changeControlFactory = changeControlFactory;
+    this.changeUpdateFactory = changeUpdateFactory;
+    this.updateManagerFactory = updateManagerFactory;
+    this.indexer = indexer;
+    this.gitRefUpdated = gitRefUpdated;
+    this.db = db;
+    this.indexFutures = new ArrayList<>();
+  }
+
+  @Override
+  public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
+    execute(ImmutableList.of(this), listener, requestId, false);
+  }
+
+  @Override
+  protected Context newContext() {
+    return new ContextImpl();
+  }
+
+  private void executeUpdateRepo() throws UpdateException, RestApiException {
+    try {
+      logDebug("Executing updateRepo on {} ops", ops.size());
+      RepoContextImpl ctx = new RepoContextImpl();
+      for (BatchUpdateOp op : ops.values()) {
+        op.updateRepo(ctx);
+      }
+
+      logDebug("Executing updateRepo on {} RepoOnlyOps", repoOnlyOps.size());
+      for (RepoOnlyOp op : repoOnlyOps) {
+        op.updateRepo(ctx);
+      }
+
+      if (onSubmitValidators != null && !getRefUpdates().isEmpty()) {
+        // Validation of refs has to take place here and not at the beginning of executeRefUpdates.
+        // Otherwise, failing validation in a second BatchUpdate object will happen *after* the
+        // first update's executeRefUpdates has finished, hence after first repo's refs have been
+        // updated, which is too late.
+        onSubmitValidators.validate(
+            project, ctx.getRevWalk().getObjectReader(), repoView.getCommands());
+      }
+
+      // TODO(dborowitz): Don't flush when fusing phases.
+      if (repoView != null) {
+        logDebug("Flushing inserter");
+        repoView.getInserter().flush();
+      } else {
+        logDebug("No objects to flush");
+      }
+    } catch (Exception e) {
+      Throwables.throwIfInstanceOf(e, RestApiException.class);
+      throw new UpdateException(e);
+    }
+  }
+
+  // TODO(dborowitz): Don't execute non-change ref updates separately when fusing phases.
+  private void executeRefUpdates(boolean dryrun) throws IOException, RestApiException {
+    if (getRefUpdates().isEmpty()) {
+      logDebug("No ref updates to execute");
+      return;
+    }
+    // May not be opened if the caller added ref updates but no new objects.
+    initRepository();
+    batchRefUpdate = repoView.getRepository().getRefDatabase().newBatchUpdate();
+    batchRefUpdate.setPushCertificate(pushCert);
+    batchRefUpdate.setRefLogMessage(refLogMessage, true);
+    batchRefUpdate.setAllowNonFastForwards(true);
+    repoView.getCommands().addTo(batchRefUpdate);
+    logDebug("Executing batch of {} ref updates", batchRefUpdate.getCommands().size());
+    if (dryrun) {
+      return;
+    }
+
+    // Force BatchRefUpdate to read newly referenced objects using a new RevWalk, rather than one
+    // that might have access to unflushed objects.
+    try (RevWalk updateRw = new RevWalk(repoView.getRepository())) {
+      batchRefUpdate.execute(updateRw, NullProgressMonitor.INSTANCE);
+    }
+    boolean ok = true;
+    for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
+      if (cmd.getResult() != ReceiveCommand.Result.OK) {
+        ok = false;
+        break;
+      }
+    }
+    if (!ok) {
+      throw new RestApiException("BatchRefUpdate failed: " + batchRefUpdate);
+    }
+  }
+
+  private Map<Change.Id, ChangeResult> executeChangeOps(boolean dryrun) throws Exception {
+    logDebug("Executing change ops");
+    Map<Change.Id, ChangeResult> result =
+        Maps.newLinkedHashMapWithExpectedSize(ops.keySet().size());
+    initRepository();
+    Repository repo = repoView.getRepository();
+    // TODO(dborowitz): Teach NoteDbUpdateManager to allow reusing the same inserter and batch ref
+    // update as in executeUpdateRepo.
+    try (ObjectInserter ins = repo.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk rw = new RevWalk(reader);
+        NoteDbUpdateManager updateManager =
+            updateManagerFactory
+                .create(project)
+                .setChangeRepo(repo, rw, ins, new ChainedReceiveCommands(repo))) {
+      if (user.isIdentifiedUser()) {
+        updateManager.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, tz));
+      }
+      for (Map.Entry<Change.Id, Collection<BatchUpdateOp>> e : ops.asMap().entrySet()) {
+        Change.Id id = e.getKey();
+        ChangeContextImpl ctx = newChangeContext(id);
+        boolean dirty = false;
+        logDebug("Applying {} ops for change {}", e.getValue().size(), id);
+        for (BatchUpdateOp op : e.getValue()) {
+          dirty |= op.updateChange(ctx);
+        }
+        if (!dirty) {
+          logDebug("No ops reported dirty, short-circuiting");
+          result.put(id, ChangeResult.SKIPPED);
+          continue;
+        }
+        for (ChangeUpdate u : ctx.updates.values()) {
+          updateManager.add(u);
+        }
+        if (ctx.deleted) {
+          logDebug("Change {} was deleted", id);
+          updateManager.deleteChange(id);
+          result.put(id, ChangeResult.DELETED);
+        } else {
+          result.put(id, ChangeResult.UPSERTED);
+        }
+      }
+
+      if (!dryrun) {
+        logDebug("Executing NoteDb updates");
+        updateManager.execute();
+      }
+    }
+    return result;
+  }
+
+  private ChangeContextImpl newChangeContext(Change.Id id) throws OrmException {
+    logDebug("Opening change {} for update", id);
+    Change c = newChanges.get(id);
+    boolean isNew = c != null;
+    if (!isNew) {
+      // Pass a synthetic change into ChangeNotes.Factory, which will take care of checking for
+      // existence and populating columns from the parsed notes state.
+      // TODO(dborowitz): This dance made more sense when using Reviewdb; consider a nicer way.
+      c = ChangeNotes.Factory.newNoteDbOnlyChange(project, id);
+    } else {
+      logDebug("Change {} is new", id);
+    }
+    ChangeNotes notes = changeNotesFactory.createForBatchUpdate(c, !isNew);
+    ChangeControl ctl = changeControlFactory.controlFor(notes, user);
+    return new ChangeContextImpl(ctl);
+  }
+
+  private void reindexChanges(Map<Change.Id, ChangeResult> updateResults, boolean dryrun) {
+    if (dryrun) {
+      return;
+    }
+    logDebug("Reindexing {} changes", updateResults.size());
+    for (Map.Entry<Change.Id, ChangeResult> e : updateResults.entrySet()) {
+      Change.Id id = e.getKey();
+      switch (e.getValue()) {
+        case UPSERTED:
+          indexFutures.add(indexer.indexAsync(project, id));
+          break;
+        case DELETED:
+          indexFutures.add(indexer.deleteAsync(id));
+          break;
+        case SKIPPED:
+          break;
+        default:
+          throw new IllegalStateException("unexpected result: " + e.getValue());
+      }
+    }
+  }
+
+  private void executePostOps() throws Exception {
+    ContextImpl ctx = new ContextImpl();
+    for (BatchUpdateOp op : ops.values()) {
+      op.postUpdate(ctx);
+    }
+
+    for (RepoOnlyOp op : repoOnlyOps) {
+      op.postUpdate(ctx);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestScopePropagator.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestScopePropagator.java
index 4d66809e..a1333c6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestScopePropagator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestScopePropagator.java
@@ -172,52 +172,46 @@
   /** @see #wrap(Callable) */
   protected abstract <T> Callable<T> wrapImpl(Callable<T> callable);
 
-  protected <T> Callable<T> context(final RequestContext context, final Callable<T> callable) {
-    return new Callable<T>() {
-      @Override
-      public T call() throws Exception {
-        RequestContext old =
-            local.setContext(
-                new RequestContext() {
-                  @Override
-                  public CurrentUser getUser() {
-                    return context.getUser();
-                  }
+  protected <T> Callable<T> context(RequestContext context, Callable<T> callable) {
+    return () -> {
+      RequestContext old =
+          local.setContext(
+              new RequestContext() {
+                @Override
+                public CurrentUser getUser() {
+                  return context.getUser();
+                }
 
-                  @Override
-                  public Provider<ReviewDb> getReviewDbProvider() {
-                    return dbProviderProvider.get();
-                  }
-                });
-        try {
-          return callable.call();
-        } finally {
-          local.setContext(old);
-        }
+                @Override
+                public Provider<ReviewDb> getReviewDbProvider() {
+                  return dbProviderProvider.get();
+                }
+              });
+      try {
+        return callable.call();
+      } finally {
+        local.setContext(old);
       }
     };
   }
 
-  protected <T> Callable<T> cleanup(final Callable<T> callable) {
-    return new Callable<T>() {
-      @Override
-      public T call() throws Exception {
-        RequestCleanup cleanup =
-            scope
-                .scope(
-                    Key.get(RequestCleanup.class),
-                    new Provider<RequestCleanup>() {
-                      @Override
-                      public RequestCleanup get() {
-                        return new RequestCleanup();
-                      }
-                    })
-                .get();
-        try {
-          return callable.call();
-        } finally {
-          cleanup.run();
-        }
+  protected <T> Callable<T> cleanup(Callable<T> callable) {
+    return () -> {
+      RequestCleanup cleanup =
+          scope
+              .scope(
+                  Key.get(RequestCleanup.class),
+                  new Provider<RequestCleanup>() {
+                    @Override
+                    public RequestCleanup get() {
+                      return new RequestCleanup();
+                    }
+                  })
+              .get();
+      try {
+        return callable.call();
+      } finally {
+        cleanup.run();
       }
     };
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/ThreadLocalRequestScopePropagator.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/ThreadLocalRequestScopePropagator.java
index 4b27208..90fb994 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/ThreadLocalRequestScopePropagator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/ThreadLocalRequestScopePropagator.java
@@ -41,21 +41,18 @@
 
   /** @see RequestScopePropagator#wrap(Callable) */
   @Override
-  protected final <T> Callable<T> wrapImpl(final Callable<T> callable) {
-    final C ctx = continuingContext(requireContext());
-    return new Callable<T>() {
-      @Override
-      public T call() throws Exception {
-        C old = threadLocal.get();
-        threadLocal.set(ctx);
-        try {
-          return callable.call();
-        } finally {
-          if (old != null) {
-            threadLocal.set(old);
-          } else {
-            threadLocal.remove();
-          }
+  protected final <T> Callable<T> wrapImpl(Callable<T> callable) {
+    C ctx = continuingContext(requireContext());
+    return () -> {
+      C old = threadLocal.get();
+      threadLocal.set(ctx);
+      try {
+        return callable.call();
+      } finally {
+        if (old != null) {
+          threadLocal.set(old);
+        } else {
+          threadLocal.remove();
         }
       }
     };
diff --git a/gerrit-server/src/main/java/gerrit/PRED__check_user_label_3.java b/gerrit-server/src/main/java/gerrit/PRED__check_user_label_3.java
new file mode 100644
index 0000000..e84b3ac
--- /dev/null
+++ b/gerrit-server/src/main/java/gerrit/PRED__check_user_label_3.java
@@ -0,0 +1,107 @@
+// Copyright (C) 2017 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 gerrit;
+
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.rules.StoredValues;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.LabelPermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
+import com.googlecode.prolog_cafe.exceptions.JavaException;
+import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.exceptions.SystemException;
+import com.googlecode.prolog_cafe.lang.IntegerTerm;
+import com.googlecode.prolog_cafe.lang.JavaObjectTerm;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Predicate;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.SymbolTerm;
+import com.googlecode.prolog_cafe.lang.Term;
+import com.googlecode.prolog_cafe.lang.VariableTerm;
+
+/**
+ * Checks user can set label to val.
+ *
+ * <pre>
+ *   '_check_user_label'(+Label, +CurrentUser, +Val)
+ * </pre>
+ */
+class PRED__check_user_label_3 extends Predicate.P3 {
+  PRED__check_user_label_3(Term a1, Term a2, Term a3, Operation n) {
+    arg1 = a1;
+    arg2 = a2;
+    arg3 = a3;
+    cont = n;
+  }
+
+  @Override
+  public Operation exec(Prolog engine) throws PrologException {
+    engine.setB0();
+    Term a1 = arg1.dereference();
+    Term a2 = arg2.dereference();
+    Term a3 = arg3.dereference();
+
+    if (a1 instanceof VariableTerm) {
+      throw new PInstantiationException(this, 1);
+    }
+    if (!(a1 instanceof SymbolTerm)) {
+      throw new IllegalTypeException(this, 1, "atom", a1);
+    }
+    String label = a1.name();
+
+    if (a2 instanceof VariableTerm) {
+      throw new PInstantiationException(this, 2);
+    }
+    if (!(a2 instanceof JavaObjectTerm) || !a2.convertible(CurrentUser.class)) {
+      throw new IllegalTypeException(this, 2, "CurrentUser)", a2);
+    }
+    CurrentUser user = (CurrentUser) ((JavaObjectTerm) a2).object();
+
+    if (a3 instanceof VariableTerm) {
+      throw new PInstantiationException(this, 3);
+    }
+    if (!(a3 instanceof IntegerTerm)) {
+      throw new IllegalTypeException(this, 3, "integer", a3);
+    }
+    short val = (short) ((IntegerTerm) a3).intValue();
+
+    try {
+      ChangeData cd = StoredValues.CHANGE_DATA.get(engine);
+      LabelType type = cd.getLabelTypes().byLabel(label);
+      if (type == null) {
+        return engine.fail();
+      }
+      StoredValues.PERMISSION_BACKEND
+          .get(engine)
+          .user(user)
+          .change(cd)
+          .check(new LabelPermission.WithValue(type, val));
+      return cont;
+    } catch (OrmException err) {
+      throw new JavaException(this, 1, err);
+    } catch (AuthException err) {
+      return engine.fail();
+    } catch (PermissionBackendException err) {
+      SystemException se = new SystemException(err.getMessage());
+      se.initCause(err);
+      throw se;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/gerrit/PRED__load_commit_labels_1.java b/gerrit-server/src/main/java/gerrit/PRED__load_commit_labels_1.java
index 8b5a33d..5a3d656 100644
--- a/gerrit-server/src/main/java/gerrit/PRED__load_commit_labels_1.java
+++ b/gerrit-server/src/main/java/gerrit/PRED__load_commit_labels_1.java
@@ -38,7 +38,7 @@
     Term listHead = Prolog.Nil;
     try {
       ChangeData cd = StoredValues.CHANGE_DATA.get(engine);
-      LabelTypes types = StoredValues.CHANGE_CONTROL.get(engine).getLabelTypes();
+      LabelTypes types = cd.getLabelTypes();
 
       for (PatchSetApproval a : cd.currentApprovals()) {
         LabelType t = types.byLabel(a.getLabelId());
diff --git a/gerrit-server/src/main/java/gerrit/PRED__user_label_range_4.java b/gerrit-server/src/main/java/gerrit/PRED__user_label_range_4.java
index d06664e..f7f39da 100644
--- a/gerrit-server/src/main/java/gerrit/PRED__user_label_range_4.java
+++ b/gerrit-server/src/main/java/gerrit/PRED__user_label_range_4.java
@@ -14,14 +14,18 @@
 
 package gerrit;
 
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
+import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.rules.StoredValues;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.LabelPermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
 import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
+import com.googlecode.prolog_cafe.exceptions.JavaException;
 import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.exceptions.SystemException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.JavaObjectTerm;
 import com.googlecode.prolog_cafe.lang.Operation;
@@ -30,12 +34,13 @@
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
 import com.googlecode.prolog_cafe.lang.VariableTerm;
+import java.util.Set;
 
 /**
  * Resolves the valid range for a label on a CurrentUser.
  *
  * <pre>
- *   '$user_label_range'(+Label, +CurrentUser, -Min, -Max)
+ *   '_user_label_range'(+Label, +CurrentUser, -Min, -Max)
  * </pre>
  */
 class PRED__user_label_range_4 extends Predicate.P4 {
@@ -71,20 +76,34 @@
     }
     CurrentUser user = (CurrentUser) ((JavaObjectTerm) a2).object();
 
-    ChangeControl ctl = StoredValues.CHANGE_CONTROL.get(engine).forUser(user);
-    PermissionRange range = ctl.getRange(Permission.LABEL + label);
-    if (range == null) {
+    Set<LabelPermission.WithValue> can;
+    try {
+      ChangeData cd = StoredValues.CHANGE_DATA.get(engine);
+      LabelType type = cd.getLabelTypes().byLabel(label);
+      if (type == null) {
+        return engine.fail();
+      }
+      can = StoredValues.PERMISSION_BACKEND.get(engine).user(user).change(cd).test(type);
+    } catch (OrmException err) {
+      throw new JavaException(this, 1, err);
+    } catch (PermissionBackendException err) {
+      SystemException se = new SystemException(err.getMessage());
+      se.initCause(err);
+      throw se;
+    }
+
+    int min = 0;
+    int max = 0;
+    for (LabelPermission.WithValue v : can) {
+      min = Math.min(min, v.value());
+      max = Math.max(max, v.value());
+    }
+
+    if (!a3.unify(new IntegerTerm(min), engine.trail)) {
       return engine.fail();
     }
 
-    IntegerTerm min = new IntegerTerm(range.getMin());
-    IntegerTerm max = new IntegerTerm(range.getMax());
-
-    if (!a3.unify(min, engine.trail)) {
-      return engine.fail();
-    }
-
-    if (!a4.unify(max, engine.trail)) {
+    if (!a4.unify(new IntegerTerm(max), engine.trail)) {
       return engine.fail();
     }
 
diff --git a/gerrit-server/src/main/java/gerrit/PRED_get_legacy_label_types_1.java b/gerrit-server/src/main/java/gerrit/PRED_get_legacy_label_types_1.java
index ea3fb17..9bfcc61 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_get_legacy_label_types_1.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_get_legacy_label_types_1.java
@@ -17,6 +17,8 @@
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.rules.StoredValues;
+import com.google.gwtorm.server.OrmException;
+import com.googlecode.prolog_cafe.exceptions.JavaException;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.ListTerm;
@@ -51,7 +53,12 @@
   public Operation exec(Prolog engine) throws PrologException {
     engine.setB0();
     Term a1 = arg1.dereference();
-    List<LabelType> list = StoredValues.CHANGE_CONTROL.get(engine).getLabelTypes().getLabelTypes();
+    List<LabelType> list;
+    try {
+      list = StoredValues.CHANGE_DATA.get(engine).getLabelTypes().getLabelTypes();
+    } catch (OrmException err) {
+      throw new JavaException(this, 1, err);
+    }
     Term head = Prolog.Nil;
     for (int idx = list.size() - 1; 0 <= idx; idx--) {
       head = new ListTerm(export(list.get(idx)), head);
diff --git a/gerrit-server/src/main/prolog/BUILD b/gerrit-server/src/main/prolog/BUILD
deleted file mode 100644
index 603a0bf..0000000
--- a/gerrit-server/src/main/prolog/BUILD
+++ /dev/null
@@ -1,8 +0,0 @@
-load("//lib/prolog:prolog.bzl", "prolog_cafe_library")
-
-prolog_cafe_library(
-    name = "common",
-    srcs = ["gerrit_common.pl"],
-    visibility = ["//visibility:public"],
-    deps = ["//gerrit-server:server"],
-)
diff --git a/gerrit-server/src/main/prolog/gerrit_common.pl b/gerrit-server/src/main/prolog/gerrit_common.pl
index 59c926f..4671e0d 100644
--- a/gerrit-server/src/main/prolog/gerrit_common.pl
+++ b/gerrit-server/src/main/prolog/gerrit_common.pl
@@ -92,6 +92,27 @@
 
 %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
 %%
+%% check_user_label/3:
+%%
+%%   Check Who can set Label to Val.
+%%
+check_user_label(Label, Who, Val) :-
+  hash_get(commit_labels, '$fast_range', true), !,
+  atom(Label),
+  assume_range_from_label(Label, Who, Min, Max),
+  Min @=< Val, Val @=< Max.
+check_user_label(Label, Who, Val) :-
+  Who = user(_), !,
+  atom(Label),
+  current_user(Who, User),
+  '_check_user_label'(Label, User, Val).
+check_user_label(Label, test_user(Name), Val) :-
+  clause(user:test_grant(Label, test_user(Name), range(Min, Max)), _),
+  Min @=< Val, Val @=< Max
+  .
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
 %% user_label_range/4:
 %%
 %%   Lookup the range allowed to be used.
@@ -319,8 +340,7 @@
 %%
 check_label_range_permission(Label, ExpValue, ok(Who)) :-
   commit_label(label(Label, ExpValue), Who),
-  user_label_range(Label, Who, Min, Max),
-  Min @=< ExpValue, ExpValue @=< Max
+  check_user_label(Label, Who, ExpValue)
   .
 %TODO Uncomment this clause when group suggesting is possible.
 %check_label_range_permission(Label, ExpValue, ask(Group)) :-
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/change/ChangeMessages.properties b/gerrit-server/src/main/resources/com/google/gerrit/server/change/ChangeMessages.properties
index f34c992..b2bcde3 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/change/ChangeMessages.properties
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/change/ChangeMessages.properties
@@ -1,9 +1,13 @@
 # Changes to this file should also be made in
 # gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.properties
 revertChangeDefaultMessage = Revert \"{0}\"\n\nThis reverts commit {1}.
+
+reviewerCantSeeChange = {0} does not have permission to see this change
+reviewerInactive = {0} identifies an inactive account
+reviewerInvalid = {0} is not a valid user identifier
 reviewerNotFoundUser = {0} does not identify a registered user
 reviewerNotFoundUserOrGroup = {0} does not identify a registered user or group
 
-groupIsNotAllowed =  The group {0} cannot be added as reviewer.
+groupIsNotAllowed = The group {0} cannot be added as reviewer.
 groupHasTooManyMembers = The group {0} has too many members to add them all as reviewers.
 groupManyMembersConfirmation = The group {0} has {1} members. Do you want to add them all as reviewers?
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy
index 59790dc..9adff05 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy
@@ -38,7 +38,17 @@
 
   {let $ulStyle kind="css"}
     list-style: none;
-    padding-left: 20px;
+    padding: 0;
+  {/let}
+
+  {let $fileLiStyle kind="css"}
+    margin: 0;
+    padding: 0;
+  {/let}
+
+  {let $commentLiStyle kind="css"}
+    margin: 0;
+    padding: 0 0 0 16px;
   {/let}
 
   {let $voteStyle kind="css"}
@@ -104,14 +114,14 @@
 
   <ul style="{$ulStyle}">
     {foreach $group in $commentFiles}
-      <li>
+      <li style="{$fileLiStyle}">
         <p>
           <a href="{$group.link}">{$group.title}:</a>
         </p>
 
         <ul style="{$ulStyle}">
           {foreach $comment in $group.comments}
-            <li>
+            <li style="{$commentLiStyle}">
               {if $comment.isRobotComment}
                 <p style="{$commentHeaderStyle}">
                   Robot Comment from{sp}
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
index fa2b44d..927601b 100644
--- 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
@@ -17,6 +17,7 @@
 {namespace com.google.gerrit.server.mail.template}
 
 /**
+ * @param diffLines
  * @param email
  * @param fromName
  */
@@ -36,6 +37,6 @@
   {call .Pre}{param content: $email.changeDetail /}{/call}
 
   {if $email.includeDiff}
-    {call .Pre}{param content: $email.unifiedDiff /}{/call}
+    {call .UnifiedDiff}{param diffLines: $diffLines /}{/call}
   {/if}
-{/template}
\ No newline at end of file
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
index 559bb26..8026666 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
@@ -17,6 +17,7 @@
 {namespace com.google.gerrit.server.mail.template}
 
 /**
+ * @param diffLines
  * @param email
  * @param fromName
  * @param ownerName
@@ -55,6 +56,6 @@
   {/if}
 
   {if $email.includeDiff}
-    {call .Pre}{param content: $email.unifiedDiff /}{/call}
+    {call .UnifiedDiff}{param diffLines: $diffLines /}{/call}
   {/if}
 {/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy
index 93353d7..556191d 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy
@@ -86,3 +86,36 @@
     {/if}
   {/foreach}
 {/template}
+
+/**
+ * @param diffLines
+ */
+{template .UnifiedDiff private="true" autoescape="strict" kind="html"}
+  {let $addStyle kind="css"}
+    color: green;
+  {/let}
+
+  {let $removeStyle kind="css"}
+    color: red;
+  {/let}
+
+  {let $preStyle kind="css"}
+    font-family: monospace,monospace; // Use this to avoid browsers scaling down
+                                      // monospace text.
+    white-space: pre-wrap;
+  {/let}
+
+  <pre style="{$preStyle}">
+    {foreach $line in $diffLines}
+      {if $line.type == 'add'}
+        <span style="{$addStyle}">
+      {elseif $line.type == 'remove'}
+        <span style="{$removeStyle}">
+      {else}
+        <span>
+      {/if}
+        {$line.text}
+      </span><br>
+    {/foreach}
+  </pre>
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
index bbf16d6..31cfbd6 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
@@ -17,6 +17,7 @@
 {namespace com.google.gerrit.server.mail.template}
 
 /**
+ * @param diffLines
  * @param email
  * @param fromName
  * @param patchSet
@@ -44,6 +45,6 @@
   {/if}
 
   {if $email.includeDiff}
-    {call .Pre}{param content: $email.unifiedDiff /}{/call}
+    {call .UnifiedDiff}{param diffLines: $diffLines /}{/call}
   {/if}
 {/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties b/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties
index 5a937b6..996e8a4 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -26,6 +26,7 @@
 cob = text/x-cobol
 coffee = text/x-coffeescript
 conf = text/plain
+config = text/x-ini
 cpy = text/x-cobol
 cr = text/x-crystal
 cs = text/x-csharp
@@ -156,7 +157,6 @@
 pm = text/x-perl
 pp = text/x-puppet
 pro = text/x-idl
-project.config = text/x-ini
 properties = text/x-ini
 proto = text/x-protobuf
 protobuf = text/x-protobuf
diff --git a/gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java b/gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
index 9974bc6..91b01f6 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
@@ -126,19 +126,16 @@
 
   @Test
   public void callbackMetric0() {
-    final CallbackMetric0<Long> cntr =
+    CallbackMetric0<Long> cntr =
         metrics.newCallbackMetric(
             "test/count", Long.class, new Description("simple test").setCumulative());
 
-    final AtomicInteger invocations = new AtomicInteger(0);
+    AtomicInteger invocations = new AtomicInteger(0);
     metrics.newTrigger(
         cntr,
-        new Runnable() {
-          @Override
-          public void run() {
-            invocations.getAndIncrement();
-            cntr.set(42L);
-          }
+        () -> {
+          invocations.getAndIncrement();
+          cntr.set(42L);
         });
 
     // Triggers run immediately with DropWizard binding.
diff --git a/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java b/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java
index fa4a951..40596e8 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java
@@ -17,8 +17,8 @@
 import static org.easymock.EasyMock.expect;
 
 import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.Util;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.AbstractModule;
 import com.googlecode.prolog_cafe.exceptions.CompileException;
 import com.googlecode.prolog_cafe.exceptions.ReductionLimitException;
@@ -47,27 +47,28 @@
             cfg.setInt("rules", null, "reductionLimit", 1300);
             cfg.setInt("rules", null, "compileReductionLimit", (int) 1e6);
             bind(PrologEnvironment.Args.class)
-                .toInstance(new PrologEnvironment.Args(null, null, null, null, null, null, cfg));
+                .toInstance(
+                    new PrologEnvironment.Args(null, null, null, null, null, null, null, cfg));
           }
         });
   }
 
   @Override
-  protected void setUpEnvironment(PrologEnvironment env) {
+  protected void setUpEnvironment(PrologEnvironment env) throws Exception {
     LabelTypes labelTypes = new LabelTypes(Arrays.asList(Util.codeReview(), Util.verified()));
-    ChangeControl ctl = EasyMock.createMock(ChangeControl.class);
-    expect(ctl.getLabelTypes()).andStubReturn(labelTypes);
-    EasyMock.replay(ctl);
-    env.set(StoredValues.CHANGE_CONTROL, ctl);
+    ChangeData cd = EasyMock.createMock(ChangeData.class);
+    expect(cd.getLabelTypes()).andStubReturn(labelTypes);
+    EasyMock.replay(cd);
+    env.set(StoredValues.CHANGE_DATA, cd);
   }
 
   @Test
-  public void gerritCommon() {
+  public void gerritCommon() throws Exception {
     runPrologBasedTests();
   }
 
   @Test
-  public void reductionLimit() throws CompileException {
+  public void reductionLimit() throws Exception {
     PrologEnvironment env = envFactory.create(machine);
     setUpEnvironment(env);
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/rules/PrologTestCase.java b/gerrit-server/src/test/java/com/google/gerrit/rules/PrologTestCase.java
index 6f6d189..7b2b388 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/rules/PrologTestCase.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/rules/PrologTestCase.java
@@ -84,7 +84,7 @@
    *
    * @param env Prolog environment.
    */
-  protected void setUpEnvironment(PrologEnvironment env) {}
+  protected void setUpEnvironment(PrologEnvironment env) throws Exception {}
 
   private PrologMachineCopy newMachine() {
     BufferingPrologControl ctl = new BufferingPrologControl();
@@ -115,7 +115,7 @@
     return env.execute(Prolog.BUILTIN, "clause", head, new VariableTerm());
   }
 
-  public void runPrologBasedTests() {
+  public void runPrologBasedTests() throws Exception {
     int errors = 0;
     long start = TimeUtil.nowMs();
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java b/gerrit-server/src/test/java/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java
new file mode 100644
index 0000000..801b2b0
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2017 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.edit.tree;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.io.CharStreams;
+import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.StringSubject;
+import com.google.common.truth.Subject;
+import com.google.common.truth.SubjectFactory;
+import com.google.common.truth.Truth;
+import com.google.gerrit.extensions.restapi.RawInput;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+
+public class ChangeFileContentModificationSubject
+    extends Subject<ChangeFileContentModificationSubject, ChangeFileContentModification> {
+
+  private static final SubjectFactory<
+          ChangeFileContentModificationSubject, ChangeFileContentModification>
+      MODIFICATION_SUBJECT_FACTORY =
+          new SubjectFactory<
+              ChangeFileContentModificationSubject, ChangeFileContentModification>() {
+            @Override
+            public ChangeFileContentModificationSubject getSubject(
+                FailureStrategy failureStrategy, ChangeFileContentModification modification) {
+              return new ChangeFileContentModificationSubject(failureStrategy, modification);
+            }
+          };
+
+  public static ChangeFileContentModificationSubject assertThat(
+      ChangeFileContentModification modification) {
+    return assertAbout(MODIFICATION_SUBJECT_FACTORY).that(modification);
+  }
+
+  private ChangeFileContentModificationSubject(
+      FailureStrategy failureStrategy, ChangeFileContentModification modification) {
+    super(failureStrategy, modification);
+  }
+
+  public StringSubject filePath() {
+    isNotNull();
+    return Truth.assertThat(actual().getFilePath()).named("filePath");
+  }
+
+  public StringSubject newContent() throws IOException {
+    isNotNull();
+    RawInput newContent = actual().getNewContent();
+    Truth.assertThat(newContent).named("newContent").isNotNull();
+    String contentString =
+        CharStreams.toString(
+            new InputStreamReader(newContent.getInputStream(), StandardCharsets.UTF_8));
+    return Truth.assertThat(contentString).named("newContent");
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/edit/tree/TreeModificationSubject.java b/gerrit-server/src/test/java/com/google/gerrit/server/edit/tree/TreeModificationSubject.java
new file mode 100644
index 0000000..ac4ebb8
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/edit/tree/TreeModificationSubject.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2017 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.edit.tree;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.Subject;
+import com.google.common.truth.SubjectFactory;
+import com.google.gerrit.truth.ListSubject;
+import java.util.List;
+
+public class TreeModificationSubject extends Subject<TreeModificationSubject, TreeModification> {
+
+  private static final SubjectFactory<TreeModificationSubject, TreeModification>
+      TREE_MODIFICATION_SUBJECT_FACTORY =
+          new SubjectFactory<TreeModificationSubject, TreeModification>() {
+            @Override
+            public TreeModificationSubject getSubject(
+                FailureStrategy failureStrategy, TreeModification treeModification) {
+              return new TreeModificationSubject(failureStrategy, treeModification);
+            }
+          };
+
+  public static TreeModificationSubject assertThat(TreeModification treeModification) {
+    return assertAbout(TREE_MODIFICATION_SUBJECT_FACTORY).that(treeModification);
+  }
+
+  public static ListSubject<TreeModificationSubject, TreeModification> assertThatList(
+      List<TreeModification> treeModifications) {
+    return ListSubject.assertThat(treeModifications, TreeModificationSubject::assertThat)
+        .named("treeModifications");
+  }
+
+  private TreeModificationSubject(
+      FailureStrategy failureStrategy, TreeModification treeModification) {
+    super(failureStrategy, treeModification);
+  }
+
+  public ChangeFileContentModificationSubject asChangeFileContentModification() {
+    isInstanceOf(ChangeFileContentModification.class);
+    return ChangeFileContentModificationSubject.assertThat(
+        (ChangeFileContentModification) actual());
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java
new file mode 100644
index 0000000..c1a65bb
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java
@@ -0,0 +1,333 @@
+// Copyright (C) 2017 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.fixes;
+
+import static com.google.gerrit.server.edit.tree.TreeModificationSubject.assertThatList;
+import static org.easymock.EasyMock.createMock;
+import static org.easymock.EasyMock.replay;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Comment.Range;
+import com.google.gerrit.reviewdb.client.FixReplacement;
+import com.google.gerrit.server.change.FileContentUtil;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import com.google.gerrit.server.project.ProjectState;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import org.easymock.EasyMock;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class FixReplacementInterpreterTest {
+
+  @Rule public ExpectedException expectedException = ExpectedException.none();
+
+  private final FileContentUtil fileContentUtil = createMock(FileContentUtil.class);
+  private final Repository repository = createMock(Repository.class);
+  private final ProjectState projectState = createMock(ProjectState.class);
+  private final ObjectId patchSetCommitId = createMock(ObjectId.class);
+  private final String filePath1 = "an/arbitrary/file.txt";
+  private final String filePath2 = "another/arbitrary/file.txt";
+
+  private FixReplacementInterpreter fixReplacementInterpreter;
+
+  @Before
+  public void setUp() {
+    fixReplacementInterpreter = new FixReplacementInterpreter(fileContentUtil);
+  }
+
+  @Test
+  public void noReplacementsResultInNoTreeModifications() throws Exception {
+    List<TreeModification> treeModifications = toTreeModifications();
+    assertThatList(treeModifications).isEmpty();
+  }
+
+  @Test
+  public void treeModificationsTargetCorrectFiles() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(1, 6, 3, 2), "Modified content");
+    FixReplacement fixReplacement2 =
+        new FixReplacement(filePath1, new Range(3, 5, 3, 5), "Second modification");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+    FixReplacement fixReplacement3 =
+        new FixReplacement(filePath2, new Range(2, 0, 3, 0), "Another modified content");
+    mockFileContent(filePath2, "1st line\n2nd line\n3rd line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications =
+        toTreeModifications(fixReplacement, fixReplacement3, fixReplacement2);
+    List<TreeModification> sortedTreeModifications = getSortedCopy(treeModifications);
+    assertThatList(sortedTreeModifications)
+        .element(0)
+        .asChangeFileContentModification()
+        .filePath()
+        .isEqualTo(filePath1);
+    assertThatList(sortedTreeModifications)
+        .element(0)
+        .asChangeFileContentModification()
+        .newContent()
+        .startsWith("First");
+    assertThatList(sortedTreeModifications)
+        .element(1)
+        .asChangeFileContentModification()
+        .filePath()
+        .isEqualTo(filePath2);
+    assertThatList(sortedTreeModifications)
+        .element(1)
+        .asChangeFileContentModification()
+        .newContent()
+        .startsWith("1st");
+  }
+
+  @Test
+  public void replacementsCanDeleteALine() throws Exception {
+    FixReplacement fixReplacement = new FixReplacement(filePath1, new Range(2, 0, 3, 0), "");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("First line\nThird line\n");
+  }
+
+  @Test
+  public void replacementsCanAddALine() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(2, 0, 2, 0), "A new line\n");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("First line\nA new line\nSecond line\nThird line\n");
+  }
+
+  @Test
+  public void replacementsMaySpanMultipleLines() throws Exception {
+    FixReplacement fixReplacement = new FixReplacement(filePath1, new Range(1, 6, 3, 1), "and t");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("First and third line\n");
+  }
+
+  @Test
+  public void replacementsMayOccurOnSameLine() throws Exception {
+    FixReplacement fixReplacement1 = new FixReplacement(filePath1, new Range(2, 0, 2, 6), "A");
+    FixReplacement fixReplacement2 =
+        new FixReplacement(filePath1, new Range(2, 7, 2, 11), "modification");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications =
+        toTreeModifications(fixReplacement1, fixReplacement2);
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("First line\nA modification\nThird line\n");
+  }
+
+  @Test
+  public void replacementsMayTouch() throws Exception {
+    FixReplacement fixReplacement1 =
+        new FixReplacement(filePath1, new Range(1, 6, 2, 7), "modified ");
+    FixReplacement fixReplacement2 =
+        new FixReplacement(filePath1, new Range(2, 7, 3, 5), "content");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications =
+        toTreeModifications(fixReplacement1, fixReplacement2);
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("First modified content line\n");
+  }
+
+  @Test
+  public void replacementsCanAddContentAtEndOfFile() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(4, 0, 4, 0), "New content");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("First line\nSecond line\nThird line\nNew content");
+  }
+
+  @Test
+  public void replacementsCanModifySeveralFilesInAnyOrder() throws Exception {
+    FixReplacement fixReplacement1 =
+        new FixReplacement(filePath1, new Range(1, 1, 3, 2), "Modified content");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+    FixReplacement fixReplacement2 =
+        new FixReplacement(filePath2, new Range(2, 0, 3, 0), "First modification\n");
+    FixReplacement fixReplacement3 =
+        new FixReplacement(filePath2, new Range(3, 0, 4, 0), "Second modification\n");
+    mockFileContent(filePath2, "1st line\n2nd line\n3rd line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications =
+        toTreeModifications(fixReplacement3, fixReplacement1, fixReplacement2);
+    List<TreeModification> sortedTreeModifications = getSortedCopy(treeModifications);
+    assertThatList(sortedTreeModifications)
+        .element(0)
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("FModified contentird line\n");
+    assertThatList(sortedTreeModifications)
+        .element(1)
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("1st line\nFirst modification\nSecond modification\n");
+  }
+
+  @Test
+  public void lineSeparatorCanBeChanged() throws Exception {
+    FixReplacement fixReplacement = new FixReplacement(filePath1, new Range(2, 11, 3, 0), "\r");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("First line\nSecond line\rThird line\n");
+  }
+
+  @Test
+  public void replacementsDoNotNeedToBeOrderedAccordingToRange() throws Exception {
+    FixReplacement fixReplacement1 =
+        new FixReplacement(filePath1, new Range(1, 0, 2, 0), "1st modification\n");
+    FixReplacement fixReplacement2 =
+        new FixReplacement(filePath1, new Range(3, 0, 4, 0), "2nd modification\n");
+    FixReplacement fixReplacement3 =
+        new FixReplacement(filePath1, new Range(4, 0, 5, 0), "3rd modification\n");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\nFourth line\nFifth line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications =
+        toTreeModifications(fixReplacement2, fixReplacement1, fixReplacement3);
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo(
+            "1st modification\nSecond line\n2nd modification\n3rd modification\nFifth line\n");
+  }
+
+  @Test
+  public void replacementsMustNotReferToNotExistingLine() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(5, 0, 5, 0), "A new line\n");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+
+    expectedException.expect(ResourceConflictException.class);
+    toTreeModifications(fixReplacement);
+  }
+
+  @Test
+  public void replacementsMustNotReferToZeroLine() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(0, 0, 0, 0), "A new line\n");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+
+    expectedException.expect(ResourceConflictException.class);
+    toTreeModifications(fixReplacement);
+  }
+
+  @Test
+  public void replacementsMustNotReferToNotExistingOffsetOfIntermediateLine() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(1, 0, 1, 11), "modified");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+
+    expectedException.expect(ResourceConflictException.class);
+    toTreeModifications(fixReplacement);
+  }
+
+  @Test
+  public void replacementsMustNotReferToNotExistingOffsetOfLastLine() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(3, 0, 3, 11), "modified");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+
+    expectedException.expect(ResourceConflictException.class);
+    toTreeModifications(fixReplacement);
+  }
+
+  @Test
+  public void replacementsMustNotReferToNegativeOffset() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(1, -1, 1, 5), "modified");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+
+    expectedException.expect(ResourceConflictException.class);
+    toTreeModifications(fixReplacement);
+  }
+
+  private void mockFileContent(String filePath, String fileContent) throws Exception {
+    EasyMock.expect(
+            fileContentUtil.getContent(repository, projectState, patchSetCommitId, filePath))
+        .andReturn(BinaryResult.create(fileContent));
+  }
+
+  private List<TreeModification> toTreeModifications(FixReplacement... fixReplacements)
+      throws Exception {
+    return fixReplacementInterpreter.toTreeModifications(
+        repository, projectState, patchSetCommitId, ImmutableList.copyOf(fixReplacements));
+  }
+
+  private static List<TreeModification> getSortedCopy(List<TreeModification> treeModifications) {
+    List<TreeModification> sortedTreeModifications = new ArrayList<>(treeModifications);
+    sortedTreeModifications.sort(Comparator.comparing(TreeModification::getFilePath));
+    return sortedTreeModifications;
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/fixes/LineIdentifierTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/fixes/LineIdentifierTest.java
new file mode 100644
index 0000000..f638346
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/fixes/LineIdentifierTest.java
@@ -0,0 +1,257 @@
+// Copyright (C) 2017 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.fixes;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class LineIdentifierTest {
+
+  @Rule public ExpectedException expectedException = ExpectedException.none();
+
+  @Test
+  public void lineNumberMustBePositive() {
+    LineIdentifier lineIdentifier = new LineIdentifier("First line\nSecond line");
+    expectedException.expect(StringIndexOutOfBoundsException.class);
+    expectedException.expectMessage("positive");
+    lineIdentifier.getStartIndexOfLine(0);
+  }
+
+  @Test
+  public void lineNumberMustIndicateAnAvailableLine() {
+    LineIdentifier lineIdentifier = new LineIdentifier("First line\nSecond line");
+    expectedException.expect(StringIndexOutOfBoundsException.class);
+    expectedException.expectMessage("Line 3 isn't available");
+    lineIdentifier.getStartIndexOfLine(3);
+  }
+
+  @Test
+  public void startIndexOfFirstLineIsRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567");
+    int startIndex = lineIdentifier.getStartIndexOfLine(1);
+    assertThat(startIndex).isEqualTo(0);
+  }
+
+  @Test
+  public void lengthOfFirstLineIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567");
+    int lineLength = lineIdentifier.getLengthOfLine(1);
+    assertThat(lineLength).isEqualTo(8);
+  }
+
+  @Test
+  public void startIndexOfSecondLineIsRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567");
+    int startIndex = lineIdentifier.getStartIndexOfLine(2);
+    assertThat(startIndex).isEqualTo(9);
+  }
+
+  @Test
+  public void lengthOfSecondLineIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567");
+    int lineLength = lineIdentifier.getLengthOfLine(2);
+    assertThat(lineLength).isEqualTo(3);
+  }
+
+  @Test
+  public void startIndexOfLastLineIsRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567");
+    int startIndex = lineIdentifier.getStartIndexOfLine(3);
+    assertThat(startIndex).isEqualTo(13);
+  }
+
+  @Test
+  public void lengthOfLastLineIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567");
+    int lineLength = lineIdentifier.getLengthOfLine(3);
+    assertThat(lineLength).isEqualTo(7);
+  }
+
+  @Test
+  public void emptyFirstLineIsRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("\n123\n1234567");
+    int startIndex = lineIdentifier.getStartIndexOfLine(1);
+    assertThat(startIndex).isEqualTo(0);
+  }
+
+  @Test
+  public void lengthOfEmptyFirstLineIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("\n123\n1234567");
+    int lineLength = lineIdentifier.getLengthOfLine(1);
+    assertThat(lineLength).isEqualTo(0);
+  }
+
+  @Test
+  public void emptyIntermediaryLineIsRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n\n1234567");
+    int startIndex = lineIdentifier.getStartIndexOfLine(2);
+    assertThat(startIndex).isEqualTo(9);
+  }
+
+  @Test
+  public void lengthOfEmptyIntermediaryLineIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n\n1234567");
+    int lineLength = lineIdentifier.getLengthOfLine(2);
+    assertThat(lineLength).isEqualTo(0);
+  }
+
+  @Test
+  public void lineAfterIntermediaryLineIsRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n\n1234567");
+    int startIndex = lineIdentifier.getStartIndexOfLine(3);
+    assertThat(startIndex).isEqualTo(10);
+  }
+
+  @Test
+  public void emptyLastLineIsRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n");
+    int startIndex = lineIdentifier.getStartIndexOfLine(3);
+    assertThat(startIndex).isEqualTo(13);
+  }
+
+  @Test
+  public void lengthOfEmptyLastLineIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n");
+    int lineLength = lineIdentifier.getLengthOfLine(3);
+    assertThat(lineLength).isEqualTo(0);
+  }
+
+  @Test
+  public void startIndexOfSingleLineIsRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678");
+    int startIndex = lineIdentifier.getStartIndexOfLine(1);
+    assertThat(startIndex).isEqualTo(0);
+  }
+
+  @Test
+  public void lengthOfSingleLineIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678");
+    int lineLength = lineIdentifier.getLengthOfLine(1);
+    assertThat(lineLength).isEqualTo(8);
+  }
+
+  @Test
+  public void startIndexOfSingleEmptyLineIsRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("");
+    int startIndex = lineIdentifier.getStartIndexOfLine(1);
+    assertThat(startIndex).isEqualTo(0);
+  }
+
+  @Test
+  public void lengthOfSingleEmptyLineIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("");
+    int lineLength = lineIdentifier.getLengthOfLine(1);
+    assertThat(lineLength).isEqualTo(0);
+  }
+
+  @Test
+  public void lookingUpSubsequentLinesIsPossible() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567\n12");
+
+    int firstLineStartIndex = lineIdentifier.getStartIndexOfLine(1);
+    assertThat(firstLineStartIndex).isEqualTo(0);
+
+    int secondLineStartIndex = lineIdentifier.getStartIndexOfLine(2);
+    assertThat(secondLineStartIndex).isEqualTo(9);
+  }
+
+  @Test
+  public void lookingUpNotSubsequentLinesInAscendingOrderIsPossible() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567\n12");
+
+    int firstLineStartIndex = lineIdentifier.getStartIndexOfLine(1);
+    assertThat(firstLineStartIndex).isEqualTo(0);
+
+    int fourthLineStartIndex = lineIdentifier.getStartIndexOfLine(4);
+    assertThat(fourthLineStartIndex).isEqualTo(21);
+  }
+
+  @Test
+  public void lookingUpNotSubsequentLinesInDescendingOrderIsPossible() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567\n12");
+
+    int fourthLineStartIndex = lineIdentifier.getStartIndexOfLine(4);
+    assertThat(fourthLineStartIndex).isEqualTo(21);
+
+    int secondLineStartIndex = lineIdentifier.getStartIndexOfLine(2);
+    assertThat(secondLineStartIndex).isEqualTo(9);
+  }
+
+  @Test
+  public void linesSeparatedByOnlyCarriageReturnAreRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\r123\r12");
+    int startIndex = lineIdentifier.getStartIndexOfLine(2);
+    assertThat(startIndex).isEqualTo(9);
+  }
+
+  @Test
+  public void lengthOfLinesSeparatedByOnlyCarriageReturnIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\r123\r12");
+    int lineLength = lineIdentifier.getLengthOfLine(2);
+    assertThat(lineLength).isEqualTo(3);
+  }
+
+  @Test
+  public void linesSeparatedByLineFeedAndCarriageReturnAreRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\r\n123\r\n12");
+    int startIndex = lineIdentifier.getStartIndexOfLine(2);
+    assertThat(startIndex).isEqualTo(10);
+  }
+
+  @Test
+  public void lengthOfLinesSeparatedByLineFeedAndCarriageReturnIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\r\n123\r\n12");
+    int lineLength = lineIdentifier.getLengthOfLine(2);
+    assertThat(lineLength).isEqualTo(3);
+  }
+
+  @Test
+  public void linesSeparatedByMixtureOfCarriageReturnAndLineFeedAreRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\r123\r\n12\n123456\r\n1234");
+    int startIndex = lineIdentifier.getStartIndexOfLine(5);
+    assertThat(startIndex).isEqualTo(25);
+  }
+
+  @Test
+  public void linesSeparatedBySomeUnicodeLinebreakCharacterAreRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\u2029123\u202912");
+    int startIndex = lineIdentifier.getStartIndexOfLine(2);
+    assertThat(startIndex).isEqualTo(9);
+  }
+
+  @Test
+  public void lengthOfLinesSeparatedBySomeUnicodeLinebreakCharacterIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\u2029123\u202912");
+    int lineLength = lineIdentifier.getLengthOfLine(2);
+    assertThat(lineLength).isEqualTo(3);
+  }
+
+  @Test
+  public void blanksAreNotInterpretedAsLineSeparators() {
+    LineIdentifier lineIdentifier = new LineIdentifier("1 2345678\n123\n12");
+    int startIndex = lineIdentifier.getStartIndexOfLine(2);
+    assertThat(startIndex).isEqualTo(10);
+  }
+
+  @Test
+  public void tabsAreNotInterpretedAsLineSeparators() {
+    LineIdentifier lineIdentifier = new LineIdentifier("123\t45678\n123\n12");
+    int startIndex = lineIdentifier.getStartIndexOfLine(2);
+    assertThat(startIndex).isEqualTo(10);
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/fixes/StringModifierTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/fixes/StringModifierTest.java
new file mode 100644
index 0000000..d23e928
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/fixes/StringModifierTest.java
@@ -0,0 +1,106 @@
+// Copyright (C) 2017 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.fixes;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class StringModifierTest {
+
+  @Rule public ExpectedException expectedException = ExpectedException.none();
+
+  private final String originalString = "This is the original, unmodified string.";
+  private StringModifier stringModifier;
+
+  @Before
+  public void setUp() {
+    stringModifier = new StringModifier(originalString);
+  }
+
+  @Test
+  public void singlePartIsReplaced() {
+    stringModifier.replace(0, 11, "An");
+    String modifiedString = stringModifier.getResult();
+    assertThat(modifiedString).isEqualTo("An original, unmodified string.");
+  }
+
+  @Test
+  public void twoPartsCanBeReplacedWithInsertionFirst() {
+    stringModifier.replace(5, 5, "string ");
+    stringModifier.replace(8, 39, "a modified version");
+    String modifiedString = stringModifier.getResult();
+    assertThat(modifiedString).isEqualTo("This string is a modified version.");
+  }
+
+  @Test
+  public void twoPartsCanBeReplacedWithDeletionFirst() {
+    stringModifier.replace(0, 8, "");
+    stringModifier.replace(12, 32, "modified");
+    String modifiedString = stringModifier.getResult();
+    assertThat(modifiedString).isEqualTo("the modified string.");
+  }
+
+  @Test
+  public void replacedPartsMayTouch() {
+    stringModifier.replace(0, 8, "");
+    stringModifier.replace(8, 32, "The modified");
+    String modifiedString = stringModifier.getResult();
+    assertThat(modifiedString).isEqualTo("The modified string.");
+  }
+
+  @Test
+  public void replacedPartsMustNotOverlap() {
+    stringModifier.replace(0, 9, "");
+    expectedException.expect(StringIndexOutOfBoundsException.class);
+    stringModifier.replace(8, 32, "The modified");
+  }
+
+  @Test
+  public void startIndexMustNotBeGreaterThanEndIndex() {
+    expectedException.expect(StringIndexOutOfBoundsException.class);
+    stringModifier.replace(10, 9, "something");
+  }
+
+  @Test
+  public void startIndexMustNotBeNegative() {
+    expectedException.expect(StringIndexOutOfBoundsException.class);
+    stringModifier.replace(-1, 9, "something");
+  }
+
+  @Test
+  public void newContentCanBeInsertedAtEndOfString() {
+    stringModifier.replace(
+        originalString.length(), originalString.length(), " And this an addition.");
+    String modifiedString = stringModifier.getResult();
+    assertThat(modifiedString)
+        .isEqualTo("This is the original, unmodified string. And this an addition.");
+  }
+
+  @Test
+  public void startIndexMustNotBeGreaterThanLengthOfString() {
+    expectedException.expect(StringIndexOutOfBoundsException.class);
+    stringModifier.replace(originalString.length() + 1, originalString.length() + 1, "something");
+  }
+
+  @Test
+  public void endIndexMustNotBeGreaterThanLengthOfString() {
+    expectedException.expect(StringIndexOutOfBoundsException.class);
+    stringModifier.replace(8, originalString.length() + 1, "something");
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
index 1724c51..b80e31e 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.IndexConfig;
 import com.google.gerrit.server.index.QueryOptions;
-import com.google.gerrit.server.query.AndPredicate;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gerrit.server.query.change.AndChangeSource;
@@ -58,7 +57,7 @@
     indexes = new ChangeIndexCollection();
     indexes.setSearchIndex(index);
     queryBuilder = new FakeQueryBuilder(indexes);
-    rewrite = new ChangeIndexRewriter(indexes, IndexConfig.create(0, 0, 3));
+    rewrite = new ChangeIndexRewriter(indexes, IndexConfig.builder().maxTerms(3).build());
   }
 
   @Test
@@ -196,9 +195,10 @@
     assertThat(rewrite(in)).isEqualTo(query(in));
 
     indexes.setSearchIndex(new FakeChangeIndex(FakeChangeIndex.V1));
-    Predicate<ChangeData> out = rewrite(in);
-    assertThat(out).isInstanceOf(AndPredicate.class);
-    assertThat(out.getChildren()).containsExactly(query(in.getChild(0)), in.getChild(1)).inOrder();
+
+    exception.expect(QueryParseException.class);
+    exception.expectMessage("Unsupported index predicate: file:a");
+    rewrite(in);
   }
 
   @Test
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java
index 6fda100..3bbd335 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java
@@ -27,8 +27,8 @@
         new FakeQueryBuilder.Definition<>(FakeQueryBuilder.class),
         new ChangeQueryBuilder.Arguments(
             null, null, null, null, null, null, null, null, null, null, null, null, null, null,
-            null, null, null, null, null, null, null, indexes, null, null, null, null, null, null,
-            null, null, null));
+            null, null, null, null, null, null, null, null, indexes, null, null, null, null, null,
+            null, null, null, null));
   }
 
   @Operator
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java
index 4eef629..0af642d 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java
@@ -321,11 +321,13 @@
     Change indexChange = newChange(P1, new Account.Id(1));
     indexChange.setNoteDbState(SHA1);
 
-    assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, null)).isFalse();
+    // Change is missing from ReviewDb but present in index.
+    assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, null)).isTrue();
 
+    // Change differs only in primary storage.
     Change noteDbPrimary = clone(indexChange);
     noteDbPrimary.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
-    assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, noteDbPrimary)).isFalse();
+    assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, noteDbPrimary)).isTrue();
 
     assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, clone(indexChange))).isFalse();
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/ValidatorTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/ValidatorTest.java
deleted file mode 100644
index 8cf1097..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/ValidatorTest.java
+++ /dev/null
@@ -1,62 +0,0 @@
-// 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.mail.send;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
-
-import java.io.BufferedReader;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import org.junit.Test;
-
-public class ValidatorTest {
-  private static final String UNSUPPORTED_PREFIX = "#! ";
-
-  @Test
-  public void validateLocalDomain() throws Exception {
-    assertThat(OutgoingEmailValidator.isValid("foo@bar.local")).isTrue();
-  }
-
-  @Test
-  public void validateTopLevelDomains() throws Exception {
-    try (InputStream in = this.getClass().getResourceAsStream("tlds-alpha-by-domain.txt")) {
-      if (in == null) {
-        throw new Exception("TLD list not found");
-      }
-      BufferedReader r = new BufferedReader(new InputStreamReader(in));
-      String tld;
-      while ((tld = r.readLine()) != null) {
-        if (tld.startsWith("# ") || tld.startsWith("XN--")) {
-          // Ignore comments and non-latin domains
-          continue;
-        }
-        if (tld.startsWith(UNSUPPORTED_PREFIX)) {
-          String test = "test@example." + tld.toLowerCase().substring(UNSUPPORTED_PREFIX.length());
-          assert_()
-              .withFailureMessage("expected invalid TLD \"" + test + "\"")
-              .that(OutgoingEmailValidator.isValid(test))
-              .isFalse();
-        } else {
-          String test = "test@example." + tld.toLowerCase();
-          assert_()
-              .withFailureMessage("failed to validate TLD \"" + test + "\"")
-              .that(OutgoingEmailValidator.isValid(test))
-              .isTrue();
-        }
-      }
-    }
-  }
-}
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 9d6cb60..5a1d10c 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
@@ -49,6 +49,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.util.RequestId;
 import com.google.gerrit.testutil.TestChanges;
@@ -751,7 +752,7 @@
     try (RevWalk walk = new RevWalk(repo)) {
       RevCommit commit = walk.parseCommit(update.getResult());
       walk.parseBody(commit);
-      assertThat(commit.getFullMessage()).endsWith("Hashtags: tag1,tag2\n");
+      assertThat(commit.getFullMessage()).contains("Hashtags: tag1,tag2\n");
     }
   }
 
@@ -3265,6 +3266,138 @@
     assertThat(notes.getReadOnlyUntil()).isEqualTo(new Timestamp(0));
   }
 
+  @Test
+  public void privateDefault() throws Exception {
+    Change c = newChange();
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.isPrivate()).isFalse();
+  }
+
+  @Test
+  public void privateSetPrivate() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setPrivate(true);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.isPrivate()).isTrue();
+  }
+
+  @Test
+  public void privateSetPrivateMultipleTimes() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setPrivate(true);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.setPrivate(false);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.isPrivate()).isFalse();
+  }
+
+  @Test
+  public void defaultReviewersByEmailIsEmpty() throws Exception {
+    Change c = newChange();
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().all()).isEmpty();
+  }
+
+  @Test
+  public void putReviewerByEmail() throws Exception {
+    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().all()).containsExactly(adr);
+  }
+
+  @Test
+  public void putAndRemoveReviewerByEmail() throws Exception {
+    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.removeReviewerByEmail(adr);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().all()).isEmpty();
+  }
+
+  @Test
+  public void putRemoveAndAddBackReviewerByEmail() throws Exception {
+    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.removeReviewerByEmail(adr);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.CC);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().all()).containsExactly(adr);
+  }
+
+  @Test
+  public void putReviewerByEmailAndCcByEmail() throws Exception {
+    Address adrReviewer = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+    Address adrCc = new Address("Foo Bor", "foo.bar.2@gerritcodereview.com");
+
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adrReviewer, ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adrCc, ReviewerStateInternal.CC);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.REVIEWER))
+        .containsExactly(adrReviewer);
+    assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.CC))
+        .containsExactly(adrCc);
+    assertThat(notes.getReviewersByEmail().all()).containsExactly(adrReviewer, adrCc);
+  }
+
+  @Test
+  public void putReviewerByEmailAndChangeToCc() throws Exception {
+    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.CC);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.REVIEWER)).isEmpty();
+    assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.CC)).containsExactly(adr);
+    assertThat(notes.getReviewersByEmail().all()).containsExactly(adr);
+  }
+
   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 25b5168..83dcf61 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
@@ -23,6 +23,7 @@
 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.mail.Address;
 import com.google.gerrit.server.util.RequestId;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.TestChanges;
@@ -382,6 +383,32 @@
     assertBodyEquals("Update patch set 1\n\nPatch-set: 1\nCurrent: true\n", update.getResult());
   }
 
+  @Test
+  public void reviewerByEmail() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(
+        new Address("John Doe", "j.doe@gerritcodereview.com"), ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    assertBodyEquals(
+        "Update patch set 1\n\nPatch-set: 1\n"
+            + "Reviewer-email: John Doe <j.doe@gerritcodereview.com>\n",
+        update.getResult());
+  }
+
+  @Test
+  public void ccByEmail() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(new Address("j.doe@gerritcodereview.com"), ReviewerStateInternal.CC);
+    update.commit();
+
+    assertBodyEquals(
+        "Update patch set 1\n\nPatch-set: 1\nCC-email: j.doe@gerritcodereview.com\n",
+        update.getResult());
+  }
+
   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/NoteDbUpdateManagerTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/NoteDbUpdateManagerTest.java
new file mode 100644
index 0000000..12eb39d
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/NoteDbUpdateManagerTest.java
@@ -0,0 +1,119 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.git.LockFailureException;
+import java.io.IOException;
+import java.util.function.Consumer;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for {@link NoteDbUpdateManager}. */
+@RunWith(JUnit4.class)
+public class NoteDbUpdateManagerTest {
+  private static final Consumer<ReceiveCommand> OK = c -> c.setResult(ReceiveCommand.Result.OK);
+  private static final Consumer<ReceiveCommand> LOCK_FAILURE =
+      c -> c.setResult(ReceiveCommand.Result.LOCK_FAILURE);
+  private static final Consumer<ReceiveCommand> REJECTED =
+      c -> c.setResult(ReceiveCommand.Result.REJECTED_OTHER_REASON);
+  private static final Consumer<ReceiveCommand> ABORTED =
+      c -> {
+        c.setResult(ReceiveCommand.Result.NOT_ATTEMPTED);
+        ReceiveCommand.abort(ImmutableList.of(c));
+        checkState(
+            c.getResult() != ReceiveCommand.Result.NOT_ATTEMPTED
+                && c.getResult() != ReceiveCommand.Result.LOCK_FAILURE
+                && c.getResult() != ReceiveCommand.Result.OK,
+            "unexpected state after abort: %s",
+            c);
+      };
+
+  @Test
+  public void checkBatchRefUpdateResults() throws Exception {
+    checkResults(OK);
+    checkResults(OK, OK);
+
+    assertIoException(REJECTED);
+    assertIoException(OK, REJECTED);
+    assertIoException(LOCK_FAILURE, REJECTED);
+    assertIoException(LOCK_FAILURE, OK);
+    assertIoException(LOCK_FAILURE, REJECTED, OK);
+    assertIoException(LOCK_FAILURE, LOCK_FAILURE, REJECTED);
+    assertIoException(LOCK_FAILURE, ABORTED, REJECTED);
+    assertIoException(LOCK_FAILURE, ABORTED, OK);
+
+    assertLockFailureException(LOCK_FAILURE);
+    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE);
+    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE, ABORTED);
+    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE, ABORTED, ABORTED);
+    assertLockFailureException(ABORTED);
+    assertLockFailureException(ABORTED, ABORTED);
+  }
+
+  @SafeVarargs
+  private static void checkResults(Consumer<ReceiveCommand>... resultSetters) throws Exception {
+    NoteDbUpdateManager.checkResults(newBatchRefUpdate(resultSetters));
+  }
+
+  @SafeVarargs
+  private static void assertIoException(Consumer<ReceiveCommand>... resultSetters) {
+    try {
+      NoteDbUpdateManager.checkResults(newBatchRefUpdate(resultSetters));
+      assert_().fail("expected IOException");
+    } catch (IOException e) {
+      assertThat(e).isNotInstanceOf(LockFailureException.class);
+    }
+  }
+
+  @SafeVarargs
+  private static void assertLockFailureException(Consumer<ReceiveCommand>... resultSetters)
+      throws Exception {
+    try {
+      NoteDbUpdateManager.checkResults(newBatchRefUpdate(resultSetters));
+      assert_().fail("expected LockFailureException");
+    } catch (LockFailureException e) {
+      // Expected.
+    }
+  }
+
+  @SafeVarargs
+  private static BatchRefUpdate newBatchRefUpdate(Consumer<ReceiveCommand>... resultSetters) {
+    try (Repository repo = new InMemoryRepository(new DfsRepositoryDescription("repo"))) {
+      BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
+      for (int i = 0; i < resultSetters.length; i++) {
+        ReceiveCommand cmd =
+            new ReceiveCommand(
+                ObjectId.fromString(String.format("%039x1", i)),
+                ObjectId.fromString(String.format("%039x2", i)),
+                "refs/heads/branch" + i);
+        bru.addCommand(cmd);
+        resultSetters[i].accept(cmd);
+      }
+      return bru;
+    }
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java
index df3e405..6977ce2 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java
@@ -159,14 +159,11 @@
     // Seed existing ref value.
     writeBlob("id", "1");
 
-    final AtomicBoolean doneBgUpdate = new AtomicBoolean(false);
+    AtomicBoolean doneBgUpdate = new AtomicBoolean(false);
     Runnable bgUpdate =
-        new Runnable() {
-          @Override
-          public void run() {
-            if (!doneBgUpdate.getAndSet(true)) {
-              writeBlob("id", "1234");
-            }
+        () -> {
+          if (!doneBgUpdate.getAndSet(true)) {
+            writeBlob("id", "1234");
           }
         };
 
@@ -203,20 +200,13 @@
 
   @Test
   public void failAfterRetryerGivesUp() throws Exception {
-    final AtomicInteger bgCounter = new AtomicInteger(1234);
-    Runnable bgUpdate =
-        new Runnable() {
-          @Override
-          public void run() {
-            writeBlob("id", Integer.toString(bgCounter.getAndAdd(1000)));
-          }
-        };
+    AtomicInteger bgCounter = new AtomicInteger(1234);
     RepoSequence s =
         newSequence(
             "id",
             1,
             10,
-            bgUpdate,
+            () -> writeBlob("id", Integer.toString(bgCounter.getAndAdd(1000))),
             RetryerBuilder.<RefUpdate.Result>newBuilder()
                 .withStopStrategy(StopStrategies.stopAfterAttempt(3))
                 .build());
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
index 6390e1f6..ebd5b49 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
@@ -58,6 +58,8 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.index.SingleVersionModule.SingleVersionListener;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.RequestContext;
@@ -109,12 +111,14 @@
     assertThat(u.controlForRef(ref).isOwner()).named("NOT OWN " + ref).isFalse();
   }
 
-  private void assertCanRead(ProjectControl u) {
-    assertThat(u.isVisible()).named("can read").isTrue();
+  private void assertCanAccess(ProjectControl u) {
+    boolean access = u.asForProject().testOrFalse(ProjectPermission.ACCESS);
+    assertThat(access).named("can access").isTrue();
   }
 
-  private void assertCannotRead(ProjectControl u) {
-    assertThat(u.isVisible()).named("cannot read").isFalse();
+  private void assertAccessDenied(ProjectControl u) {
+    boolean access = u.asForProject().testOrFalse(ProjectPermission.ACCESS);
+    assertThat(access).named("cannot access").isFalse();
   }
 
   private void assertCanRead(String ref, ProjectControl u) {
@@ -158,19 +162,23 @@
   }
 
   private void assertCanUpdate(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canUpdate()).named("can update " + ref).isTrue();
+    boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.UPDATE);
+    assertThat(update).named("can update " + ref).isTrue();
   }
 
   private void assertCannotUpdate(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canUpdate()).named("cannot update " + ref).isFalse();
+    boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.UPDATE);
+    assertThat(update).named("cannot update " + ref).isFalse();
   }
 
   private void assertCanForceUpdate(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canForceUpdate()).named("can force push " + ref).isTrue();
+    boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.FORCE_UPDATE);
+    assertThat(update).named("can force push " + ref).isTrue();
   }
 
   private void assertCannotForceUpdate(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canForceUpdate()).named("cannot force push " + ref).isFalse();
+    boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.FORCE_UPDATE);
+    assertThat(update).named("cannot force push " + ref).isFalse();
   }
 
   private void assertCanVote(int score, PermissionRange range) {
@@ -438,13 +446,13 @@
   public void inheritDuplicateSections() throws Exception {
     allow(parent, READ, ADMIN, "refs/*");
     allow(local, READ, DEVS, "refs/heads/*");
-    assertCanRead(user(local, "a", ADMIN));
+    assertCanAccess(user(local, "a", ADMIN));
 
     local = new ProjectConfig(localKey);
     local.load(newRepository(localKey));
     local.getProject().setParentName(parentKey);
     allow(local, READ, DEVS, "refs/*");
-    assertCanRead(user(local, "d", DEVS));
+    assertCanAccess(user(local, "d", DEVS));
   }
 
   @Test
@@ -452,7 +460,7 @@
     allow(parent, READ, REGISTERED_USERS, "refs/*");
     deny(local, READ, REGISTERED_USERS, "refs/*");
 
-    assertCannotRead(user(local));
+    assertAccessDenied(user(local));
   }
 
   @Test
@@ -461,7 +469,7 @@
     deny(local, READ, REGISTERED_USERS, "refs/heads/*");
 
     ProjectControl u = user(local);
-    assertCanRead(u);
+    assertCanAccess(u);
     assertCanRead("refs/master", u);
     assertCanRead("refs/tags/foobar", u);
     assertCanRead("refs/heads/master", u);
@@ -474,7 +482,7 @@
     allow(local, READ, REGISTERED_USERS, "refs/heads/*");
 
     ProjectControl u = user(local);
-    assertCanRead(u);
+    assertCanAccess(u);
     assertCannotRead("refs/foobar", u);
     assertCannotRead("refs/tags/foobar", u);
     assertCanRead("refs/heads/foobar", u);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index 3d13536..ee894c9 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -98,6 +98,7 @@
   @Inject protected AllProjectsName allProjects;
 
   protected LifecycleManager lifecycle;
+  protected Injector injector;
   protected ReviewDb db;
   protected AccountInfo currentUserInfo;
   protected CurrentUser user;
@@ -107,11 +108,14 @@
   @Before
   public void setUpInjector() throws Exception {
     lifecycle = new LifecycleManager();
-    Injector injector = createInjector();
+    injector = createInjector();
     lifecycle.add(injector);
     injector.injectMembers(this);
     lifecycle.start();
+    setUpDatabase();
+  }
 
+  protected void setUpDatabase() throws Exception {
     db = schemaFactory.open();
     schemaCreator.create(db);
 
@@ -255,6 +259,7 @@
     assertQuery("Jo Do", user1);
     assertQuery("jo do", user1);
     assertQuery("self", currentUserInfo, user3);
+    assertQuery("me", currentUserInfo);
     assertQuery("name:John", user1);
     assertQuery("name:john", user1);
     assertQuery("name:Doe", user1);
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 cd179b5..ba37a5e 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
@@ -44,6 +44,8 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
@@ -65,13 +67,16 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeTriplet;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.index.IndexConfig;
 import com.google.gerrit.server.index.QueryOptions;
 import com.google.gerrit.server.index.change.ChangeField;
@@ -83,8 +88,11 @@
 import com.google.gerrit.server.notedb.NoteDbChangeState;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.testutil.ConfigSuite;
@@ -94,6 +102,7 @@
 import com.google.gerrit.testutil.InMemoryRepositoryManager;
 import com.google.gerrit.testutil.InMemoryRepositoryManager.Repo;
 import com.google.gerrit.testutil.TestTimeUtil;
+import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Provider;
@@ -101,6 +110,7 @@
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -111,10 +121,14 @@
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.SystemReader;
 import org.junit.After;
 import org.junit.Before;
@@ -130,6 +144,7 @@
     return cfg;
   }
 
+  @Inject protected AccountCache accountCache;
   @Inject protected AccountManager accountManager;
   @Inject protected AllUsersName allUsersName;
   @Inject protected BatchUpdate.Factory updateFactory;
@@ -140,17 +155,24 @@
   @Inject protected ChangeIndexCollection indexes;
   @Inject protected ChangeIndexer indexer;
   @Inject protected IndexConfig indexConfig;
-  @Inject protected InMemoryDatabase schemaFactory;
   @Inject protected InMemoryRepositoryManager repoManager;
   @Inject protected InternalChangeQuery internalChangeQuery;
   @Inject protected ChangeNotes.Factory notesFactory;
+  @Inject protected OneOffRequestContext oneOffRequestContext;
   @Inject protected PatchSetInserter.Factory patchSetFactory;
   @Inject protected PatchSetUtil psUtil;
   @Inject protected ChangeControl.GenericFactory changeControlFactory;
   @Inject protected ChangeQueryProcessor queryProcessor;
   @Inject protected SchemaCreator schemaCreator;
+  @Inject protected SchemaFactory<ReviewDb> schemaFactory;
   @Inject protected Sequences seq;
   @Inject protected ThreadLocalRequestContext requestContext;
+  @Inject protected ProjectCache projectCache;
+  @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
+  @Inject protected ExternalIdsUpdate.Server externalIdsUpdate;
+
+  // Only for use in setting up/tearing down injector; other users should use schemaFactory.
+  @Inject private InMemoryDatabase inMemoryDatabase;
 
   protected Injector injector;
   protected LifecycleManager lifecycle;
@@ -173,12 +195,16 @@
   }
 
   protected void setUpDatabase() throws Exception {
+    try (ReviewDb underlyingDb = inMemoryDatabase.getDatabase().open()) {
+      schemaCreator.create(underlyingDb);
+    }
     db = schemaFactory.open();
-    schemaCreator.create(db);
 
     userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
     Account userAccount = db.accounts().get(userId);
-    userAccount.setPreferredEmail("user@example.com");
+    String email = "user@example.com";
+    externalIdsUpdate.create().insert(ExternalId.createEmail(userId, email));
+    userAccount.setPreferredEmail(email);
     db.accounts().update(ImmutableList.of(userAccount));
     user = userFactory.create(userId);
     requestContext.setContext(newRequestContext(userAccount.getId()));
@@ -208,7 +234,7 @@
     if (db != null) {
       db.close();
     }
-    InMemoryDatabase.drop(schemaFactory);
+    InMemoryDatabase.drop(inMemoryDatabase);
   }
 
   @Before
@@ -373,11 +399,77 @@
   }
 
   @Test
+  public void byPrivate() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo), userId);
+    Account.Id user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+    Change change2 = insert(repo, newChange(repo), user2);
+
+    // No private changes.
+    assertQuery("is:open", change2, change1);
+    assertQuery("is:private");
+
+    gApi.changes().id(change1.getChangeId()).setPrivate(true, null);
+
+    // Change1 is not private, but should be still visible to its owner.
+    assertQuery("is:open", change1, change2);
+    assertQuery("is:private", change1);
+
+    // Switch request context to user2.
+    requestContext.setContext(newRequestContext(user2));
+    assertQuery("is:open", change2);
+    assertQuery("is:private");
+  }
+
+  @Test
+  public void byWip() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo), userId);
+
+    assertQuery("is:open", change1);
+    assertQuery("is:wip");
+
+    gApi.changes().id(change1.getChangeId()).setWorkInProgress();
+
+    assertQuery("is:wip", change1);
+
+    gApi.changes().id(change1.getChangeId()).setReadyForReview();
+
+    assertQuery("is:wip");
+  }
+
+  @Test
+  public void excludeWipChangeFromReviewersDashboards() throws Exception {
+    Account.Id user1 = createAccount("user1");
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo), userId);
+
+    AddReviewerInput rin = new AddReviewerInput();
+    rin.reviewer = user1.toString();
+    rin.state = ReviewerState.REVIEWER;
+    gApi.changes().id(change1.getId().get()).addReviewer(rin);
+
+    assertQuery("is:wip");
+    assertQuery("reviewer:" + user1, change1);
+
+    gApi.changes().id(change1.getChangeId()).setWorkInProgress();
+
+    assertQuery("is:wip", change1);
+    assertQuery("reviewer:" + user1);
+
+    gApi.changes().id(change1.getChangeId()).setReadyForReview();
+
+    assertQuery("is:wip");
+    assertQuery("reviewer:" + user1, change1);
+  }
+
+  @Test
   public void byCommit() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     ChangeInserter ins = newChange(repo);
     insert(repo, ins);
-    String sha = ins.getCommit().name();
+    String sha = ins.getCommitId().name();
 
     assertQuery("0000000000000000000000000000000000000000");
     for (int i = 0; i <= 36; i++) {
@@ -402,57 +494,82 @@
   }
 
   @Test
-  public void byAuthor() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
-
-    // By exact email address
-    assertQuery("author:jauthor@example.com", change1);
-
-    // By email address part
-    assertQuery("author:jauthor", change1);
-    assertQuery("author:example", change1);
-    assertQuery("author:example.com", change1);
-
-    // By name part
-    assertQuery("author:Author", change1);
-
-    // Case insensitive
-    assertQuery("author:jAuThOr", change1);
-    assertQuery("author:ExAmPlE", change1);
-
-    // By non-existing email address / name / part
-    assertQuery("author:jcommitter@example.com");
-    assertQuery("author:somewhere.com");
-    assertQuery("author:jcommitter");
-    assertQuery("author:Committer");
+  public void byAuthorExact() throws Exception {
+    byAuthorOrCommitterExact("author:");
   }
 
   @Test
-  public void byCommitter() throws Exception {
+  public void byAuthorFullText() throws Exception {
+    byAuthorOrCommitterFullText("author:");
+  }
+
+  @Test
+  public void byCommitterExact() throws Exception {
+    byAuthorOrCommitterExact("committer:");
+  }
+
+  @Test
+  public void byCommitterFullText() throws Exception {
+    byAuthorOrCommitterFullText("committer:");
+  }
+
+  private void byAuthorOrCommitterExact(String searchOperator) throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
+    PersonIdent johnDoe = new PersonIdent("John Doe", "john.doe@example.com");
+    PersonIdent john = new PersonIdent("John", "john@example.com");
+    PersonIdent doeSmith = new PersonIdent("Doe Smith", "doe_smith@example.com");
+    Change change1 = createChange(repo, johnDoe);
+    Change change2 = createChange(repo, john);
+    Change change3 = createChange(repo, doeSmith);
 
-    // By exact email address
-    assertQuery("committer:jcommitter@example.com", change1);
+    // Only email address.
+    assertQuery(searchOperator + "john.doe@example.com", change1);
+    assertQuery(searchOperator + "john@example.com", change2);
+    assertQuery(searchOperator + "Doe_SmIth@example.com", change3); // Case insensitive.
 
-    // By email address part
-    assertQuery("committer:jcommitter", change1);
-    assertQuery("committer:example", change1);
-    assertQuery("committer:example.com", change1);
+    // Right combination of email address and name.
+    assertQuery(searchOperator + "\"John Doe <john.doe@example.com>\"", change1);
+    assertQuery(searchOperator + "\" John <john@example.com> \"", change2);
+    assertQuery(searchOperator + "\"doE SMITH <doe_smitH@example.com>\"", change3);
 
-    // By name part
-    assertQuery("committer:Committer", change1);
+    // Wrong combination of email address and name.
+    assertQuery(searchOperator + "\"John <john.doe@example.com>\"");
+    assertQuery(searchOperator + "\"Doe John <john@example.com>\"");
+    assertQuery(searchOperator + "\"Doe John <doe_smith@example.com>\"");
+  }
 
-    // Case insensitive
-    assertQuery("committer:jCoMmItTeR", change1);
-    assertQuery("committer:ExAmPlE", change1);
+  private void byAuthorOrCommitterFullText(String searchOperator) throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    PersonIdent johnDoe = new PersonIdent("John Doe", "john.doe@example.com");
+    PersonIdent john = new PersonIdent("John", "john@example.com");
+    PersonIdent doeSmith = new PersonIdent("Doe Smith", "doe_smith@example.com");
+    Change change1 = createChange(repo, johnDoe);
+    Change change2 = createChange(repo, john);
+    Change change3 = createChange(repo, doeSmith);
 
-    // By non-existing email address / name / part
-    assertQuery("committer:jauthor@example.com");
-    assertQuery("committer:somewhere.com");
-    assertQuery("committer:jauthor");
-    assertQuery("committer:Author");
+    // By exact name.
+    assertQuery(searchOperator + "\"John Doe\"", change1);
+    assertQuery(searchOperator + "\"john\"", change2, change1);
+    assertQuery(searchOperator + "\"Doe smith\"", change3);
+
+    // By name part.
+    assertQuery(searchOperator + "Doe", change3, change1);
+    assertQuery(searchOperator + "smith", change3);
+
+    // By wrong combination.
+    assertQuery(searchOperator + "\"John Smith\"");
+
+    // By invalid query.
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("invalid value");
+    // SchemaUtil.getNameParts will return an empty set for query only containing these characters.
+    assertQuery(searchOperator + "@.- /_");
+  }
+
+  private Change createChange(TestRepository<Repo> repo, PersonIdent person) throws Exception {
+    RevCommit commit =
+        repo.parseBody(repo.commit().message("message").author(person).committer(person).create());
+    return insert(repo, newChangeForCommit(repo, commit), null);
   }
 
   @Test
@@ -536,8 +653,23 @@
     assertQuery("intopic:fixup", change4);
     assertQuery("topic:\"\"", change5);
     assertQuery("intopic:\"\"", change5);
-    assertQuery("intopic:^feature2.*", change4, change2);
-    assertQuery("intopic:{^.*feature2$}", change3, change2);
+  }
+
+  @Test
+  public void byTopicRegex() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+
+    ChangeInserter ins1 = newChangeWithTopic(repo, "feature1");
+    Change change1 = insert(repo, ins1);
+
+    ChangeInserter ins2 = newChangeWithTopic(repo, "Cherrypick-feature1");
+    Change change2 = insert(repo, ins2);
+
+    ChangeInserter ins3 = newChangeWithTopic(repo, "feature1-fixup");
+    Change change3 = insert(repo, ins3);
+
+    assertQuery("intopic:^feature1.*", change3, change1);
+    assertQuery("intopic:{^.*feature1$}", change2, change1);
   }
 
   @Test
@@ -822,38 +954,14 @@
   }
 
   @Test
-  public void updatedOrderWithMinuteResolution() throws Exception {
-    resetTimeWithClockStep(2, MINUTES);
-    TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins1 = newChange(repo);
-    Change change1 = insert(repo, ins1);
-    Change change2 = insert(repo, newChange(repo));
-
-    assertThat(lastUpdatedMs(change1)).isLessThan(lastUpdatedMs(change2));
-    assertQuery("status:new", change2, change1);
-
-    gApi.changes().id(change1.getId().get()).topic("new-topic");
-    change1 = notesFactory.create(db, change1.getProject(), change1.getId()).getChange();
-
-    assertThat(lastUpdatedMs(change1)).isGreaterThan(lastUpdatedMs(change2));
-    assertThat(lastUpdatedMs(change1) - lastUpdatedMs(change2))
-        .isGreaterThan(MILLISECONDS.convert(1, MINUTES));
-
-    // change1 moved to the top.
-    assertQuery("status:new", change1, change2);
-  }
-
-  @Test
-  public void updatedOrderWithSubMinuteResolution() throws Exception {
+  public void updatedOrder() throws Exception {
     resetTimeWithClockStep(1, SECONDS);
-
     TestRepository<Repo> repo = createProject("repo");
     ChangeInserter ins1 = newChange(repo);
     Change change1 = insert(repo, ins1);
     Change change2 = insert(repo, newChange(repo));
 
     assertThat(lastUpdatedMs(change1)).isLessThan(lastUpdatedMs(change2));
-
     assertQuery("status:new", change2, change1);
 
     gApi.changes().id(change1.getId().get()).topic("new-topic");
@@ -861,7 +969,7 @@
 
     assertThat(lastUpdatedMs(change1)).isGreaterThan(lastUpdatedMs(change2));
     assertThat(lastUpdatedMs(change1) - lastUpdatedMs(change2))
-        .isLessThan(MILLISECONDS.convert(1, MINUTES));
+        .isAtLeast(MILLISECONDS.convert(1, SECONDS));
 
     // change1 moved to the top.
     assertQuery("status:new", change1, change2);
@@ -1343,7 +1451,8 @@
     allUsers.update(draftsRef.getName(), draftsRef.getObjectId());
     assertThat(allUsers.getRepository().exactRef(draftsRef.getName())).isNotNull();
 
-    if (PrimaryStorage.of(change) == PrimaryStorage.REVIEW_DB) {
+    if (PrimaryStorage.of(change) == PrimaryStorage.REVIEW_DB
+        && !notesMigration.disableChangeReviewDb()) {
       // Record draft ref in noteDbState as well.
       ReviewDb db = ReviewDbUtil.unwrapDb(this.db);
       change = db.changes().get(id);
@@ -1380,7 +1489,8 @@
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
-    insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
+    Change change3 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
+    Change change4 = insert(repo, newChange(repo));
 
     gApi.accounts()
         .self()
@@ -1394,16 +1504,42 @@
             new StarsInput(
                 new HashSet<>(Arrays.asList(StarredChangesUtil.DEFAULT_LABEL, "green", "blue"))));
 
+    gApi.accounts()
+        .self()
+        .setStars(
+            change4.getId().toString(), new StarsInput(new HashSet<>(Arrays.asList("ignore"))));
+
     // check labeled stars
     assertQuery("star:red", change1);
     assertQuery("star:blue", change2, change1);
-    assertQuery("has:stars", change2, change1);
+    assertQuery("has:stars", change4, change2, change1);
 
     // check default star
     assertQuery("has:star", change2);
     assertQuery("is:starred", change2);
     assertQuery("starredby:self", change2);
     assertQuery("star:" + StarredChangesUtil.DEFAULT_LABEL, change2);
+
+    // check ignored
+    assertQuery("is:ignored", change4);
+    assertQuery("-is:ignored", change3, change2, change1);
+  }
+
+  @Test
+  public void byIgnore() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Account.Id user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+    Change change1 = insert(repo, newChange(repo), user2);
+    Change change2 = insert(repo, newChange(repo), user2);
+
+    gApi.changes().id(change1.getId().toString()).ignore(true);
+    assertQuery("is:ignored", change1);
+    assertQuery("-is:ignored", change2);
+
+    gApi.changes().id(change1.getId().toString()).ignore(false);
+    assertQuery("is:ignored");
+    assertQuery("-is:ignored", change2, change1);
   }
 
   @Test
@@ -1523,6 +1659,71 @@
   }
 
   @Test
+  public void reviewerAndCcByEmail() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    Project.NameKey project = new Project.NameKey("repo");
+    TestRepository<Repo> repo = createProject(project.get());
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(conf);
+
+    String userByEmail = "un.registered@reviewer.com";
+    String userByEmailWithName = "John Doe <" + userByEmail + ">";
+
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    insert(repo, newChange(repo));
+
+    AddReviewerInput rin = new AddReviewerInput();
+    rin.reviewer = userByEmailWithName;
+    rin.state = ReviewerState.REVIEWER;
+    gApi.changes().id(change1.getId().get()).addReviewer(rin);
+
+    rin = new AddReviewerInput();
+    rin.reviewer = userByEmailWithName;
+    rin.state = ReviewerState.CC;
+    gApi.changes().id(change2.getId().get()).addReviewer(rin);
+
+    assertQuery("reviewer:\"" + userByEmailWithName + "\"", change1);
+    assertQuery("cc:\"" + userByEmailWithName + "\"", change2);
+
+    // Omitting the name:
+    assertQuery("reviewer:\"" + userByEmail + "\"", change1);
+    assertQuery("cc:\"" + userByEmail + "\"", change2);
+  }
+
+  @Test
+  public void reviewerAndCcByEmailWithQueryForDifferentUser() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    Project.NameKey project = new Project.NameKey("repo");
+    TestRepository<Repo> repo = createProject(project.get());
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(conf);
+
+    String userByEmail = "John Doe <un.registered@reviewer.com>";
+
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    insert(repo, newChange(repo));
+
+    AddReviewerInput rin = new AddReviewerInput();
+    rin.reviewer = userByEmail;
+    rin.state = ReviewerState.REVIEWER;
+    gApi.changes().id(change1.getId().get()).addReviewer(rin);
+
+    rin = new AddReviewerInput();
+    rin.reviewer = userByEmail;
+    rin.state = ReviewerState.CC;
+    gApi.changes().id(change2.getId().get()).addReviewer(rin);
+
+    assertQuery("reviewer:\"someone@example.com\"");
+    assertQuery("cc:\"someone@example.com\"");
+  }
+
+  @Test
   public void submitRecords() throws Exception {
     Account.Id user1 = createAccount("user1");
     TestRepository<Repo> repo = createProject("repo");
@@ -1610,9 +1811,28 @@
 
   @Test
   public void byCommitsOnBranchNotMerged() throws Exception {
+    TestRepository<Repo> tr = createProject("repo");
+    testByCommitsOnBranchNotMerged(tr, ImmutableSet.of());
+  }
+
+  @Test
+  public void byCommitsOnBranchNotMergedSkipsMissingChanges() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
+    ObjectId missing =
+        repo.branch(new PatchSet.Id(new Change.Id(987654), 1).toRefName())
+            .commit()
+            .message("No change for this commit")
+            .insertChangeId()
+            .create()
+            .copy();
+    testByCommitsOnBranchNotMerged(repo, ImmutableSet.of(missing));
+  }
+
+  private void testByCommitsOnBranchNotMerged(TestRepository<Repo> repo, Collection<ObjectId> extra)
+      throws Exception {
     int n = 10;
-    List<String> shas = new ArrayList<>(n);
+    List<String> shas = new ArrayList<>(n + extra.size());
+    extra.forEach(i -> shas.add(i.name()));
     List<Integer> expectedIds = new ArrayList<>(n);
     Branch.NameKey dest = null;
     for (int i = 0; i < n; i++) {
@@ -1621,7 +1841,7 @@
       if (dest == null) {
         dest = ins.getChange().getDest();
       }
-      shas.add(ins.getCommit().name());
+      shas.add(ins.getCommitId().name());
       expectedIds.add(ins.getChange().getId().get());
     }
 
@@ -1804,6 +2024,27 @@
         .containsExactlyElementsIn(expectedPatterns);
   }
 
+  @Test
+  public void selfAndMe() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo), userId);
+    insert(repo, newChange(repo));
+    gApi.accounts().self().starChange(change1.getId().toString());
+    gApi.accounts().self().starChange(change2.getId().toString());
+
+    assertQuery("starredby:self", change2, change1);
+    assertQuery("starredby:me", change2, change1);
+  }
+
+  @Test
+  public void defaultFieldWithManyUsers() throws Exception {
+    for (int i = 0; i < ChangeQueryBuilder.MAX_ACCOUNTS_PER_DEFAULT_FIELD * 2; i++) {
+      createAccount("user" + i, "User " + i, "user" + i + "@example.com", true);
+    }
+    assertQuery("us");
+  }
+
   protected ChangeInserter newChange(TestRepository<Repo> repo) throws Exception {
     return newChange(repo, null, null, null, null);
   }
@@ -1848,7 +2089,7 @@
     ChangeInserter ins =
         changeFactory
             .create(id, commit, branch)
-            .setValidatePolicy(CommitValidators.Policy.NONE)
+            .setValidate(false)
             .setStatus(status)
             .setTopic(topic);
     return ins;
@@ -1893,10 +2134,12 @@
             .create(ctl, new PatchSet.Id(c.getId(), n), commit)
             .setNotify(NotifyHandling.NONE)
             .setFireRevisionCreated(false)
-            .setValidatePolicy(CommitValidators.Policy.NONE);
+            .setValidate(false);
     try (BatchUpdate bu = updateFactory.create(db, c.getProject(), user, TimeUtil.nowTs());
-        ObjectInserter oi = repo.getRepository().newObjectInserter()) {
-      bu.setRepository(repo.getRepository(), repo.getRevWalk(), oi);
+        ObjectInserter oi = repo.getRepository().newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      bu.setRepository(repo.getRepository(), rw, oi);
       bu.addOp(c.getId(), inserter);
       bu.execute();
     }
@@ -2005,4 +2248,21 @@
             Patch.COMMIT_MSG, ImmutableList.<ReviewInput.CommentInput>of(comment));
     gApi.changes().id(changeId).current().review(input);
   }
+
+  private Account.Id createAccount(String username, String fullName, String email, boolean active)
+      throws Exception {
+    try (ManualRequestContext ctx = oneOffRequestContext.open()) {
+      Account.Id id = accountManager.authenticate(AuthRequest.forUser(username)).getAccountId();
+      if (email != null) {
+        accountManager.link(id, AuthRequest.forEmail(email));
+      }
+      Account a = db.accounts().get(id);
+      a.setFullName(fullName);
+      a.setPreferredEmail(email);
+      a.setActive(active);
+      db.accounts().update(ImmutableList.of(a));
+      accountCache.evict(id);
+      return id;
+    }
+  }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
index a0e5ee0..4b8309a 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
@@ -98,6 +98,7 @@
   @Inject protected GroupCache groupCache;
 
   protected LifecycleManager lifecycle;
+  protected Injector injector;
   protected ReviewDb db;
   protected AccountInfo currentUserInfo;
   protected CurrentUser user;
@@ -107,11 +108,14 @@
   @Before
   public void setUpInjector() throws Exception {
     lifecycle = new LifecycleManager();
-    Injector injector = createInjector();
+    injector = createInjector();
     lifecycle.add(injector);
     injector.injectMembers(this);
     lifecycle.start();
+    setUpDatabase();
+  }
 
+  protected void setUpDatabase() throws Exception {
     db = schemaFactory.open();
     schemaCreator.create(db);
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaUpdaterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaUpdaterTest.java
index 9a32365..74eb1d2 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaUpdaterTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaUpdaterTest.java
@@ -38,6 +38,7 @@
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.gwtorm.server.StatementExecutor;
 import com.google.inject.Guice;
+import com.google.inject.Key;
 import com.google.inject.ProvisionException;
 import com.google.inject.TypeLiteral;
 import java.io.FileNotFoundException;
@@ -82,7 +83,10 @@
                 new FactoryModule() {
                   @Override
                   protected void configure() {
-                    bind(new TypeLiteral<SchemaFactory<ReviewDb>>() {}).toInstance(db);
+                    TypeLiteral<SchemaFactory<ReviewDb>> schemaFactory =
+                        new TypeLiteral<SchemaFactory<ReviewDb>>() {};
+                    bind(schemaFactory).to(NotesMigrationSchemaFactory.class);
+                    bind(Key.get(schemaFactory, ReviewDbFactory.class)).toInstance(db);
                     bind(SitePaths.class).toInstance(paths);
 
                     Config cfg = new Config();
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/update/BatchUpdateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/update/BatchUpdateTest.java
index 892d037..dba3b3d 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2017 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.update;
 
 import static org.junit.Assert.assertEquals;
@@ -17,6 +31,7 @@
 import com.google.gerrit.testutil.InMemoryDatabase;
 import com.google.gerrit.testutil.InMemoryModule;
 import com.google.gerrit.testutil.InMemoryRepositoryManager;
+import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
@@ -25,26 +40,22 @@
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.transport.ReceiveCommand;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 
 public class BatchUpdateTest {
   @Inject private AccountManager accountManager;
-
   @Inject private IdentifiedUser.GenericFactory userFactory;
-
-  @Inject private InMemoryDatabase schemaFactory;
-
+  @Inject private SchemaFactory<ReviewDb> schemaFactory;
   @Inject private InMemoryRepositoryManager repoManager;
-
   @Inject private SchemaCreator schemaCreator;
-
   @Inject private ThreadLocalRequestContext requestContext;
-
   @Inject private BatchUpdate.Factory batchUpdateFactory;
 
+  // Only for use in setting up/tearing down injector; other users should use schemaFactory.
+  @Inject private InMemoryDatabase inMemoryDatabase;
+
   private LifecycleManager lifecycle;
   private ReviewDb db;
   private TestRepository<InMemoryRepository> repo;
@@ -59,8 +70,10 @@
     lifecycle.add(injector);
     lifecycle.start();
 
+    try (ReviewDb underlyingDb = inMemoryDatabase.getDatabase().open()) {
+      schemaCreator.create(underlyingDb);
+    }
     db = schemaFactory.open();
-    schemaCreator.create(db);
     Account.Id userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
     user = userFactory.create(userId);
 
@@ -95,7 +108,7 @@
     if (db != null) {
       db.close();
     }
-    InMemoryDatabase.drop(schemaFactory);
+    InMemoryDatabase.drop(inMemoryDatabase);
   }
 
   @Test
@@ -108,9 +121,7 @@
           new RepoOnlyOp() {
             @Override
             public void updateRepo(RepoContext ctx) throws Exception {
-              ctx.addRefUpdate(
-                  new ReceiveCommand(
-                      masterCommit.getId(), branchCommit.getId(), "refs/heads/master"));
+              ctx.addRefUpdate(masterCommit.getId(), branchCommit.getId(), "refs/heads/master");
             }
           });
       bu.execute();
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/update/RepoViewTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/update/RepoViewTest.java
new file mode 100644
index 0000000..0ea9f83
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/update/RepoViewTest.java
@@ -0,0 +1,154 @@
+// Copyright (C) 2017 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.update;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static org.eclipse.jgit.lib.Constants.R_HEADS;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.testutil.InMemoryRepositoryManager;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.StoredConfig;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class RepoViewTest {
+  private static final String MASTER = "refs/heads/master";
+  private static final String BRANCH = "refs/heads/branch";
+
+  private Repository repo;
+  private TestRepository<?> tr;
+  private RepoView view;
+
+  @Before
+  public void setUp() throws Exception {
+    InMemoryRepositoryManager repoManager = new InMemoryRepositoryManager();
+    Project.NameKey project = new Project.NameKey("project");
+    repo = repoManager.createRepository(project);
+    tr = new TestRepository<>(repo);
+    tr.branch(MASTER).commit().create();
+    view = new RepoView(repoManager, project);
+  }
+
+  @After
+  public void tearDown() {
+    view.close();
+    repo.close();
+  }
+
+  @Test
+  public void getConfigIsDefensiveCopy() throws Exception {
+    StoredConfig orig = repo.getConfig();
+    orig.setString("a", "config", "option", "yes");
+    orig.save();
+
+    Config copy = view.getConfig();
+    copy.setString("a", "config", "option", "no");
+
+    assertThat(orig.getString("a", "config", "option")).isEqualTo("yes");
+    assertThat(repo.getConfig().getString("a", "config", "option")).isEqualTo("yes");
+  }
+
+  @Test
+  public void getRef() throws Exception {
+    ObjectId oldMaster = repo.exactRef(MASTER).getObjectId();
+    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(oldMaster);
+    assertThat(repo.exactRef(BRANCH)).isNull();
+    assertThat(view.getRef(MASTER)).hasValue(oldMaster);
+    assertThat(view.getRef(BRANCH)).isEmpty();
+
+    tr.branch(MASTER).commit().create();
+    tr.branch(BRANCH).commit().create();
+    assertThat(repo.exactRef(MASTER).getObjectId()).isNotEqualTo(oldMaster);
+    assertThat(repo.exactRef(BRANCH)).isNotNull();
+    assertThat(view.getRef(MASTER)).hasValue(oldMaster);
+    assertThat(view.getRef(BRANCH)).isEmpty();
+  }
+
+  @Test
+  public void getRefsRescansWhenNotCaching() throws Exception {
+    ObjectId oldMaster = repo.exactRef(MASTER).getObjectId();
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", oldMaster);
+
+    ObjectId newBranch = tr.branch(BRANCH).commit().create();
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", oldMaster, "branch", newBranch);
+  }
+
+  @Test
+  public void getRefsUsesCachedValueMatchingGetRef() throws Exception {
+    ObjectId master1 = repo.exactRef(MASTER).getObjectId();
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1);
+    assertThat(view.getRef(MASTER)).hasValue(master1);
+
+    // Doesn't reflect new value for master.
+    ObjectId master2 = tr.branch(MASTER).commit().create();
+    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(master2);
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1);
+
+    // Branch wasn't previously cached, so does reflect new value.
+    ObjectId branch1 = tr.branch(BRANCH).commit().create();
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1, "branch", branch1);
+
+    // Looking up branch causes it to be cached.
+    assertThat(view.getRef(BRANCH)).hasValue(branch1);
+    ObjectId branch2 = tr.branch(BRANCH).commit().create();
+    assertThat(repo.exactRef(BRANCH).getObjectId()).isEqualTo(branch2);
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1, "branch", branch1);
+  }
+
+  @Test
+  public void getRefsReflectsCommands() throws Exception {
+    ObjectId master1 = repo.exactRef(MASTER).getObjectId();
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1);
+
+    ObjectId master2 = tr.commit().create();
+    view.getCommands().add(new ReceiveCommand(master1, master2, MASTER));
+
+    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(master1);
+    assertThat(view.getRef(MASTER)).hasValue(master2);
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master2);
+
+    view.getCommands().add(new ReceiveCommand(master2, ObjectId.zeroId(), MASTER));
+
+    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(master1);
+    assertThat(view.getRef(MASTER)).isEmpty();
+    assertThat(view.getRefs(R_HEADS)).isEmpty();
+  }
+
+  @Test
+  public void getRefsOverwritesCachedValueWithCommand() throws Exception {
+    ObjectId master1 = repo.exactRef(MASTER).getObjectId();
+    assertThat(view.getRef(MASTER)).hasValue(master1);
+
+    ObjectId master2 = tr.commit().create();
+    view.getCommands().add(new ReceiveCommand(master1, master2, MASTER));
+
+    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(master1);
+    assertThat(view.getRef(MASTER)).hasValue(master2);
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master2);
+
+    view.getCommands().add(new ReceiveCommand(master2, ObjectId.zeroId(), MASTER));
+
+    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(master1);
+    assertThat(view.getRef(MASTER)).isEmpty();
+    assertThat(view.getRefs(R_HEADS)).isEmpty();
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/DisabledReviewDb.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/DisabledReviewDb.java
index 885a1f5..870dba7 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/DisabledReviewDb.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/DisabledReviewDb.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.testutil;
 
 import com.google.gerrit.reviewdb.server.AccountAccess;
-import com.google.gerrit.reviewdb.server.AccountExternalIdAccess;
 import com.google.gerrit.reviewdb.server.AccountGroupAccess;
 import com.google.gerrit.reviewdb.server.AccountGroupByIdAccess;
 import com.google.gerrit.reviewdb.server.AccountGroupByIdAudAccess;
@@ -89,11 +88,6 @@
   }
 
   @Override
-  public AccountExternalIdAccess accountExternalIds() {
-    throw new Disabled();
-  }
-
-  @Override
   public AccountGroupAccess accountGroups() {
     throw new Disabled();
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritServerTests.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritServerTests.java
index 038baac..dd42b67 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritServerTests.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritServerTests.java
@@ -49,8 +49,10 @@
       };
 
   public void beforeTest() throws Exception {
-    notesMigration = new TestNotesMigration().setFromEnv();
+    notesMigration = new TestNotesMigration();
   }
 
-  public void afterTest() {}
+  public void afterTest() {
+    notesMigration.resetFromEnv();
+  }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
index 9944508..bce0a0f 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
@@ -57,8 +57,11 @@
 import com.google.gerrit.server.notedb.GwtormChangeBundleReader;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.DiffExecutor;
+import com.google.gerrit.server.project.DefaultPermissionBackendModule;
 import com.google.gerrit.server.schema.DataSourceType;
 import com.google.gerrit.server.schema.H2AccountPatchReviewStore;
+import com.google.gerrit.server.schema.NotesMigrationSchemaFactory;
+import com.google.gerrit.server.schema.ReviewDbFactory;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.securestore.DefaultSecureStore;
 import com.google.gerrit.server.securestore.SecureStore;
@@ -69,6 +72,7 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
+import com.google.inject.Key;
 import com.google.inject.Module;
 import com.google.inject.Provider;
 import com.google.inject.Provides;
@@ -146,6 +150,7 @@
             });
     bind(MetricMaker.class).to(DisabledMetricMaker.class);
     install(cfgInjector.getInstance(GerritGlobalModule.class));
+    install(new DefaultPermissionBackendModule());
     install(new SearchingChangeCacheImpl.Module());
     factory(GarbageCollection.Factory.class);
 
@@ -171,13 +176,15 @@
     bind(ListeningExecutorService.class)
         .annotatedWith(ChangeUpdateExecutor.class)
         .toInstance(MoreExecutors.newDirectExecutorService());
-
     bind(DataSourceType.class).to(InMemoryH2Type.class);
-    bind(new TypeLiteral<SchemaFactory<ReviewDb>>() {}).to(InMemoryDatabase.class);
     bind(ChangeBundleReader.class).to(GwtormChangeBundleReader.class);
-
     bind(SecureStore.class).to(DefaultSecureStore.class);
 
+    TypeLiteral<SchemaFactory<ReviewDb>> schemaFactory =
+        new TypeLiteral<SchemaFactory<ReviewDb>>() {};
+    bind(schemaFactory).to(NotesMigrationSchemaFactory.class);
+    bind(Key.get(schemaFactory, ReviewDbFactory.class)).to(InMemoryDatabase.class);
+
     install(NoSshKeyCache.module());
     install(
         new CanonicalWebUrlModule() {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryRepositoryManager.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryRepositoryManager.java
index 4826d9e..a90cbb9 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryRepositoryManager.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryRepositoryManager.java
@@ -19,6 +19,8 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.RepositoryCaseMismatchException;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.inject.Inject;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.SortedSet;
@@ -30,7 +32,7 @@
 /** Repository manager that uses in-memory repositories. */
 public class InMemoryRepositoryManager implements GitRepositoryManager {
   public static InMemoryRepository newRepository(Project.NameKey name) {
-    return new Repo(name);
+    return new Repo(new TestNotesMigration(), name);
   }
 
   public static class Description extends DfsRepositoryDescription {
@@ -49,11 +51,15 @@
   public static class Repo extends InMemoryRepository {
     private String description;
 
-    private Repo(Project.NameKey name) {
+    private Repo(NotesMigration notesMigration, Project.NameKey name) {
       super(new Description(name));
-      // TODO(dborowitz): Allow atomic transactions when this is supported:
-      // https://git.eclipse.org/r/#/c/61841/2/org.eclipse.jgit/src/org/eclipse/jgit/internal/storage/dfs/InMemoryRepository.java@313
-      setPerformsAtomicTransactions(false);
+      // Normally, mimic the behavior of JGit FileRepository, the standard Gerrit repository
+      // backend, and don't support atomic ref updates. The exception is when we're testing with
+      // fused ref updates, which requires atomic ref updates to function.
+      //
+      // TODO(dborowitz): Change to match the behavior of JGit FileRepository after fixing
+      // https://bugs.eclipse.org/bugs/show_bug.cgi?id=515678
+      setPerformsAtomicTransactions(notesMigration.fuseUpdates());
     }
 
     @Override
@@ -72,7 +78,18 @@
     }
   }
 
-  private Map<String, Repo> repos = new HashMap<>();
+  private final NotesMigration notesMigration;
+  private final Map<String, Repo> repos;
+
+  public InMemoryRepositoryManager() {
+    this(new TestNotesMigration());
+  }
+
+  @Inject
+  InMemoryRepositoryManager(NotesMigration notesMigration) {
+    this.notesMigration = notesMigration;
+    this.repos = new HashMap<>();
+  }
 
   @Override
   public synchronized Repo openRepository(Project.NameKey name) throws RepositoryNotFoundException {
@@ -89,7 +106,7 @@
         throw new RepositoryCaseMismatchException(name);
       }
     } catch (RepositoryNotFoundException e) {
-      repo = new Repo(name);
+      repo = new Repo(notesMigration, name);
       repos.put(normalize(name), repo);
     }
     return repo;
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java
index aeaaa47..ad876ce 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java
@@ -89,7 +89,7 @@
 
     List<ChangeBundle> allExpected = readExpected(changeIds);
 
-    boolean oldWrite = notesMigration.writeChanges();
+    boolean oldWrite = notesMigration.rawWriteChangesSetting();
     boolean oldRead = notesMigration.readChanges();
     try {
       notesMigration.setWriteChanges(true);
@@ -162,7 +162,7 @@
   private void checkActual(List<ChangeBundle> allExpected, List<String> msgs) throws Exception {
     ReviewDb db = getUnwrappedDb();
     boolean oldRead = notesMigration.readChanges();
-    boolean oldWrite = notesMigration.writeChanges();
+    boolean oldWrite = notesMigration.rawWriteChangesSetting();
     try {
       notesMigration.setWriteChanges(true);
       notesMigration.setReadChanges(true);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java
index 552f6f1..e8446a2 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java
@@ -35,6 +35,9 @@
   /** All change tables are entirely disabled. */
   DISABLE_CHANGE_REVIEW_DB(true),
 
+  /** All change tables are entirely disabled, and code/meta ref updates are fused. */
+  FUSED(true),
+
   /**
    * Run tests with NoteDb disabled, then convert ReviewDb to NoteDb and check that the results
    * match.
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestNotesMigration.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestNotesMigration.java
index e6a72fc..c05dd01 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestNotesMigration.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestNotesMigration.java
@@ -27,8 +27,13 @@
   private volatile boolean writeChanges;
   private volatile PrimaryStorage changePrimaryStorage = PrimaryStorage.REVIEW_DB;
   private volatile boolean disableChangeReviewDb;
+  private volatile boolean fuseUpdates;
   private volatile boolean failOnLoad;
 
+  public TestNotesMigration() {
+    resetFromEnv();
+  }
+
   @Override
   public boolean readChanges() {
     return readChanges;
@@ -51,10 +56,15 @@
     return disableChangeReviewDb;
   }
 
+  @Override
+  public boolean fuseUpdates() {
+    return fuseUpdates;
+  }
+
   // Increase visbility from superclass, as tests may want to check whether
   // NoteDb data is written in specific migration scenarios.
   @Override
-  public boolean writeChanges() {
+  public boolean rawWriteChangesSetting() {
     return writeChanges;
   }
 
@@ -83,6 +93,11 @@
     return this;
   }
 
+  public TestNotesMigration setFuseUpdates(boolean fuseUpdates) {
+    this.fuseUpdates = fuseUpdates;
+    return this;
+  }
+
   public TestNotesMigration setFailOnLoad(boolean failOnLoad) {
     this.failOnLoad = failOnLoad;
     return this;
@@ -92,31 +107,42 @@
     return setReadChanges(enabled).setWriteChanges(enabled);
   }
 
-  public TestNotesMigration setFromEnv() {
+  public TestNotesMigration resetFromEnv() {
     switch (NoteDbMode.get()) {
       case READ_WRITE:
         setWriteChanges(true);
         setReadChanges(true);
         setChangePrimaryStorage(PrimaryStorage.REVIEW_DB);
         setDisableChangeReviewDb(false);
+        setFuseUpdates(false);
         break;
       case WRITE:
         setWriteChanges(true);
         setReadChanges(false);
         setChangePrimaryStorage(PrimaryStorage.REVIEW_DB);
         setDisableChangeReviewDb(false);
+        setFuseUpdates(false);
         break;
       case PRIMARY:
         setWriteChanges(true);
         setReadChanges(true);
         setChangePrimaryStorage(PrimaryStorage.NOTE_DB);
         setDisableChangeReviewDb(false);
+        setFuseUpdates(false);
         break;
       case DISABLE_CHANGE_REVIEW_DB:
         setWriteChanges(true);
         setReadChanges(true);
         setChangePrimaryStorage(PrimaryStorage.NOTE_DB);
         setDisableChangeReviewDb(true);
+        setFuseUpdates(false);
+        break;
+      case FUSED:
+        setWriteChanges(true);
+        setReadChanges(true);
+        setChangePrimaryStorage(PrimaryStorage.NOTE_DB);
+        setDisableChangeReviewDb(true);
+        setFuseUpdates(true);
         break;
       case CHECK:
       case OFF:
@@ -125,8 +151,18 @@
         setReadChanges(false);
         setChangePrimaryStorage(PrimaryStorage.REVIEW_DB);
         setDisableChangeReviewDb(false);
+        setFuseUpdates(false);
         break;
     }
     return this;
   }
+
+  public TestNotesMigration setFrom(NotesMigration other) {
+    setWriteChanges(other.rawWriteChangesSetting());
+    setReadChanges(other.readChanges());
+    setChangePrimaryStorage(other.changePrimaryStorage());
+    setDisableChangeReviewDb(other.disableChangeReviewDb());
+    setFuseUpdates(other.fuseUpdates());
+    return this;
+  }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AbstractGitCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AbstractGitCommand.java
index 4144ed2..dc96e18 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AbstractGitCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AbstractGitCommand.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.sshd.SshScope.Context;
 import com.google.inject.Inject;
@@ -84,7 +85,7 @@
     return n;
   }
 
-  private void service() throws IOException, Failure {
+  private void service() throws IOException, PermissionBackendException, Failure {
     project = projectControl.getProjectState().getProject();
 
     try {
@@ -100,5 +101,5 @@
     }
   }
 
-  protected abstract void runImpl() throws IOException, Failure;
+  protected abstract void runImpl() throws IOException, PermissionBackendException, Failure;
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommand.java
index 45835d9..0ac7765 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommand.java
@@ -16,12 +16,16 @@
 
 import com.google.common.base.Throwables;
 import com.google.common.util.concurrent.Atomics;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.CapabilityControl;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import java.io.IOException;
 import java.util.LinkedList;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.atomic.AtomicReference;
 import org.apache.sshd.server.Command;
 import org.apache.sshd.server.Environment;
@@ -30,14 +34,17 @@
 public class AliasCommand extends BaseCommand {
   private final DispatchCommandProvider root;
   private final CurrentUser currentUser;
+  private final PermissionBackend permissionBackend;
   private final CommandName command;
   private final AtomicReference<Command> atomicCmd;
 
   AliasCommand(
       @CommandName(Commands.ROOT) DispatchCommandProvider root,
+      PermissionBackend permissionBackend,
       CurrentUser currentUser,
       CommandName command) {
     this.root = root;
+    this.permissionBackend = permissionBackend;
     this.currentUser = currentUser;
     this.command = command;
     this.atomicCmd = Atomics.newReference();
@@ -47,7 +54,7 @@
   public void start(Environment env) throws IOException {
     try {
       begin(env);
-    } catch (UnloggedFailure e) {
+    } catch (Failure e) {
       String msg = e.getMessage();
       if (!msg.endsWith("\n")) {
         msg += "\n";
@@ -58,7 +65,7 @@
     }
   }
 
-  private void begin(Environment env) throws UnloggedFailure, IOException {
+  private void begin(Environment env) throws IOException, Failure {
     Map<String, CommandProvider> map = root.getMap();
     for (String name : chain(command)) {
       CommandProvider p = map.get(name);
@@ -103,17 +110,16 @@
     }
   }
 
-  private void checkRequiresCapability(Command cmd) throws UnloggedFailure {
-    RequiresCapability rc = cmd.getClass().getAnnotation(RequiresCapability.class);
-    if (rc != null) {
-      CapabilityControl ctl = currentUser.getCapabilities();
-      if (!ctl.canPerform(rc.value()) && !ctl.canAdministrateServer()) {
-        String msg =
-            String.format(
-                "fatal: %s does not have \"%s\" capability.",
-                currentUser.getUserName(), rc.value());
-        throw new UnloggedFailure(BaseCommand.STATUS_NOT_ADMIN, msg);
+  private void checkRequiresCapability(Command cmd) throws Failure {
+    try {
+      Set<GlobalOrPluginPermission> check = GlobalPermission.fromAnnotation(cmd.getClass());
+      try {
+        permissionBackend.user(currentUser).checkAny(check);
+      } catch (AuthException err) {
+        throw new UnloggedFailure(BaseCommand.STATUS_NOT_ADMIN, "fatal: " + err.getMessage());
       }
+    } catch (PermissionBackendException err) {
+      throw new Failure(1, "fatal: permissions unavailable", err);
     }
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommandProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommandProvider.java
index 10beb40..0ef0473 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommandProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommandProvider.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.sshd;
 
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import org.apache.sshd.server.Command;
@@ -27,6 +28,7 @@
   @CommandName(Commands.ROOT)
   private DispatchCommandProvider root;
 
+  @Inject private PermissionBackend permissionBackend;
   @Inject private CurrentUser currentUser;
 
   public AliasCommandProvider(CommandName command) {
@@ -35,6 +37,6 @@
 
   @Override
   public Command get() {
-    return new AliasCommand(root, currentUser, command);
+    return new AliasCommand(root, permissionBackend, currentUser, command);
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java
index bc465ec..dbf5b10 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java
@@ -20,19 +20,26 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.RequestCleanup;
 import com.google.gerrit.server.git.ProjectRunnable;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.WorkQueue.CancelableRunnable;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.sshd.SshScope.Context;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.gerrit.util.cli.EndOfOptionsHandler;
 import com.google.inject.Inject;
+import com.google.inject.Injector;
 import java.io.BufferedWriter;
 import java.io.IOException;
 import java.io.InputStream;
@@ -80,6 +87,7 @@
 
   @Inject @CommandExecutor private WorkQueue.Executor executor;
 
+  @Inject private PermissionBackend permissionBackend;
   @Inject private CurrentUser user;
 
   @Inject private SshScope.Context context;
@@ -89,6 +97,10 @@
   @PluginName
   private String pluginName;
 
+  @Inject private Injector injector;
+
+  @Inject private DynamicMap<DynamicOptions.DynamicBean> dynamicBeans = null;
+
   /** The task, as scheduled on a worker thread. */
   private final AtomicReference<Future<?>> task;
 
@@ -193,6 +205,10 @@
    */
   protected void parseCommandLine(Object options) throws UnloggedFailure {
     final CmdLineParser clp = newCmdLineParser(options);
+    DynamicOptions pluginOptions = new DynamicOptions(options, injector, dynamicBeans);
+    pluginOptions.parseDynamicBeans(clp);
+    pluginOptions.setDynamicBeans();
+    pluginOptions.onBeanParseStart();
     try {
       clp.parseArgument(argv);
     } catch (IllegalArgumentException | CmdLineException err) {
@@ -207,6 +223,7 @@
       msg.write(usage());
       throw new UnloggedFailure(1, msg.toString());
     }
+    pluginOptions.onBeanParseEnd();
   }
 
   protected String usage() {
@@ -224,31 +241,6 @@
    * <p>Typically this should be invoked within {@link Command#start(Environment)}, such as:
    *
    * <pre>
-   * startThread(new Runnable() {
-   *   public void run() {
-   *     runImp();
-   *   }
-   * });
-   * </pre>
-   *
-   * @param thunk the runnable to execute on the thread, performing the command's logic.
-   */
-  protected void startThread(final Runnable thunk) {
-    startThread(
-        new CommandRunnable() {
-          @Override
-          public void run() throws Exception {
-            thunk.run();
-          }
-        });
-  }
-
-  /**
-   * Spawn a function into its own thread.
-   *
-   * <p>Typically this should be invoked within {@link Command#start(Environment)}, such as:
-   *
-   * <pre>
    * startThread(new CommandRunnable() {
    *   public void run() throws Exception {
    *     runImp();
@@ -264,7 +256,7 @@
   protected void startThread(final CommandRunnable thunk) {
     final TaskThunk tt = new TaskThunk(thunk);
 
-    if (isAdminHighPriorityCommand() && user.getCapabilities().canAdministrateServer()) {
+    if (isAdminHighPriorityCommand()) {
       // Admin commands should not block the main work threads (there
       // might be an interactive shell there), nor should they wait
       // for the main work threads.
@@ -276,7 +268,15 @@
   }
 
   private boolean isAdminHighPriorityCommand() {
-    return getClass().getAnnotation(AdminHighPriorityCommand.class) != null;
+    if (getClass().getAnnotation(AdminHighPriorityCommand.class) != null) {
+      try {
+        permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+        return true;
+      } catch (AuthException | PermissionBackendException e) {
+        return false;
+      }
+    }
+    return false;
   }
 
   /**
@@ -469,6 +469,7 @@
   }
 
   /** Runnable function which can throw an exception. */
+  @FunctionalInterface
   public interface CommandRunnable {
     void run() throws Exception;
   }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java
index 4488c71..e64ab0e 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java
@@ -16,6 +16,7 @@
 
 import static java.util.stream.Collectors.toList;
 
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -24,6 +25,9 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ChangesCollection;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectControl;
@@ -43,6 +47,7 @@
   private final ReviewDb db;
   private final ChangeNotes.Factory changeNotesFactory;
   private final ChangeControl.GenericFactory changeControlFactory;
+  private final PermissionBackend permissionBackend;
 
   @Inject
   ChangeArgumentParser(
@@ -51,13 +56,15 @@
       ChangeFinder changeFinder,
       ReviewDb db,
       ChangeNotes.Factory changeNotesFactory,
-      ChangeControl.GenericFactory changeControlFactory) {
+      ChangeControl.GenericFactory changeControlFactory,
+      PermissionBackend permissionBackend) {
     this.currentUser = currentUser;
     this.changesCollection = changesCollection;
     this.changeFinder = changeFinder;
     this.db = db;
     this.changeNotesFactory = changeNotesFactory;
     this.changeControlFactory = changeControlFactory;
+    this.permissionBackend = permissionBackend;
   }
 
   public void addChange(String id, Map<Change.Id, ChangeResource> changes)
@@ -80,9 +87,13 @@
     List<ChangeControl> matched =
         useIndex ? changeFinder.find(id, currentUser) : changeFromNotesFactory(id, currentUser);
     List<ChangeControl> toAdd = new ArrayList<>(changes.size());
-    boolean canMaintainServer =
-        currentUser.isIdentifiedUser()
-            && currentUser.asIdentifiedUser().getCapabilities().canMaintainServer();
+    boolean canMaintainServer;
+    try {
+      permissionBackend.user(currentUser).check(GlobalPermission.MAINTAIN_SERVER);
+      canMaintainServer = true;
+    } catch (AuthException | PermissionBackendException e) {
+      canMaintainServer = false;
+    }
     for (ChangeControl ctl : matched) {
       if (!changes.containsKey(ctl.getId())
           && inProject(projectControl, ctl.getProject())
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java
index c6d750c..66b8fe6 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java
@@ -230,13 +230,7 @@
       Future<?> future = task.getAndSet(null);
       if (future != null) {
         future.cancel(true);
-        destroyExecutor.execute(
-            new Runnable() {
-              @Override
-              public void run() {
-                onDestroy();
-              }
-            });
+        destroyExecutor.execute(this::onDestroy);
       }
     }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommand.java
index 2f3d10f6..87e90f4 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommand.java
@@ -20,8 +20,10 @@
 import com.google.common.util.concurrent.Atomics;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.CapabilityUtils;
 import com.google.gerrit.server.args4j.SubcommandHandler;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -41,6 +43,7 @@
   }
 
   private final CurrentUser currentUser;
+  private final PermissionBackend permissionBackend;
   private final Map<String, CommandProvider> commands;
   private final AtomicReference<Command> atomicCmd;
 
@@ -51,8 +54,12 @@
   private List<String> args = new ArrayList<>();
 
   @Inject
-  DispatchCommand(CurrentUser cu, @Assisted final Map<String, CommandProvider> all) {
-    currentUser = cu;
+  DispatchCommand(
+      CurrentUser user,
+      PermissionBackend permissionBackend,
+      @Assisted Map<String, CommandProvider> all) {
+    this.currentUser = user;
+    this.permissionBackend = permissionBackend;
     commands = all;
     atomicCmd = Atomics.newReference();
   }
@@ -117,9 +124,13 @@
       pluginName = ((BaseCommand) cmd).getPluginName();
     }
     try {
-      CapabilityUtils.checkRequiresCapability(currentUser, pluginName, cmd.getClass());
+      permissionBackend
+          .user(currentUser)
+          .checkAny(GlobalPermission.fromAnnotation(pluginName, cmd.getClass()));
     } catch (AuthException e) {
       throw new UnloggedFailure(BaseCommand.STATUS_NOT_ADMIN, e.getMessage());
+    } catch (PermissionBackendException e) {
+      throw new UnloggedFailure(1, "fatal: permission check unavailable", e);
     }
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
index 837865e..6a68211 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
@@ -14,18 +14,17 @@
 
 package com.google.gerrit.sshd;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.gerrit.server.ssh.SshKeyCreator;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
@@ -91,39 +90,33 @@
   }
 
   static class Loader extends CacheLoader<String, Iterable<SshKeyCacheEntry>> {
-    private final SchemaFactory<ReviewDb> schema;
+    private final ExternalIds externalIds;
     private final VersionedAuthorizedKeys.Accessor authorizedKeys;
 
     @Inject
-    Loader(SchemaFactory<ReviewDb> schema, VersionedAuthorizedKeys.Accessor authorizedKeys) {
-      this.schema = schema;
+    Loader(ExternalIds externalIds, VersionedAuthorizedKeys.Accessor authorizedKeys) {
+      this.externalIds = externalIds;
       this.authorizedKeys = authorizedKeys;
     }
 
     @Override
     public Iterable<SshKeyCacheEntry> load(String username) throws Exception {
-      try (ReviewDb db = schema.open()) {
-        ExternalId user =
-            ExternalId.from(
-                db.accountExternalIds()
-                    .get(
-                        ExternalId.Key.create(SCHEME_USERNAME, username).asAccountExternalIdKey()));
-        if (user == null) {
-          return NO_SUCH_USER;
-        }
-
-        List<SshKeyCacheEntry> kl = new ArrayList<>(4);
-        for (AccountSshKey k : authorizedKeys.getKeys(user.accountId())) {
-          if (k.isValid()) {
-            add(kl, k);
-          }
-        }
-
-        if (kl.isEmpty()) {
-          return NO_KEYS;
-        }
-        return Collections.unmodifiableList(kl);
+      ExternalId user = externalIds.get(ExternalId.Key.create(SCHEME_USERNAME, username));
+      if (user == null) {
+        return NO_SUCH_USER;
       }
+
+      List<SshKeyCacheEntry> kl = new ArrayList<>(4);
+      for (AccountSshKey k : authorizedKeys.getKeys(user.accountId())) {
+        if (k.isValid()) {
+          add(kl, k);
+        }
+      }
+
+      if (kl.isEmpty()) {
+        return NO_KEYS;
+      }
+      return Collections.unmodifiableList(kl);
     }
 
     private void add(List<SshKeyCacheEntry> kl, AccountSshKey k) {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshModule.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshModule.java
index d25c58b..789a630 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshModule.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshModule.java
@@ -17,7 +17,9 @@
 import static com.google.gerrit.extensions.registration.PrivateInternals_DynamicTypes.registerInParentInjectors;
 import static com.google.inject.Scopes.SINGLETON;
 
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.PeerDaemonUser;
 import com.google.gerrit.server.RemotePeer;
 import com.google.gerrit.server.config.GerritRequestModule;
@@ -94,6 +96,8 @@
         .annotatedWith(UniqueAnnotations.create())
         .to(SshPluginStarterCallback.class);
 
+    DynamicMap.mapOf(binder(), DynamicOptions.DynamicBean.class);
+
     listener().toInstance(registerInParentInjectors());
     listener().to(SshLog.class);
     listener().to(SshDaemon.class);
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshPluginStarterCallback.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
index 7f76ec6..9ae1814 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.sshd;
 
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.plugins.Plugin;
 import com.google.gerrit.server.plugins.ReloadPluginListener;
 import com.google.gerrit.server.plugins.StartPluginListener;
@@ -30,10 +32,14 @@
   private static final Logger log = LoggerFactory.getLogger(SshPluginStarterCallback.class);
 
   private final DispatchCommandProvider root;
+  private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
   @Inject
-  SshPluginStarterCallback(@CommandName(Commands.ROOT) DispatchCommandProvider root) {
+  SshPluginStarterCallback(
+      @CommandName(Commands.ROOT) DispatchCommandProvider root,
+      DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
     this.root = root;
+    this.dynamicBeans = dynamicBeans;
   }
 
   @Override
@@ -58,10 +64,19 @@
       try {
         return plugin.getSshInjector().getProvider(key);
       } catch (RuntimeException err) {
-        log.warn(
-            String.format("Plugin %s did not define its top-level command", plugin.getName()), err);
+        if (!providesDynamicOptions(plugin)) {
+          log.warn(
+              String.format(
+                  "Plugin %s did not define its top-level command nor any DynamicOptions",
+                  plugin.getName()),
+              err);
+        }
       }
     }
     return null;
   }
+
+  private boolean providesDynamicOptions(Plugin plugin) {
+    return dynamicBeans.plugins().contains(plugin.getName());
+  }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SuExec.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SuExec.java
index 53a98eb..54371c1 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SuExec.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SuExec.java
@@ -18,11 +18,15 @@
 
 import com.google.common.base.Throwables;
 import com.google.common.util.concurrent.Atomics;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PeerDaemonUser;
 import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.sshd.SshScope.Context;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -45,6 +49,7 @@
 public final class SuExec extends BaseCommand {
   private final SshScope sshScope;
   private final DispatchCommandProvider dispatcher;
+  private final PermissionBackend permissionBackend;
 
   private boolean enableRunAs;
   private CurrentUser caller;
@@ -67,6 +72,7 @@
   SuExec(
       final SshScope sshScope,
       @CommandName(Commands.ROOT) final DispatchCommandProvider dispatcher,
+      PermissionBackend permissionBackend,
       final CurrentUser caller,
       final SshSession session,
       final IdentifiedUser.GenericFactory userFactory,
@@ -74,6 +80,7 @@
       AuthConfig config) {
     this.sshScope = sshScope;
     this.dispatcher = dispatcher;
+    this.permissionBackend = permissionBackend;
     this.caller = caller;
     this.session = session;
     this.userFactory = userFactory;
@@ -115,8 +122,14 @@
       // OK.
     } else if (!enableRunAs) {
       throw die("suexec disabled by auth.enableRunAs = false");
-    } else if (!caller.getCapabilities().canRunAs()) {
-      throw die("suexec not permitted");
+    } else {
+      try {
+        permissionBackend.user(caller).check(GlobalPermission.RUN_AS);
+      } catch (AuthException e) {
+        throw die("suexec not permitted");
+      } catch (PermissionBackendException e) {
+        throw die("suexec not available: " + e);
+      }
     }
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminQueryShell.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminQueryShell.java
index 42c7578..ef1cd81 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminQueryShell.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminQueryShell.java
@@ -15,9 +15,12 @@
 package com.google.gerrit.sshd.commands;
 
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.errors.PermissionDeniedException;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.sshd.AdminHighPriorityCommand;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
@@ -29,8 +32,8 @@
 @RequiresCapability(GlobalCapability.ACCESS_DATABASE)
 @CommandMetaData(name = "gsql", description = "Administrative interface to active database")
 final class AdminQueryShell extends SshCommand {
+  @Inject private PermissionBackend permissionBackend;
   @Inject private QueryShell.Factory factory;
-
   @Inject private IdentifiedUser currentUser;
 
   @Option(name = "--format", usage = "Set output format")
@@ -42,9 +45,11 @@
   @Override
   protected void run() throws Failure {
     try {
-      checkPermission();
-    } catch (PermissionDeniedException err) {
+      permissionBackend.user(currentUser).check(GlobalPermission.ACCESS_DATABASE);
+    } catch (AuthException err) {
       throw die(err.getMessage());
+    } catch (PermissionBackendException e) {
+      throw new Failure(1, "unavailable", e);
     }
 
     QueryShell shell = factory.create(in, out);
@@ -55,22 +60,4 @@
       shell.run();
     }
   }
-
-  /**
-   * Assert that the current user is permitted to perform raw queries.
-   *
-   * <p>As the @RequireCapability guards at various entry points of internal commands implicitly add
-   * administrators (which we want to avoid), we also check permissions within QueryShell and grant
-   * access only to those who can access the database, regardless of whether they are administrators
-   * or not.
-   *
-   * @throws PermissionDeniedException
-   */
-  private void checkPermission() throws PermissionDeniedException {
-    if (!currentUser.getCapabilities().canAccessDatabase()) {
-      throw new PermissionDeniedException(
-          String.format(
-              "%s does not have \"Access Database\" capability.", currentUser.getUserName()));
-    }
-  }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java
index 35ddc2a..4434694 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ListChildProjects;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectControl;
@@ -129,7 +130,11 @@
       childProjects.add(pc.getProject().getNameKey());
     }
     if (oldParent != null) {
-      childProjects.addAll(getChildrenForReparenting(oldParent));
+      try {
+        childProjects.addAll(getChildrenForReparenting(oldParent));
+      } catch (PermissionBackendException e) {
+        throw new Failure(1, "permissions unavailable", e);
+      }
     }
 
     for (final Project.NameKey nameKey : childProjects) {
@@ -185,7 +190,8 @@
    * list of child projects does not contain projects that were specified to be excluded from
    * reparenting.
    */
-  private List<Project.NameKey> getChildrenForReparenting(final ProjectControl parent) {
+  private List<Project.NameKey> getChildrenForReparenting(final ProjectControl parent)
+      throws PermissionBackendException {
     final List<Project.NameKey> childProjects = new ArrayList<>();
     final List<Project.NameKey> excluded = new ArrayList<>(excludedChildren.size());
     for (final ProjectControl excludedChild : excludedChildren) {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
index 8b323dc..0df2a80 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.SuggestParentCandidates;
 import com.google.gerrit.sshd.CommandMetaData;
@@ -175,7 +175,7 @@
   @Inject private SuggestParentCandidates suggestParentCandidates;
 
   @Override
-  protected void run() throws UnloggedFailure {
+  protected void run() throws Failure {
     try {
       if (!suggestParent) {
         if (projectName == null) {
@@ -207,14 +207,14 @@
 
         gApi.projects().create(input);
       } else {
-        List<Project.NameKey> parentCandidates = suggestParentCandidates.getNameKeys();
-
-        for (Project.NameKey parent : parentCandidates) {
-          stdout.print(parent + "\n");
+        for (Project.NameKey parent : suggestParentCandidates.getNameKeys()) {
+          stdout.print(parent.get() + '\n');
         }
       }
-    } catch (RestApiException | NoSuchProjectException err) {
+    } catch (RestApiException err) {
       throw die(err);
+    } catch (PermissionBackendException err) {
+      throw new Failure(1, "permissions unavailable", err);
     }
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/FlushCaches.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/FlushCaches.java
index 21bfe9b..392fd29 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/FlushCaches.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/FlushCaches.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.server.config.ListCaches;
 import com.google.gerrit.server.config.ListCaches.OutputFormat;
 import com.google.gerrit.server.config.PostCaches;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
@@ -81,6 +82,8 @@
       }
     } catch (RestApiException e) {
       throw die(e.getMessage());
+    } catch (PermissionBackendException e) {
+      throw new Failure(1, "unavailable", e);
     }
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
index fc65cf3..86209fe 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.sshd.commands;
 
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.Index;
@@ -23,7 +22,6 @@
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import java.io.IOException;
 import java.util.LinkedHashMap;
 import java.util.Map;
 import org.kohsuke.args4j.Argument;
@@ -57,7 +55,7 @@
     for (ChangeResource rsrc : changes.values()) {
       try {
         index.apply(rsrc, new Index.Input());
-      } catch (IOException | RestApiException | OrmException e) {
+      } catch (Exception e) {
         ok = false;
         writeError(
             "error", String.format("failed to index change %s: %s", rsrc.getId(), e.getMessage()));
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/KillCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/KillCommand.java
index 4ebc568..3465a9c 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/KillCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/KillCommand.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.server.config.DeleteTask;
 import com.google.gerrit.server.config.TaskResource;
 import com.google.gerrit.server.config.TasksCollection;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.sshd.AdminHighPriorityCommand;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
@@ -50,7 +51,7 @@
       try {
         TaskResource taskRsrc = tasksCollection.parse(cfgRsrc, IdString.fromDecoded(id));
         deleteTask.apply(taskRsrc, null);
-      } catch (AuthException | ResourceNotFoundException e) {
+      } catch (AuthException | ResourceNotFoundException | PermissionBackendException e) {
         stderr.print("kill: " + id + ": No such task\n");
       }
     }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Query.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Query.java
index 1192eb5..2e5bf71 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Query.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Query.java
@@ -24,7 +24,7 @@
 import org.kohsuke.args4j.Option;
 
 @CommandMetaData(name = "query", description = "Query the change database")
-class Query extends SshCommand {
+public class Query extends SshCommand {
   @Inject private OutputStreamQuery processor;
 
   @Option(name = "--format", metaVar = "FMT", usage = "Output display format")
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ScpCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ScpCommand.java
index 1ed0bb0..0c8c74a 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ScpCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ScpCommand.java
@@ -82,13 +82,7 @@
 
   @Override
   public void start(final Environment env) {
-    startThread(
-        new Runnable() {
-          @Override
-          public void run() {
-            runImp();
-          }
-        });
+    startThread(this::runImp);
   }
 
   private void runImp() {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
index 21591dd..8c6db83 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
@@ -42,6 +42,7 @@
 import com.google.gerrit.server.account.PutHttpPassword;
 import com.google.gerrit.server.account.PutName;
 import com.google.gerrit.server.account.PutPreferred;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gwtorm.server.OrmException;
@@ -174,7 +175,8 @@
   }
 
   private void setAccount()
-      throws OrmException, IOException, UnloggedFailure, ConfigInvalidException {
+      throws OrmException, IOException, UnloggedFailure, ConfigInvalidException,
+          PermissionBackendException {
     user = genericUserFactory.create(id);
     rsrc = new AccountResource(user);
     try {
@@ -227,7 +229,8 @@
   }
 
   private void addSshKeys(List<String> sshKeys)
-      throws RestApiException, OrmException, IOException, ConfigInvalidException {
+      throws RestApiException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     for (final String sshKey : sshKeys) {
       AddSshKey.Input in = new AddSshKey.Input();
       in.raw = RawInputUtil.create(sshKey.getBytes(UTF_8), "plain/text");
@@ -237,7 +240,7 @@
 
   private void deleteSshKeys(List<String> sshKeys)
       throws RestApiException, OrmException, RepositoryNotFoundException, IOException,
-          ConfigInvalidException {
+          ConfigInvalidException, PermissionBackendException {
     List<SshKeyInfo> infos = getSshKeys.apply(rsrc);
     if (sshKeys.contains("ALL")) {
       for (SshKeyInfo i : infos) {
@@ -256,14 +259,15 @@
 
   private void deleteSshKey(SshKeyInfo i)
       throws AuthException, OrmException, RepositoryNotFoundException, IOException,
-          ConfigInvalidException {
+          ConfigInvalidException, PermissionBackendException {
     AccountSshKey sshKey =
         new AccountSshKey(new AccountSshKey.Id(user.getAccountId(), i.seq), i.sshPublicKey);
     deleteSshKey.apply(new AccountResource.SshKey(user, sshKey), null);
   }
 
   private void addEmail(String email)
-      throws UnloggedFailure, RestApiException, OrmException, IOException, ConfigInvalidException {
+      throws UnloggedFailure, RestApiException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     EmailInput in = new EmailInput();
     in.email = email;
     in.noConfirmation = true;
@@ -275,7 +279,8 @@
   }
 
   private void deleteEmail(String email)
-      throws RestApiException, OrmException, IOException, ConfigInvalidException {
+      throws RestApiException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     if (email.equals("ALL")) {
       List<EmailInfo> emails = getEmails.apply(rsrc);
       for (EmailInfo e : emails) {
@@ -286,7 +291,8 @@
     }
   }
 
-  private void putPreferred(String email) throws RestApiException, OrmException, IOException {
+  private void putPreferred(String email)
+      throws RestApiException, OrmException, IOException, PermissionBackendException {
     for (EmailInfo e : getEmails.apply(rsrc)) {
       if (e.email.equals(email)) {
         putPreferred.apply(new AccountResource.Email(user, email), null);
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetProjectCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
index 20f65ad..c275af8 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
@@ -14,29 +14,23 @@
 
 package com.google.gerrit.sshd.commands;
 
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.PutConfig;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 @CommandMetaData(name = "set-project", description = "Change a project's settings")
 final class SetProjectCommand extends SshCommand {
-  private static final Logger log = LoggerFactory.getLogger(SetProjectCommand.class);
-
   @Argument(index = 0, required = true, metaVar = "NAME", usage = "name of the project")
   private ProjectControl projectControl;
 
@@ -144,64 +138,30 @@
   @Option(name = "--max-object-size-limit", usage = "max Git object size for this project")
   private String maxObjectSizeLimit;
 
-  @Inject private MetaDataUpdate.User metaDataUpdateFactory;
-
-  @Inject private ProjectCache projectCache;
+  @Inject private PutConfig putConfig;
 
   @Override
   protected void run() throws Failure {
-    if (!projectControl.isOwner()) {
-      throw new UnloggedFailure(1, "restricted to project owner");
+    ConfigInput configInput = new ConfigInput();
+    configInput.requireChangeId = requireChangeID;
+    configInput.submitType = submitType;
+    configInput.useContentMerge = contentMerge;
+    configInput.useContributorAgreements = contributorAgreements;
+    configInput.useSignedOffBy = signedOffBy;
+    configInput.state = state;
+    configInput.maxObjectSizeLimit = maxObjectSizeLimit;
+    // Description is different to other parameters, null won't result in
+    // keeping the existing description, it would delete it.
+    if (Strings.emptyToNull(projectDescription) != null) {
+      configInput.description = projectDescription;
+    } else {
+      configInput.description = projectControl.getProject().getDescription();
     }
-    Project ctlProject = projectControl.getProject();
-    Project.NameKey nameKey = ctlProject.getNameKey();
-    String name = ctlProject.getName();
-    final StringBuilder err = new StringBuilder();
 
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(nameKey)) {
-      ProjectConfig config = ProjectConfig.read(md);
-      Project project = config.getProject();
-
-      if (requireChangeID != null) {
-        project.setRequireChangeID(requireChangeID);
-      }
-      if (submitType != null) {
-        project.setSubmitType(submitType);
-      }
-      if (contentMerge != null) {
-        project.setUseContentMerge(contentMerge);
-      }
-      if (contributorAgreements != null) {
-        project.setUseContributorAgreements(contributorAgreements);
-      }
-      if (signedOffBy != null) {
-        project.setUseSignedOffBy(signedOffBy);
-      }
-      if (projectDescription != null) {
-        project.setDescription(projectDescription);
-      }
-      if (state != null) {
-        project.setState(state);
-      }
-      if (maxObjectSizeLimit != null) {
-        project.setMaxObjectSizeLimit(maxObjectSizeLimit);
-      }
-      md.setMessage("Project settings updated");
-      config.commit(md);
-    } catch (RepositoryNotFoundException notFound) {
-      err.append("Project ").append(name).append(" not found\n");
-    } catch (IOException | ConfigInvalidException e) {
-      final String msg = "Cannot update project " + name;
-      log.error(msg, e);
-      err.append("error: ").append(msg).append("\n");
-    }
-    projectCache.evict(ctlProject);
-
-    if (err.length() > 0) {
-      while (err.charAt(err.length() - 1) == '\n') {
-        err.setLength(err.length() - 1);
-      }
-      throw die(err.toString());
+    try {
+      putConfig.apply(new ProjectResource(projectControl), configInput);
+    } catch (RestApiException e) {
+      throw die(e);
     }
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowCaches.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowCaches.java
index e16f270..1ed7db3 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowCaches.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowCaches.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.common.Version;
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.config.GetSummary;
@@ -34,6 +35,9 @@
 import com.google.gerrit.server.config.ListCaches;
 import com.google.gerrit.server.config.ListCaches.CacheInfo;
 import com.google.gerrit.server.config.ListCaches.CacheType;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gerrit.sshd.SshDaemon;
@@ -80,12 +84,10 @@
   private boolean showThreads;
 
   @Inject private SshDaemon daemon;
-
   @Inject private ListCaches listCaches;
-
   @Inject private GetSummary getSummary;
-
   @Inject private CurrentUser self;
+  @Inject private PermissionBackend permissionBackend;
 
   @Option(
     name = "--width",
@@ -168,7 +170,15 @@
     printDiskCaches(caches);
     stdout.print('\n');
 
-    if (self.getCapabilities().canMaintainServer()) {
+    boolean showJvm;
+    try {
+      permissionBackend.user(self).check(GlobalPermission.MAINTAIN_SERVER);
+      showJvm = true;
+    } catch (AuthException | PermissionBackendException e) {
+      // Silently ignore and do not display detailed JVM information.
+      showJvm = false;
+    }
+    if (showJvm) {
       sshSummary();
 
       SummaryInfo summary = getSummary.setGc(gc).setJvm(showJVM).apply(new ConfigResource());
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowQueue.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowQueue.java
index 13db697..dfb9c9c 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowQueue.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowQueue.java
@@ -27,6 +27,9 @@
 import com.google.gerrit.server.config.ListTasks.TaskInfo;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.WorkQueue.Task;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.sshd.AdminHighPriorityCommand;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
@@ -60,10 +63,9 @@
   )
   private boolean groupByQueue;
 
+  @Inject private PermissionBackend permissionBackend;
   @Inject private ListTasks listTasks;
-
   @Inject private IdentifiedUser currentUser;
-
   @Inject private WorkQueue workQueue;
 
   private int columns = 80;
@@ -83,7 +85,7 @@
   }
 
   @Override
-  protected void run() throws UnloggedFailure {
+  protected void run() throws Failure {
     maxCommandWidth = wide ? Integer.MAX_VALUE : columns - 8 - 12 - 12 - 4 - 4;
     stdout.print(
         String.format(
@@ -97,10 +99,12 @@
       tasks = listTasks.apply(new ConfigResource());
     } catch (AuthException e) {
       throw die(e);
+    } catch (PermissionBackendException e) {
+      throw new Failure(1, "permission backend unavailable", e);
     }
-    boolean viewAll = currentUser.getCapabilities().canViewQueue();
-    long now = TimeUtil.nowMs();
 
+    boolean viewAll = permissionBackend.user(currentUser).testOrFalse(GlobalPermission.VIEW_QUEUE);
+    long now = TimeUtil.nowMs();
     if (groupByQueue) {
       ListMultimap<String, TaskInfo> byQueue = byQueue(tasks);
       for (String queueName : byQueue.keySet()) {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/UploadArchive.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/UploadArchive.java
index 4b8771a..093808c 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/UploadArchive.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/UploadArchive.java
@@ -17,9 +17,14 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.AllowedFormats;
 import com.google.gerrit.server.change.ArchiveFormat;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.sshd.AbstractGitCommand;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -115,6 +120,8 @@
     private List<String> path;
   }
 
+  @Inject private PermissionBackend permissionBackend;
+  @Inject private IdentifiedUser user;
   @Inject private AllowedFormats allowedFormats;
   @Inject private ReviewDb db;
   private Options options = new Options();
@@ -156,7 +163,7 @@
   }
 
   @Override
-  protected void runImpl() throws IOException, Failure {
+  protected void runImpl() throws IOException, PermissionBackendException, Failure {
     PacketLineOut packetOut = new PacketLineOut(out);
     packetOut.setFlushOnEnd(true);
     packetOut.writeString("ACK");
@@ -177,8 +184,8 @@
         throw new Failure(4, "fatal: reference not found");
       }
 
-      // Verify the user has permissions to read the specified reference
-      if (!projectControl.allRefsAreVisible() && !canRead(treeId)) {
+      // Verify the user has permissions to read the specified tree.
+      if (!canRead(treeId)) {
         throw new Failure(5, "fatal: cannot perform upload-archive operation");
       }
 
@@ -235,10 +242,16 @@
     return Collections.emptyMap();
   }
 
-  private boolean canRead(ObjectId revId) throws IOException {
-    try (RevWalk rw = new RevWalk(repo)) {
-      RevCommit commit = rw.parseCommit(revId);
-      return projectControl.canReadCommit(db, repo, commit);
+  private boolean canRead(ObjectId revId) throws IOException, PermissionBackendException {
+    try {
+      permissionBackend.user(user).project(project.getNameKey()).check(ProjectPermission.READ);
+      return true;
+    } catch (AuthException e) {
+      // Check reachability of the specific revision.
+      try (RevWalk rw = new RevWalk(repo)) {
+        RevCommit commit = rw.parseCommit(revId);
+        return projectControl.canReadCommit(db, repo, commit);
+      }
     }
   }
 }
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/restapi/BinaryResultSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/restapi/BinaryResultSubject.java
index 989ab0f..30ac496 100644
--- a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/restapi/BinaryResultSubject.java
+++ b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/restapi/BinaryResultSubject.java
@@ -18,6 +18,7 @@
 
 import com.google.common.truth.FailureStrategy;
 import com.google.common.truth.PrimitiveByteArraySubject;
+import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
 import com.google.common.truth.SubjectFactory;
 import com.google.common.truth.Truth;
@@ -51,6 +52,15 @@
     super(failureStrategy, binaryResult);
   }
 
+  public StringSubject asString() throws IOException {
+    isNotNull();
+    // We shouldn't close the BinaryResult within this method as it might still
+    // be used afterwards. Besides, closing it doesn't have an effect for most
+    // implementations of a BinaryResult.
+    BinaryResult binaryResult = actual();
+    return Truth.assertThat(binaryResult.asString());
+  }
+
   public PrimitiveByteArraySubject bytes() throws IOException {
     isNotNull();
     // We shouldn't close the BinaryResult within this method as it might still
diff --git a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java b/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java
index 11f380d..8aa5b9e 100644
--- a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java
+++ b/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java
@@ -44,6 +44,8 @@
 import java.io.Writer;
 import java.lang.annotation.Annotation;
 import java.lang.reflect.AnnotatedElement;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -58,8 +60,10 @@
 import org.kohsuke.args4j.spi.BooleanOptionHandler;
 import org.kohsuke.args4j.spi.EnumOptionHandler;
 import org.kohsuke.args4j.spi.FieldSetter;
+import org.kohsuke.args4j.spi.MethodSetter;
 import org.kohsuke.args4j.spi.OptionHandler;
 import org.kohsuke.args4j.spi.Setter;
+import org.kohsuke.args4j.spi.Setters;
 
 /**
  * Extended command line parser which handles --foo=value arguments.
@@ -253,6 +257,10 @@
     return findHandler(makeOption(name)) instanceof BooleanOptionHandler;
   }
 
+  public void parseWithPrefix(String prefix, Object bean) {
+    parser.parseWithPrefix(prefix, bean);
+  }
+
   private String makeOption(String name) {
     if (!name.startsWith("-")) {
       if (name.length() == 1) {
@@ -313,6 +321,70 @@
     throw new CmdLineException(parser, String.format("invalid boolean \"%s=%s\"", name, value));
   }
 
+  private static class PrefixedOption implements Option {
+    String prefix;
+    Option o;
+
+    PrefixedOption(String prefix, Option o) {
+      this.prefix = prefix;
+      this.o = o;
+    }
+
+    @Override
+    public String name() {
+      return getPrefixedName(prefix, o.name());
+    }
+
+    @Override
+    public String[] aliases() {
+      String[] prefixedAliases = new String[o.aliases().length];
+      for (int i = 0; i < prefixedAliases.length; i++) {
+        prefixedAliases[i] = getPrefixedName(prefix, o.aliases()[i]);
+      }
+      return prefixedAliases;
+    }
+
+    @Override
+    public String usage() {
+      return o.usage();
+    }
+
+    @Override
+    public String metaVar() {
+      return o.metaVar();
+    }
+
+    @Override
+    public boolean required() {
+      return o.required();
+    }
+
+    @Override
+    public boolean hidden() {
+      return o.hidden();
+    }
+
+    @SuppressWarnings("rawtypes")
+    @Override
+    public Class<? extends OptionHandler> handler() {
+      return o.handler();
+    }
+
+    @Override
+    public String[] depends() {
+      return o.depends();
+    }
+
+    @Override
+    public Class<? extends Annotation> annotationType() {
+      return o.annotationType();
+    }
+
+    private static String getPrefixedName(String prefix, String name) {
+      return "--" + prefix + name;
+    }
+  }
+
   private class MyParser extends org.kohsuke.args4j.CmdLineParser {
     @SuppressWarnings("rawtypes")
     private List<OptionHandler> optionsList;
@@ -324,6 +396,25 @@
       ensureOptionsInitialized();
     }
 
+    // NOTE: Argument annotations on bean are ignored.
+    public void parseWithPrefix(String prefix, Object bean) {
+      // recursively process all the methods/fields.
+      for (Class<?> c = bean.getClass(); c != null; c = c.getSuperclass()) {
+        for (Method m : c.getDeclaredMethods()) {
+          Option o = m.getAnnotation(Option.class);
+          if (o != null) {
+            addOption(new MethodSetter(this, bean, m), new PrefixedOption(prefix, o));
+          }
+        }
+        for (Field f : c.getDeclaredFields()) {
+          Option o = f.getAnnotation(Option.class);
+          if (o != null) {
+            addOption(Setters.create(f, bean), new PrefixedOption(prefix, o));
+          }
+        }
+      }
+    }
+
     @SuppressWarnings({"unchecked", "rawtypes"})
     @Override
     protected OptionHandler createOptionHandler(final OptionDef option, final Setter setter) {
diff --git a/gerrit-war/BUILD b/gerrit-war/BUILD
index 66a0a47..865f940 100644
--- a/gerrit-war/BUILD
+++ b/gerrit-war/BUILD
@@ -18,8 +18,8 @@
         "//gerrit-pgm:init-api",
         "//gerrit-pgm:util",
         "//gerrit-reviewdb:server",
+        "//gerrit-server:prolog-common",
         "//gerrit-server:server",
-        "//gerrit-server/src/main/prolog:common",
         "//gerrit-sshd:sshd",
         "//lib:guava",
         "//lib:gwtorm",
diff --git a/gerrit-war/pom.xml b/gerrit-war/pom.xml
index 2dcf3c1..f3dec88 100644
--- a/gerrit-war/pom.xml
+++ b/gerrit-war/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>2.14.1-SNAPSHOT</version>
+  <version>2.15-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java b/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
index a4e8e3c..f5cd9fc 100644
--- a/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
+++ b/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
@@ -62,6 +62,7 @@
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
 import com.google.gerrit.server.plugins.PluginRestApiModule;
+import com.google.gerrit.server.project.DefaultPermissionBackendModule;
 import com.google.gerrit.server.schema.DataSourceModule;
 import com.google.gerrit.server.schema.DataSourceProvider;
 import com.google.gerrit.server.schema.DataSourceType;
@@ -317,6 +318,7 @@
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new SearchingChangeCacheImpl.Module());
     modules.add(new InternalAccountDirectory.Module());
+    modules.add(new DefaultPermissionBackendModule());
     modules.add(new DefaultCacheFactory.Module());
     modules.add(cfgInjector.getInstance(MailReceiver.Module.class));
     modules.add(new SmtpEmailSender.Module());
diff --git a/lib/elasticsearch/BUILD b/lib/elasticsearch/BUILD
index 8dc4bce..c40925e 100644
--- a/lib/elasticsearch/BUILD
+++ b/lib/elasticsearch/BUILD
@@ -26,9 +26,6 @@
     ],
 )
 
-# Java REST client for Elasticsearch.
-VERSION = "0.1.7"
-
 java_library(
     name = "jest-common",
     data = ["//lib:LICENSE-Apache2.0"],
diff --git a/lib/jgit/jgit.bzl b/lib/jgit/jgit.bzl
index 502c060..ba85594 100644
--- a/lib/jgit/jgit.bzl
+++ b/lib/jgit/jgit.bzl
@@ -1,6 +1,6 @@
 load("//tools/bzl:maven_jar.bzl", "GERRIT", "MAVEN_LOCAL", "MAVEN_CENTRAL", "maven_jar")
 
-_JGIT_VERS = "4.7.0.201704051617-r"
+_JGIT_VERS = "4.8.0.201705170830-rc1"
 
 _DOC_VERS = _JGIT_VERS # Set to _JGIT_VERS unless using a snapshot
 
@@ -26,28 +26,28 @@
         name = "jgit_lib",
         artifact = "org.eclipse.jgit:org.eclipse.jgit:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "99be65d1827276b97d4f51668b60f4a38f282bda",
-        src_sha1 = "de519d6f352aaf12e4c65f7590591326ac24d2e8",
+        sha1 = "f1a2099e87cb57525233a5e882b4e35b5a2803dc",
+        src_sha1 = "1646655e3ed54c01dfa31881dd6e7fea5ec74dd2",
         unsign = True,
     )
     maven_jar(
         name = "jgit_servlet",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.http.server:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "72fa98ebf001aadd3dcb99ca8f7fcd90983da56b",
+        sha1 = "4613ff060415639f53fbed0f703f1e81688a9ac5",
         unsign = True,
     )
     maven_jar(
         name = "jgit_archive",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.archive:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "f825504a903dfe8d3daa61d6ab5c26fbad92c954",
+        sha1 = "8fd983244622c9085f74cec4361761119c7093fe",
     )
     maven_jar(
         name = "jgit_junit",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.junit:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "e0dbc6d3568b2ba65c9421af2f06e4158a624bcb",
+        sha1 = "d50f32f886cd4dfe2590dec9631578660c672df5",
         unsign = True,
     )
 
diff --git a/lib/js/bower_archives.bzl b/lib/js/bower_archives.bzl
index 67cc7c0..9b96521 100644
--- a/lib/js/bower_archives.bzl
+++ b/lib/js/bower_archives.bzl
@@ -10,8 +10,8 @@
   bower_archive(
     name = "accessibility-developer-tools",
     package = "accessibility-developer-tools",
-    version = "2.11.0",
-    sha1 = "792cb24b649dafb316e7e536f8ae65d0d7b52bab")
+    version = "2.12.0",
+    sha1 = "88ae82dcdeb6c658f76eff509d0ee425cae14d49")
   bower_archive(
     name = "async",
     package = "async",
@@ -40,13 +40,13 @@
   bower_archive(
     name = "iron-fit-behavior",
     package = "iron-fit-behavior",
-    version = "1.2.5",
-    sha1 = "5938815cd227843fc77ebeac480b999600a76157")
+    version = "1.2.6",
+    sha1 = "59daa8526aac59aa72b8edcbbd24d9eed555a0f5")
   bower_archive(
     name = "iron-flex-layout",
     package = "iron-flex-layout",
-    version = "1.3.1",
-    sha1 = "ba696394abff5e799fc06eb11bff4720129a1b52")
+    version = "1.3.2",
+    sha1 = "b896041aad049a5e889a0165828d7b1262e32612")
   bower_archive(
     name = "iron-form-element-behavior",
     package = "iron-form-element-behavior",
@@ -105,5 +105,5 @@
   bower_archive(
     name = "webcomponentsjs",
     package = "webcomponentsjs",
-    version = "0.7.23",
-    sha1 = "3d62269e614175573b0a0f3039aab05d40f0a763")
+    version = "0.7.24",
+    sha1 = "559227f8ee9db9bfbd81989f24510cc0c1bfc65c")
diff --git a/lib/prolog/prolog.bzl b/lib/prolog/prolog.bzl
index 0e87c01..43a8bab 100644
--- a/lib/prolog/prolog.bzl
+++ b/lib/prolog/prolog.bzl
@@ -12,14 +12,12 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-load("//tools/bzl:genrule2.bzl", "genrule2")
-
 def prolog_cafe_library(
     name,
     srcs,
     deps = [],
     **kwargs):
-  genrule2(
+  native.genrule(
     name = name + '__pl2j',
     cmd = '$(location //lib/prolog:compiler_bin) ' +
       '$$(dirname $@) $@ ' +
diff --git a/plugins/download-commands b/plugins/download-commands
index 8357e94..6ee2462 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit 8357e942dd9da82884a4e1b6e4697479153d0496
+Subproject commit 6ee246245b9200062e753d1c6943d5782cb7fee0
diff --git a/plugins/replication b/plugins/replication
index dc18cb6..db4aecb 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit dc18cb665eb452d71602e1a980d7669a67265dfc
+Subproject commit db4aecb2b813e19007b8896a35ac68e794f758a3
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index 90bc0ed..01f61d9 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -70,9 +70,10 @@
 that serves PolyGerrit:
 
 ```sh
-bazel build polygerrit && \
-java -jar bazel-bin/polygerrit.war daemon --polygerrit-dev \
--d ../gerrit_testsite --console-log --show-stack-trace
+bazel build polygerrit &&
+  $(bazel info output_base)/external/local_jdk/bin/java \
+  -jar bazel-bin/polygerrit.war daemon --polygerrit-dev \
+  -d ../gerrit_testsite --console-log --show-stack-trace
 ```
 
 ## Running Tests
@@ -96,6 +97,11 @@
 ./polygerrit-ui/app/run_test.sh
 ```
 
+To allow the tests to run in Safari:
+
+* In the Advanced preferences tab, check "Show Develop menu in menu bar".
+* In the Develop menu, enable the "Allow Remote Automation" option.
+
 If you need to pass additional arguments to `wct`:
 
 ```sh
@@ -114,3 +120,45 @@
 
 We follow the [Google JavaScript Style Guide](https://google.github.io/styleguide/javascriptguide.xml)
 with a few exceptions. When in doubt, remain consistent with the code around you.
+
+In addition, we encourage the use of [ESLint](http://eslint.org/).
+It is available as a command line utility, as well as a plugin for most editors
+and IDEs. It, along with a few dependencies, can also be installed through NPM:
+
+```sh
+sudo npm install -g eslint eslint-config-google eslint-plugin-html
+```
+
+`eslint-config-google` is a port of the Google JS Style Guide to an ESLint
+config module, and `eslint-plugin-html` allows ESLint to lint scripts inside
+HTML.
+We have an .eslintrc.json config file in the polygerrit-ui/ directory configured
+to enforce the preferred style of the PolyGerrit project.
+After installing, you can use `eslint` on any new file you create.
+In addition, you can supply the `--fix` flag to apply some suggested fixes for
+simple style issues.
+If you modify JS inside of `<script>` tags, like for test suites, you may have
+to supply the `--ext .html` flag.
+
+Some useful commands:
+
+* To run ESLint on the whole app, less some dependency code:
+`eslint --ignore-pattern 'bower_components/' --ignore-pattern 'gr-linked-text' --ignore-pattern 'scripts/vendor' --ext .html,.js polygerrit-ui/app`
+* To run ESLint on just the subdirectory you modified:
+`eslint --ext .html,.js polygerrit-ui/app/$YOUR_DIR_HERE`
+* To run the linter on all of your local changes:
+`git diff --name-only master | xargs eslint --ext .html,.js`
+
+We also use the polylint tool to lint use of Polymer. To install polylint,
+execute the following command.
+
+```sh
+npm install -g polylint
+```
+
+To run polylint, execute the following command.
+
+```sh
+bazel test //polygerrit-ui/app:polylint_test
+```
+
diff --git a/polygerrit-ui/app/.eslintrc.json b/polygerrit-ui/app/.eslintrc.json
new file mode 100644
index 0000000..0329650
--- /dev/null
+++ b/polygerrit-ui/app/.eslintrc.json
@@ -0,0 +1,59 @@
+{
+  "extends": ["eslint:recommended", "google"],
+  "installedESLint": true,
+  "env": {
+    "browser": true,
+    "es6": true
+  },
+  "globals": {
+    "__dirname": false,
+    "app": false,
+    "page": false,
+    "Polymer": false,
+    "process": false,
+    "require": false,
+    "Gerrit": false,
+    "Promise": false,
+    "assert": false,
+    "test": false,
+    "flushAsynchronousOperations": false
+  },
+  "rules": {
+    "arrow-parens": ["error", "as-needed"],
+    "brace-style": ["error", "1tbs", { "allowSingleLine": true }],
+    "camelcase": "off",
+    "comma-dangle": ["error", "always-multiline"],
+    "eol-last": "off",
+    "indent": ["error", 2, {
+      "MemberExpression": 2,
+      "FunctionDeclaration": {"body": 1, "parameters": 2},
+      "FunctionExpression": {"body": 1, "parameters": 2},
+      "CallExpression": {"arguments": 2},
+      "ArrayExpression": 1,
+      "ObjectExpression": 1,
+      "SwitchCase": 1
+    }],
+    "max-len": [
+      "error",
+      80,
+      2,
+      {"ignoreComments": true}
+    ],
+    "new-cap": ["error", { "capIsNewExceptions": ["Polymer"] }],
+    "no-console": "off",
+    "no-undef": "off",
+    "no-var": "error",
+    "object-shorthand": ["error", "always"],
+    "prefer-arrow-callback": "error",
+    "prefer-const": "error",
+    "prefer-spread": "error",
+    "quote-props": ["error", "consistent-as-needed"],
+    "require-jsdoc": "off",
+    "semi": [2, "always"],
+    "template-curly-spacing": "error",
+    "valid-jsdoc": "off"
+  },
+  "plugins": [
+    "html"
+  ]
+}
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index abfb8f8..79ede8f 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -129,6 +129,16 @@
     ),
 )
 
+filegroup(
+    name = "bower_components",
+    srcs = glob(
+        [
+            "bower_components/**/*.html",
+            "bower_components/**/*.js",
+        ]
+    ),
+)
+
 genrule2(
     name = "pg_code_zip",
     srcs = [":pg_code"],
@@ -157,3 +167,33 @@
         "manual",
     ],
 )
+
+sh_test(
+    name = "lint_test",
+    size = "large",
+    srcs = ["lint_test.sh"],
+    data = [
+        ":pg_code",
+        ".eslintrc.json",
+    ],
+    # Should not run sandboxed.
+    tags = [
+        "local",
+        "manual",
+    ],
+)
+
+sh_test(
+    name = "polylint_test",
+    size = "large",
+    srcs = ["polylint_test.sh"],
+    data = [
+        ":pg_code",
+        "//polygerrit-ui:polygerrit_components.bower_components.zip",
+    ],
+    # Should not run sandboxed.
+    tags = [
+        "local",
+        "manual",
+    ],
+)
diff --git a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html
index 2ec8538..2174fc6 100644
--- a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html
+++ b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html
@@ -19,14 +19,14 @@
   'use strict';
 
   /** @polymerBehavior Gerrit.BaseUrlBehavior */
-  var BaseUrlBehavior = {
-    getBaseUrl: function() {
+  const BaseUrlBehavior = {
+    getBaseUrl() {
       return window.CANONICAL_PATH || '';
     },
 
-    computeGwtUrl: function(path) {
-      var base = this.getBaseUrl();
-      var clientPath = path.substring(base.length);
+    computeGwtUrl(path) {
+      const base = this.getBaseUrl();
+      const clientPath = path.substring(base.length);
       return base + '/?polygerrit=0#' + clientPath;
     },
   };
diff --git a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
index 1e277bc..a42d804 100644
--- a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
@@ -23,6 +23,7 @@
 
 <link rel="import" href="../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <script>
+  /** @type {String} */
   window.CANONICAL_PATH = '/r';
 </script>
 <link rel="import" href="base-url-behavior.html">
@@ -42,11 +43,12 @@
 </test-fixture>
 
 <script>
-  suite('base-url-behavior tests', function() {
-    var element;
-    var overlay;
+  suite('base-url-behavior tests', () => {
+    let element;
+    // eslint-disable-next-line no-unused-vars
+    let overlay;
 
-    suiteSetup(function() {
+    suiteSetup(() => {
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element',
@@ -56,20 +58,20 @@
       });
     });
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       overlay = fixture('within-overlay');
     });
 
-    test('getBaseUrl', function() {
+    test('getBaseUrl', () => {
       assert.deepEqual(element.getBaseUrl(), '/r');
     });
 
-    test('computeGwtUrl', function() {
+    test('computeGwtUrl', () => {
       assert.deepEqual(
-        element.computeGwtUrl('/r/c/1/'),
-        '/r/?polygerrit=0#/c/1/'
+          element.computeGwtUrl('/r/c/1/'),
+          '/r/?polygerrit=0#/c/1/'
       );
     });
   });
-</script>
\ No newline at end of file
+</script>
diff --git a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.html b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.html
index ca955b3..178dd7c 100644
--- a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.html
@@ -18,7 +18,7 @@
   'use strict';
 
   /** @polymerBehavior Gerrit.ChangeTableBehavior */
-  var ChangeTableBehavior = {
+  const ChangeTableBehavior = {
     properties: {
       columnNames: {
         type: Array,
@@ -32,21 +32,21 @@
           'Size',
         ],
         readOnly: true,
-      }
+      },
     },
 
     /**
      * Returns the complement to the given column array
      * @param {Array} columns
      */
-    getComplementColumns: function(columns) {
-      return this.columnNames.filter(function(column) {
-        return columns.indexOf(column) === -1;
+    getComplementColumns(columns) {
+      return this.columnNames.filter(column => {
+        return !columns.includes(column);
       });
     },
 
-    isColumnHidden: function(columnToCheck, columnsToDisplay) {
-      return columnsToDisplay.indexOf(columnToCheck) === -1;
+    isColumnHidden(columnToCheck, columnsToDisplay) {
+      return !columnsToDisplay.includes(columnToCheck);
     },
   };
 
diff --git a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html
index 42ea615..65908f2 100644
--- a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html
@@ -39,11 +39,12 @@
 </test-fixture>
 
 <script>
-  suite('gr-change-table-behavior tests', function() {
-    var element;
-    var overlay;
+  suite('gr-change-table-behavior tests', () => {
+    let element;
+    // eslint-disable-next-line no-unused-vars
+    let overlay;
 
-    suiteSetup(function() {
+    suiteSetup(() => {
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element',
@@ -51,13 +52,13 @@
       });
     });
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       overlay = fixture('within-overlay');
     });
 
-    test('getComplementColumns', function() {
-      var columns = [
+    test('getComplementColumns', () => {
+      let columns = [
         'Subject',
         'Status',
         'Owner',
@@ -79,9 +80,9 @@
           ['Owner', 'Updated']);
     });
 
-    test('isColumnHidden', function() {
-      var columnToCheck = 'Project';
-      var columnsToDisplay = [
+    test('isColumnHidden', () => {
+      const columnToCheck = 'Project';
+      let columnsToDisplay = [
         'Subject',
         'Status',
         'Owner',
@@ -92,7 +93,7 @@
       ];
       assert.isFalse(element.isColumnHidden(columnToCheck, columnsToDisplay));
 
-      var columnsToDisplay = [
+      columnsToDisplay = [
         'Subject',
         'Status',
         'Owner',
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
index acf3a62..b0f40a9d 100644
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
@@ -17,8 +17,19 @@
 (function(window) {
   'use strict';
 
+  // Tags identifying ChangeMessages that move change into WIP state.
+  const WIP_TAGS = [
+    'autogenerated:gerrit:newWipPatchSet',
+    'autogenerated:gerrit:setWorkInProgress',
+  ];
+
+  // Tags identifying ChangeMessages that move change out of WIP state.
+  const READY_TAGS = [
+    'autogenerated:gerrit:setReadyForReview',
+  ];
+
   /** @polymerBehavior Gerrit.PatchSetBehavior */
-  var PatchSetBehavior = {
+  const PatchSetBehavior = {
     /**
      * Given an object of revisions, get a particular revision based on patch
      * num.
@@ -27,15 +38,100 @@
      * @param {number|string} patchNum The number index of the revision
      * @return {Object} The correspondent revision obj from {revisions}
      */
-    getRevisionByPatchNum: function(revisions, patchNum) {
+    getRevisionByPatchNum(revisions, patchNum) {
       patchNum = parseInt(patchNum, 10);
-      for (var rev in revisions) {
+      for (const rev in revisions) {
         if (revisions.hasOwnProperty(rev) &&
             revisions[rev]._number === patchNum) {
           return revisions[rev];
         }
       }
     },
+
+    /**
+     * Construct a chronological list of patch sets derived from change details.
+     * Each element of this list is an object with the following properties:
+     *
+     *   * num {number} The number identifying the patch set
+     *   * desc {!string} Optional patch set description
+     *   * wip {boolean} If true, this patch set was never subject to review.
+     *
+     * The wip property is determined by the change's current work_in_progress
+     * property and its log of change messages.
+     *
+     * @param {Object} change The change details
+     * @return {Array<Object>} Sorted list of patch set objects, as described
+     *     above
+     */
+    computeAllPatchSets(change) {
+      if (!change) { return []; }
+      const patchNums = [];
+      for (const commit in change.revisions) {
+        if (change.revisions.hasOwnProperty(commit)) {
+          patchNums.push({
+            num: change.revisions[commit]._number,
+            desc: change.revisions[commit].description,
+          });
+        }
+      }
+      patchNums.sort((a, b) => { return a.num - b.num; });
+      return this._computeWipForPatchSets(change, patchNums);
+    },
+
+    /**
+     * Populate the wip properties of the given list of patch sets.
+     *
+     * @param {Object} change The change details
+     * @param {Array<Object>} patchNums Sorted list of patch set objects, as
+     *     generated by computeAllPatchSets
+     * @return {Array<Object>} The given list of patch set objects, with the
+     *     wip property set on each of them
+     */
+    _computeWipForPatchSets(change, patchNums) {
+      if (!change.messages || !change.messages.length) {
+        return patchNums;
+      }
+      const psWip = {};
+      let wip = change.work_in_progress;
+      for (let i = 0; i < change.messages.length; i++) {
+        const msg = change.messages[i];
+        if (WIP_TAGS.includes(msg.tag)) {
+          wip = true;
+        } else if (READY_TAGS.includes(msg.tag)) {
+          wip = false;
+        }
+        if (psWip[msg._revision_number] !== false) {
+          psWip[msg._revision_number] = wip;
+        }
+      }
+
+      for (let i = 0; i < patchNums.length; i++) {
+        patchNums[i].wip = psWip[patchNums[i].num];
+      }
+      return patchNums;
+    },
+
+    computeLatestPatchNum(allPatchSets) {
+      if (!allPatchSets || !allPatchSets.length) { return undefined; }
+      return allPatchSets[allPatchSets.length - 1].num;
+    },
+
+    /**
+     * Check whether there is no newer patch than the latest patch that was
+     * available when this change was loaded.
+     * @return {Promise} A promise that yields true if the latest patch has been
+     *     loaded, and false if a newer patch has been uploaded in the meantime.
+     */
+    fetchIsLatestKnown(change, restAPI) {
+      const knownLatest = this.computeLatestPatchNum(
+          this.computeAllPatchSets(change));
+      return restAPI.getChangeDetail(change._number)
+          .then(detail => {
+            const actualLatest = this.computeLatestPatchNum(
+                this.computeAllPatchSets(detail));
+            return actualLatest <= knownLatest;
+          });
+    },
   };
 
   window.Gerrit = window.Gerrit || {};
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
index 862c734..7ebb17a 100644
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
@@ -22,10 +22,10 @@
 <link rel="import" href="gr-patch-set-behavior.html">
 
 <script>
-  suite('gr-path-list-behavior tests', function() {
-    test('getRevisionByPatchNum', function() {
-      var get = Gerrit.PatchSetBehavior.getRevisionByPatchNum;
-      var revisions = [
+  suite('gr-path-list-behavior tests', () => {
+    test('getRevisionByPatchNum', () => {
+      const get = Gerrit.PatchSetBehavior.getRevisionByPatchNum;
+      const revisions = [
         {_number: 0},
         {_number: 1},
         {_number: 2},
@@ -34,5 +34,132 @@
       assert.deepEqual(get(revisions, 2), revisions[2]);
       assert.equal(get(revisions, '3'), undefined);
     });
+
+    test('fetchIsLatestKnown on latest', done => {
+      const knownChange = {
+        revisions: {
+          sha1: {description: 'patch 1', _number: 1},
+          sha2: {description: 'patch 2', _number: 2},
+        },
+      };
+      const mockRestApi = {
+        getChangeDetail() {
+          return Promise.resolve(knownChange);
+        },
+      };
+      Gerrit.PatchSetBehavior.fetchIsLatestKnown(knownChange, mockRestApi)
+          .then(isLatest => {
+            assert.isTrue(isLatest);
+            done();
+          });
+    });
+
+    test('fetchIsLatestKnown not on latest', done => {
+      const knownChange = {
+        revisions: {
+          sha1: {description: 'patch 1', _number: 1},
+          sha2: {description: 'patch 2', _number: 2},
+        },
+      };
+      const actualChange = {
+        revisions: {
+          sha1: {description: 'patch 1', _number: 1},
+          sha2: {description: 'patch 2', _number: 2},
+          sha3: {description: 'patch 3', _number: 3},
+        },
+      };
+      const mockRestApi = {
+        getChangeDetail() {
+          return Promise.resolve(actualChange);
+        },
+      };
+      Gerrit.PatchSetBehavior.fetchIsLatestKnown(knownChange, mockRestApi)
+          .then(isLatest => {
+            assert.isFalse(isLatest);
+            done();
+          });
+    });
+
+    test('_computeWipForPatchSets', () => {
+      // Compute patch sets for a given timeline on a change. The initial WIP
+      // property of the change can be true or false. The map of tags by
+      // revision is keyed by patch set number. Each value is a list of change
+      // message tags in the order that they occurred in the timeline. These
+      // indicate actions that modify the WIP property of the change and/or
+      // create new patch sets.
+      //
+      // Returns the actual results with an assertWip method that can be used
+      // to compare against an expected value for a particular patch set.
+      const compute = (initialWip, tagsByRevision) => {
+        const change = {
+          messages: [],
+          work_in_progress: initialWip,
+        };
+        const revs = Object.keys(tagsByRevision).sort((a, b) => {
+          return a - b;
+        });
+        for (const rev of revs) {
+          for (const tag of tagsByRevision[rev]) {
+            change.messages.push({
+              tag,
+              _revision_number: rev,
+            });
+          }
+        }
+        let patchNums = revs.map(rev => { return {num: rev}; });
+        patchNums = Gerrit.PatchSetBehavior._computeWipForPatchSets(
+            change, patchNums);
+        const actualWipsByRevision = {};
+        for (const patchNum of patchNums) {
+          actualWipsByRevision[patchNum.num] = patchNum.wip;
+        }
+        const verifier = {
+          assertWip(revision, expectedWip) {
+            const patchNum = patchNums.find(patchNum => {
+              return patchNum.num == revision;
+            });
+            if (!patchNum) {
+              assert.fail('revision ' + revision + ' not found');
+            }
+            assert.equal(patchNum.wip, expectedWip,
+                'wip state for ' + revision + ' is ' +
+              patchNum.wip + '; expected ' + expectedWip);
+            return verifier;
+          },
+        };
+        return verifier;
+      };
+
+      compute(false, {1: ['upload']}).assertWip(1, false);
+      compute(true, {1: ['upload']}).assertWip(1, true);
+
+      const setWip = 'autogenerated:gerrit:setWorkInProgress';
+      const uploadInWip = 'autogenerated:gerrit:newWipPatchSet';
+      const clearWip = 'autogenerated:gerrit:setReadyForReview';
+
+      compute(false, {
+        1: ['upload', setWip],
+        2: ['upload'],
+        3: ['upload', clearWip],
+        4: ['upload', setWip],
+      }).assertWip(1, false)  // Change was created with PS1 ready for review
+          .assertWip(2, true)   // PS2 was uploaded during WIP
+          .assertWip(3, false)  // PS3 was marked ready for review after upload
+          .assertWip(4, false); // PS4 was uploaded ready for review
+
+      compute(false, {
+        1: [uploadInWip, null, 'addReviewer'],
+        2: ['upload'],
+        3: ['upload', clearWip, setWip],
+        4: ['upload'],
+        5: ['upload', clearWip],
+        6: [uploadInWip],
+      }).assertWip(1, true)  // Change was created in WIP
+          .assertWip(2, true)  // PS2 was uploaded during WIP
+          .assertWip(3, false) // PS3 was marked ready for review
+          .assertWip(4, true)  // PS4 was uploaded during WIP
+          .assertWip(5, false) // PS5 was marked ready for review
+          .assertWip(6, true); // PS6 was uploaded with WIP option
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.html b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.html
index f9c4a80..2ccae2c 100644
--- a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.html
@@ -18,10 +18,10 @@
   'use strict';
 
   /** @polymerBehavior Gerrit.PathListBehavior */
-  var PathListBehavior = {
-    specialFilePathCompare: function(a, b) {
+  const PathListBehavior = {
+    specialFilePathCompare(a, b) {
       // The commit message always goes first.
-      var COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
+      const COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
       if (a === COMMIT_MESSAGE_PATH) {
         return -1;
       }
@@ -30,7 +30,7 @@
       }
 
       // The merge list always comes next.
-      var MERGE_LIST_PATH = '/MERGE_LIST';
+      const MERGE_LIST_PATH = '/MERGE_LIST';
       if (a === MERGE_LIST_PATH) {
         return -1;
       }
@@ -38,25 +38,24 @@
         return 1;
       }
 
-      var aLastDotIndex = a.lastIndexOf('.');
-      var aExt = a.substr(aLastDotIndex + 1);
-      var aFile = a.substr(0, aLastDotIndex) || a;
+      const aLastDotIndex = a.lastIndexOf('.');
+      const aExt = a.substr(aLastDotIndex + 1);
+      const aFile = a.substr(0, aLastDotIndex) || a;
 
-      var bLastDotIndex = b.lastIndexOf('.');
-      var bExt = b.substr(bLastDotIndex + 1);
-      var bFile = b.substr(0, bLastDotIndex) || b;
+      const bLastDotIndex = b.lastIndexOf('.');
+      const bExt = b.substr(bLastDotIndex + 1);
+      const bFile = b.substr(0, bLastDotIndex) || b;
 
       // Sort header files above others with the same base name.
-      var headerExts = ['h', 'hxx', 'hpp'];
+      const headerExts = ['h', 'hxx', 'hpp'];
       if (aFile.length > 0 && aFile === bFile) {
-        if (headerExts.indexOf(aExt) !== -1 &&
-            headerExts.indexOf(bExt) !== -1) {
+        if (headerExts.includes(aExt) && headerExts.includes(bExt)) {
           return a.localeCompare(b);
         }
-        if (headerExts.indexOf(aExt) !== -1) {
+        if (headerExts.includes(aExt)) {
           return -1;
         }
-        if (headerExts.indexOf(bExt) !== -1) {
+        if (headerExts.includes(bExt)) {
           return 1;
         }
       }
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
index 2b42587..848c744 100644
--- 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
@@ -22,19 +22,27 @@
 <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 = [
+  suite('gr-path-list-behavior tests', () => {
+    test('special sort', () => {
+      const sort = Gerrit.PathListBehavior.specialFilePathCompare;
+      const testFiles = [
         '/a.h',
         '/MERGE_LIST',
         '/a.cpp',
         '/COMMIT_MSG',
         '/asdasd',
-        '/mrPeanutbutter.py'
+        '/mrPeanutbutter.py',
       ];
-      assert.deepEqual(testFiles.sort(sort),
-          ['/COMMIT_MSG', '/MERGE_LIST', '/a.h', '/a.cpp', '/asdasd', '/mrPeanutbutter.py']);
+      assert.deepEqual(
+          testFiles.sort(sort),
+          [
+            '/COMMIT_MSG',
+            '/MERGE_LIST',
+            '/a.h',
+            '/a.cpp',
+            '/asdasd',
+            '/mrPeanutbutter.py',
+          ]);
     });
   });
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html
index 6b35328..3e9e19e 100644
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html
@@ -15,4 +15,6 @@
 -->
 <link rel="import" href="../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../elements/shared/gr-tooltip/gr-tooltip.html">
+<script src="../../scripts/rootElement.js"></script>
+
 <script src="gr-tooltip-behavior.js"></script>
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
index e4c4d11..54a59ca 100644
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
+++ b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
@@ -14,40 +14,47 @@
 (function(window) {
   'use strict';
 
-  var BOTTOM_OFFSET = 7.2; // Height of the arrow in tooltip.
+  const BOTTOM_OFFSET = 7.2; // Height of the arrow in tooltip.
 
   /** @polymerBehavior Gerrit.TooltipBehavior */
-  var TooltipBehavior = {
+  const TooltipBehavior = {
 
     properties: {
-      hasTooltip: Boolean,
+      hasTooltip: {
+        type: Boolean,
+        observer: '_setupTooltipListeners',
+      },
 
       _isTouchDevice: {
         type: Boolean,
-        value: function() {
+        value() {
           return 'ontouchstart' in document.documentElement;
         },
       },
       _tooltip: Element,
       _titleText: String,
+      _hasSetupTooltipListeners: {
+        type: Boolean,
+        value: false,
+      },
     },
 
-    attached: function() {
-      if (!this.hasTooltip) { return; }
-
-      this.addEventListener('mouseenter', this._handleShowTooltip.bind(this));
-      this.addEventListener('mouseleave', this._handleHideTooltip.bind(this));
-      this.addEventListener('tap', this._handleHideTooltip.bind(this));
-
-      this.listen(window, 'scroll', '_handleWindowScroll');
-    },
-
-    detached: function() {
+    detached() {
       this._handleHideTooltip();
       this.unlisten(window, 'scroll', '_handleWindowScroll');
     },
 
-    _handleShowTooltip: function(e) {
+    _setupTooltipListeners() {
+      if (this._hasSetupTooltipListeners || !this.hasTooltip) { return; }
+      this._hasSetupTooltipListeners = true;
+
+      this.addEventListener('mouseenter', this._handleShowTooltip.bind(this));
+      this.addEventListener('mouseleave', this._handleHideTooltip.bind(this));
+      this.addEventListener('tap', this._handleHideTooltip.bind(this));
+      this.listen(window, 'scroll', '_handleWindowScroll');
+    },
+
+    _handleShowTooltip(e) {
       if (this._isTouchDevice) { return; }
 
       if (!this.hasAttribute('title') ||
@@ -61,21 +68,21 @@
       this._titleText = this.getAttribute('title');
       this.setAttribute('title', '');
 
-      var tooltip = document.createElement('gr-tooltip');
+      const tooltip = document.createElement('gr-tooltip');
       tooltip.text = this._titleText;
       tooltip.maxWidth = this.getAttribute('max-width');
 
       // Set visibility to hidden before appending to the DOM so that
       // calculations can be made based on the element’s size.
       tooltip.style.visibility = 'hidden';
-      Polymer.dom(document.body).appendChild(tooltip);
+      Gerrit.getRootElement().appendChild(tooltip);
       this._positionTooltip(tooltip);
       tooltip.style.visibility = null;
 
       this._tooltip = tooltip;
     },
 
-    _handleHideTooltip: function(e) {
+    _handleHideTooltip(e) {
       if (this._isTouchDevice) { return; }
       if (!this.hasAttribute('title') ||
           this._titleText == null) {
@@ -89,19 +96,20 @@
       this._tooltip = null;
     },
 
-    _handleWindowScroll: function(e) {
+    _handleWindowScroll(e) {
       if (!this._tooltip) { return; }
 
       this._positionTooltip(this._tooltip);
     },
 
-    _positionTooltip: function(tooltip) {
-      var rect = this.getBoundingClientRect();
-      var boxRect = tooltip.getBoundingClientRect();
-      var parentRect = tooltip.parentElement.getBoundingClientRect();
-      var top = rect.top - parentRect.top;
-      var left = rect.left - parentRect.left + (rect.width - boxRect.width) / 2;
-      var right = parentRect.width - left - boxRect.width;
+    _positionTooltip(tooltip) {
+      const rect = this.getBoundingClientRect();
+      const boxRect = tooltip.getBoundingClientRect();
+      const parentRect = tooltip.parentElement.getBoundingClientRect();
+      const top = rect.top - parentRect.top;
+      const left =
+          rect.left - parentRect.left + (rect.width - boxRect.width) / 2;
+      const right = parentRect.width - left - boxRect.width;
       if (left < 0) {
         tooltip.updateStyles({
           '--gr-tooltip-arrow-center-offset': left + 'px',
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
index 99bfc03..5fc9d54 100644
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
@@ -32,22 +32,22 @@
 </test-fixture>
 
 <script>
-  suite('gr-tooltip-behavior tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-tooltip-behavior tests', () => {
+    let element;
+    let sandbox;
 
     function makeTooltip(tooltipRect, parentRect) {
       return {
-        getBoundingClientRect: function() { return tooltipRect; },
+        getBoundingClientRect() { return tooltipRect; },
         updateStyles: sinon.stub(),
         style: {left: 0, top: 0},
         parentElement: {
-          getBoundingClientRect: function() { return parentRect; },
+          getBoundingClientRect() { return parentRect; },
         },
       };
     }
 
-    suiteSetup(function() {
+    suiteSetup(() => {
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'tooltip-behavior-element',
@@ -55,20 +55,20 @@
       });
     });
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
       element = fixture('basic');
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('normal position', function() {
-      sandbox.stub(element, 'getBoundingClientRect', function() {
+    test('normal position', () => {
+      sandbox.stub(element, 'getBoundingClientRect', () => {
         return {top: 100, left: 100, width: 200};
       });
-      var tooltip = makeTooltip(
+      const tooltip = makeTooltip(
           {height: 30, width: 50},
           {top: 0, left: 0, width: 1000});
 
@@ -78,45 +78,51 @@
       assert.equal(tooltip.style.top, '100px');
     });
 
-    test('left side position', function() {
-      sandbox.stub(element, 'getBoundingClientRect', function() {
+    test('left side position', () => {
+      sandbox.stub(element, 'getBoundingClientRect', () => {
         return {top: 100, left: 10, width: 50};
       });
-      var tooltip = makeTooltip(
+      const tooltip = makeTooltip(
           {height: 30, width: 120},
           {top: 0, left: 0, width: 1000});
 
       element._positionTooltip(tooltip);
       assert.isTrue(tooltip.updateStyles.called);
-      var offset = tooltip.updateStyles
+      const offset = tooltip.updateStyles
           .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
       assert.isBelow(parseFloat(offset.replace(/px$/, '')), 0);
       assert.equal(tooltip.style.left, '0px');
       assert.equal(tooltip.style.top, '100px');
     });
 
-    test('right side position', function() {
-      sandbox.stub(element, 'getBoundingClientRect', function() {
+    test('right side position', () => {
+      sandbox.stub(element, 'getBoundingClientRect', () => {
         return {top: 100, left: 950, width: 50};
       });
-      var tooltip = makeTooltip(
+      const tooltip = makeTooltip(
           {height: 30, width: 120},
           {top: 0, left: 0, width: 1000});
 
       element._positionTooltip(tooltip);
       assert.isTrue(tooltip.updateStyles.called);
-      var offset = tooltip.updateStyles
+      const offset = tooltip.updateStyles
           .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
       assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
       assert.equal(tooltip.style.left, '915px');
       assert.equal(tooltip.style.top, '100px');
     });
 
-    test('hides tooltip when detached', function() {
+    test('hides tooltip when detached', () => {
       sandbox.stub(element, '_handleHideTooltip');
       element.remove();
       flushAsynchronousOperations();
       assert.isTrue(element._handleHideTooltip.called);
     });
+
+    test('sets up listeners when has-tooltip is changed', () => {
+      const addListenerStub = sandbox.stub(element, 'addEventListener');
+      element.hasTooltip = true;
+      assert.isTrue(addListenerStub.called);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior.html b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior.html
index b7d71fc..25d9c27 100644
--- a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior.html
@@ -18,14 +18,14 @@
   'use strict';
 
   /** @polymerBehavior Gerrit.URLEncodingBehavior */
-  var URLEncodingBehavior = {
+  const URLEncodingBehavior = {
     /**
      * Pretty-encodes a URL. Double-encodes the string, and then replaces
      *   benevolent characters for legibility.
      */
-    encodeURL: function(url, replaceSlashes) {
+    encodeURL(url, replaceSlashes) {
       // @see Issue 4255 regarding double-encoding.
-      var output = encodeURIComponent(encodeURIComponent(url));
+      let output = encodeURIComponent(encodeURIComponent(url));
       // @see Issue 4577 regarding more readable URLs.
       output = output.replace(/%253A/g, ':');
       output = output.replace(/%2520/g, '+');
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
index 3d99cec..975bb5e 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
@@ -20,28 +20,41 @@
 (function(window) {
   'use strict';
 
-  var getKeyboardEvent = function(e) {
-    return Polymer.dom(e.detail ? e.detail.keyboardEvent : e);
+  // Must be declared outside behavior implementation to be accessed inside
+  // behavior functions.
+  const getKeyboardEvent = function(e) {
+    e = Polymer.dom(e.detail ? e.detail.keyboardEvent : e);
+    // When e is a keyboardEvent, e.event is not null.
+    if (e.event) { e = e.event; }
+    return e;
   };
 
-  var KeyboardShortcutBehaviorImpl = {
-    modifierPressed: function(e) {
+  /** @polymerBehavior KeyboardShortcutBehaviorImpl */
+  const KeyboardShortcutBehaviorImpl = {
+    modifierPressed(e) {
       e = getKeyboardEvent(e);
-      // When e is a keyboardEvent, e.event is not null.
-      if (e.event) { e = e.event; }
       return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
     },
 
-    shouldSuppressKeyboardShortcut: function(e) {
+    isModifierPressed(e, modifier) {
+      return getKeyboardEvent(e)[modifier];
+    },
+
+    shouldSuppressKeyboardShortcut(e) {
       e = getKeyboardEvent(e);
       if (e.path[0].tagName === 'INPUT' || e.path[0].tagName === 'TEXTAREA') {
         return true;
       }
-      for (var i = 0; i < e.path.length; i++) {
+      for (let i = 0; i < e.path.length; i++) {
         if (e.path[i].tagName === 'GR-OVERLAY') { return true; }
       }
       return false;
     },
+
+    // Alias for getKeyboardEvent.
+    getKeyboardEvent(e) {
+      return getKeyboardEvent(e);
+    },
   };
 
   window.Gerrit = window.Gerrit || {};
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
index 9ede5d9..da04c37 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
@@ -21,7 +21,7 @@
 <script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
 <script src="../../bower_components/web-component-tester/browser.js"></script>
 
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="keyboard-shortcut-behavior.html">
 
 <test-fixture id="basic">
@@ -39,77 +39,77 @@
 </test-fixture>
 
 <script>
-  suite('keyboard-shortcut-behavior tests', function() {
-    var element;
-    var overlay;
-    var sandbox;
+  suite('keyboard-shortcut-behavior tests', () => {
+    let element;
+    let overlay;
+    let sandbox;
 
-    suiteSetup(function() {
+    suiteSetup(() => {
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element',
         behaviors: [Gerrit.KeyboardShortcutBehavior],
         keyBindings: {
-          'k': '_handleKey'
+          k: '_handleKey',
         },
-        _handleKey: function() {},
+        _handleKey() {},
       });
     });
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       overlay = fixture('within-overlay');
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('doesn’t block kb shortcuts for non-whitelisted els', function(done) {
-      var divEl = document.createElement('div');
+    test('doesn’t block kb shortcuts for non-whitelisted els', done => {
+      const divEl = document.createElement('div');
       element.appendChild(divEl);
-      element._handleKey = function(e) {
+      element._handleKey = e => {
         assert.isFalse(element.shouldSuppressKeyboardShortcut(e));
         done();
       };
       MockInteractions.keyDownOn(divEl, 75, null, 'k');
     });
 
-    test('blocks kb shortcuts for input els', function(done) {
-      var inputEl = document.createElement('input');
+    test('blocks kb shortcuts for input els', done => {
+      const inputEl = document.createElement('input');
       element.appendChild(inputEl);
-      element._handleKey = function(e) {
+      element._handleKey = e => {
         assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
         done();
       };
       MockInteractions.keyDownOn(inputEl, 75, null, 'k');
     });
 
-    test('blocks kb shortcuts for textarea els', function(done) {
-      var textareaEl = document.createElement('textarea');
+    test('blocks kb shortcuts for textarea els', done => {
+      const textareaEl = document.createElement('textarea');
       element.appendChild(textareaEl);
-      element._handleKey = function(e) {
+      element._handleKey = e => {
         assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
         done();
       };
       MockInteractions.keyDownOn(textareaEl, 75, null, 'k');
     });
 
-    test('blocks kb shortcuts for anything in a gr-overlay', function(done) {
-      var divEl = document.createElement('div');
-      var element = overlay.querySelector('test-element');
+    test('blocks kb shortcuts for anything in a gr-overlay', done => {
+      const divEl = document.createElement('div');
+      const element = overlay.querySelector('test-element');
       element.appendChild(divEl);
-      element._handleKey = function(e) {
+      element._handleKey = e => {
         assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
         done();
       };
       MockInteractions.keyDownOn(divEl, 75, null, 'k');
     });
 
-    test('modifierPressed returns accurate values', function() {
-      var spy = sandbox.spy(element, 'modifierPressed');
-      element._handleKey = function(e) {
+    test('modifierPressed returns accurate values', () => {
+      const spy = sandbox.spy(element, 'modifierPressed');
+      element._handleKey = e => {
         element.modifierPressed(e);
       };
       MockInteractions.keyDownOn(element, 75, 'shift', 'k');
@@ -127,5 +127,26 @@
       MockInteractions.keyDownOn(element, 75, 'alt', 'k');
       assert.isTrue(spy.lastCall.returnValue);
     });
+
+    test('isModifierPressed returns accurate value', () => {
+      const spy = sandbox.spy(element, 'isModifierPressed');
+      element._handleKey = e => {
+        element.isModifierPressed(e, 'shiftKey');
+      };
+      MockInteractions.keyDownOn(element, 75, 'shift', 'k');
+      assert.isTrue(spy.lastCall.returnValue);
+      MockInteractions.keyDownOn(element, 75, null, 'k');
+      assert.isFalse(spy.lastCall.returnValue);
+      MockInteractions.keyDownOn(element, 75, 'ctrl', 'k');
+      assert.isFalse(spy.lastCall.returnValue);
+      MockInteractions.keyDownOn(element, 75, null, 'k');
+      assert.isFalse(spy.lastCall.returnValue);
+      MockInteractions.keyDownOn(element, 75, 'meta', 'k');
+      assert.isFalse(spy.lastCall.returnValue);
+      MockInteractions.keyDownOn(element, 75, null, 'k');
+      assert.isFalse(spy.lastCall.returnValue);
+      MockInteractions.keyDownOn(element, 75, 'alt', 'k');
+      assert.isFalse(spy.lastCall.returnValue);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
index f71fe8f..3764628 100644
--- a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
@@ -20,7 +20,7 @@
   'use strict';
 
   /** @polymerBehavior Gerrit.RESTClientBehavior */
-  var RESTClientBehavior = {
+  const RESTClientBehavior = {
     ChangeDiffType: {
       ADDED: 'ADDED',
       COPIED: 'COPIED',
@@ -88,35 +88,35 @@
       REVIEWER_UPDATES: 19,
 
       // Set the submittable boolean.
-      SUBMITTABLE: 20
+      SUBMITTABLE: 20,
     },
 
-    listChangesOptionsToHex: function() {
-      var v = 0;
-      for (var i = 0; i < arguments.length; i++) {
-        v |= 1 << arguments[i];
+    listChangesOptionsToHex(...args) {
+      let v = 0;
+      for (let i = 0; i < args.length; i++) {
+        v |= 1 << args[i];
       }
       return v.toString(16);
     },
 
-    changeBaseURL: function(changeNum, patchNum) {
-      var v =  this.getBaseUrl() + '/changes/' + changeNum;
+    changeBaseURL(changeNum, patchNum) {
+      let v = this.getBaseUrl() + '/changes/' + changeNum;
       if (patchNum) {
         v += '/revisions/' + patchNum;
       }
       return v;
     },
 
-    changePath: function(changeNum) {
+    changePath(changeNum) {
       return this.getBaseUrl() + '/c/' + changeNum;
     },
 
-    changeIsOpen: function(status) {
+    changeIsOpen(status) {
       return status === this.ChangeStatus.NEW ||
           status === this.ChangeStatus.DRAFT;
     },
 
-    changeStatusString: function(change) {
+    changeStatusString(change) {
       // "Closed" states should take precedence over "open" ones.
       if (change.status === this.ChangeStatus.MERGED) {
         return 'Merged';
@@ -137,7 +137,7 @@
   window.Gerrit = window.Gerrit || {};
   window.Gerrit.RESTClientBehavior = [
     Gerrit.BaseUrlBehavior,
-    RESTClientBehavior
+    RESTClientBehavior,
   ];
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
index 2b3e858..7a8376e 100644
--- a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
@@ -21,6 +21,7 @@
 <script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
 <script src="../../bower_components/web-component-tester/browser.js"></script>
 <script>
+  /** @type {String} */
   window.CANONICAL_PATH = '/r';
 </script>
 
@@ -43,11 +44,12 @@
 </test-fixture>
 
 <script>
-  suite('rest-client-behavior tests', function() {
-    var element;
-    var overlay;
+  suite('rest-client-behavior tests', () => {
+    let element;
+    // eslint-disable-next-line no-unused-vars
+    let overlay;
 
-    suiteSetup(function() {
+    suiteSetup(() => {
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element',
@@ -58,20 +60,20 @@
       });
     });
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       overlay = fixture('within-overlay');
     });
 
-    test('changeBaseURL', function() {
+    test('changeBaseURL', () => {
       assert.deepEqual(
-        element.changeBaseURL('1', '1'),
-        '/r/changes/1/revisions/1'
+          element.changeBaseURL('1', '1'),
+          '/r/changes/1/revisions/1'
       );
     });
 
-    test('changePath', function() {
+    test('changePath', () => {
       assert.deepEqual(element.changePath('1'), '/r/c/1');
     });
   });
-</script>
\ No newline at end of file
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-project-list/gr-admin-project-list.html b/polygerrit-ui/app/elements/admin/gr-admin-project-list/gr-admin-project-list.html
new file mode 100644
index 0000000..9ceee68
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-project-list/gr-admin-project-list.html
@@ -0,0 +1,107 @@
+<!--
+Copyright (C) 2017 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.
+-->
+
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<link rel="import" href="../../../styles/gr-form-styles.html">
+
+<dom-module id="gr-admin-project-list">
+  <template>
+    <style>
+      :host {
+        display: flex;
+        flex-direction: column;
+      }
+      tr.project-table {
+        border-bottom: 1px solid #eee;
+      }
+      #projectList {
+        border-collapse: collapse;
+        width: 100%;
+      }
+      td {
+        flex-shrink: 0;
+        padding: .3em .5em;
+      }
+      th {
+        background-color: #ddd;
+        border-bottom: 1px solid #eee;
+        font-weight: bold;
+        padding: .3em .5em;
+        text-align: left;
+      }
+      a {
+        color: var(--default-text-color);
+        text-decoration: none;
+      }
+      a:hover {
+        text-decoration: underline;
+      }
+      nav {
+        padding: .5em 0;
+        text-align: center;
+      }
+      nav a {
+        display: inline-block;
+      }
+      nav a:first-of-type {
+        margin-right: .5em;
+      }
+      .description {
+        width: 70%;
+      }
+    </style>
+    <table id="projectList">
+      <tr class="headerRow">
+        <th class="name topHeader">Project Name</th>
+        <th class="description topHeader">Project Description</th>
+        <th class="repositoryBrowser topHeader">Repository Browser</th>
+        <th class="readOnly topHeader">Read only</th>
+      </tr>
+      <template is="dom-repeat" items="[[_shownProjects]]">
+        <tr class="project-table">
+          <td class="name">
+            <a href$="[[_getUrl(item.id)]]">[[item.name]]</a>
+          </td>
+          <td class="description">[[item.description]]</td>
+          <td class="repositoryBrowser">
+            <template is="dom-repeat"
+                items="[[_computeWeblink(item)]]" as="link">
+              <a href$="[[link.url]]" class="webLink" rel="noopener" target="_blank">
+                ([[link.name]])
+              </a>
+            </template>
+          </td>
+          <td class="readOnly">[[_readOnly(item)]]</td>
+        </tr>
+      </template>
+    </table>
+    <nav>
+      <a id="prevArrow"
+          href$="[[_computeNavLink(_offset, -1, _projectsPerPage)]]"
+          hidden$="[[_hidePrevArrow(_offset)]]" hidden>&larr; Prev</a>
+      <a id="nextArrow"
+          href$="[[_computeNavLink(_offset, 1, _projectsPerPage)]]"
+          hidden$="[[_hideNextArrow(_loading, _projects)]]" hidden>
+        Next &rarr;</a>
+    </nav>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-admin-project-list.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-project-list/gr-admin-project-list.js b/polygerrit-ui/app/elements/admin/gr-admin-project-list/gr-admin-project-list.js
new file mode 100644
index 0000000..038b41f
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-project-list/gr-admin-project-list.js
@@ -0,0 +1,148 @@
+// Copyright (C) 2017 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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-admin-project-list',
+
+    properties: {
+      /**
+       * URL params passed from the router.
+       */
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
+
+      /**
+       * Offset of currently visible query results.
+       */
+      _offset: Number,
+
+      _projects: Array,
+
+      /**
+       * Because  we request one more than the projectsPerPage, _shownProjects
+       * maybe one less than _projects.
+       * */
+      _shownProjects: {
+        type: Array,
+        computed: '_computeShownProjects(_projects)',
+      },
+
+      _projectsPerPage: {
+        type: Number,
+        value: 25,
+      },
+
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+    },
+
+    behaviors: [
+      Gerrit.BaseUrlBehavior,
+      Gerrit.URLEncodingBehavior,
+    ],
+
+    listeners: {
+      'next-page': '_handleNextPage',
+      'previous-page': '_handlePreviousPage',
+    },
+
+    _paramsChanged(value) {
+      this._loading = true;
+
+      if (value && value.offset) {
+        this._offset = value.offset;
+      } else {
+        this._offset = 0;
+      }
+
+      return this.$.restAPI.getProjects(this._projectsPerPage, this._offset)
+          .then(projects => {
+            if (!projects) {
+              this._projects = [];
+              return;
+            }
+            this._projects = Object.keys(projects)
+             .map(key => {
+               const project = projects[key];
+               project.name = key;
+               return project;
+             });
+            this._loading = false;
+          });
+    },
+
+    _readOnly(item) {
+      return item.state === 'READ_ONLY' ? 'Y' : 'N';
+    },
+
+    _getUrl(item) {
+      return this.getBaseUrl() + '/admin/projects/' +
+          this.encodeURL(item, false);
+    },
+
+
+    _computeWeblink(project) {
+      if (!project.web_links) {
+        return '';
+      }
+      const webLinks = project.web_links;
+      return webLinks.length ? webLinks : null;
+    },
+
+    _computeNavLink(offset, direction, projectsPerPage) {
+      // Offset could be a string when passed from the router.
+      offset = +(offset || 0);
+      const newOffset = Math.max(0, offset + (projectsPerPage * direction));
+      let href = this.getBaseUrl() + '/admin/projects';
+      if (newOffset > 0) {
+        href += ',' + newOffset;
+      }
+      return href;
+    },
+
+    _computeShownProjects(projects) {
+      return projects.slice(0, 25);
+    },
+
+    _hidePrevArrow(offset) {
+      return offset === 0;
+    },
+
+    _hideNextArrow(loading, projects) {
+      let lastPage = false;
+      if (projects.length < this._projectsPerPage + 1) {
+        lastPage = true;
+      }
+      return loading || lastPage || !projects || !projects.length;
+    },
+
+    _handleNextPage() {
+      if (this.$.nextArrow.hidden) { return; }
+      page.show(this._computeNavLink(
+          this._offset, 1, this._projectsPerPage));
+    },
+
+    _handlePreviousPage() {
+      if (this.$.prevArrow.hidden) { return; }
+      page.show(this._computeNavLink(
+          this._offset, -1, this._projectsPerPage));
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-project-list/gr-admin-project-list_test.html b/polygerrit-ui/app/elements/admin/gr-admin-project-list/gr-admin-project-list_test.html
new file mode 100644
index 0000000..f9e08cd
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-project-list/gr-admin-project-list_test.html
@@ -0,0 +1,139 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-admin-project-list</title>
+
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+
+<link rel="import" href="gr-admin-project-list.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-admin-project-list></gr-admin-project-list>
+  </template>
+</test-fixture>
+
+<script>
+  let counter = 0;
+  const projectGenerator = () => {
+    return {
+      id: `test${++counter}`,
+      state: 'ACTIVE',
+      web_links: [
+        {
+          name: 'diffusion',
+          url: `https://phabricator.example.org/r/project/test${counter}`,
+        },
+      ],
+    };
+  };
+
+  suite('gr-admin-project-list tests', () => {
+    let element;
+    let projects;
+    let value;
+
+    suite('list with projects', () => {
+      setup(done => {
+        projects = _.times(26, projectGenerator);
+
+        stub('gr-rest-api-interface', {
+          getProjects(num, offset) {
+            return Promise.resolve(projects);
+          },
+        });
+
+        element = fixture('basic');
+        element._paramsChanged(value).then(() => { flush(done); });
+      });
+
+      test('test for test project in the list', done => {
+        flush(() => {
+          assert.equal(element._projects[1].id, 'test2');
+          done();
+        });
+      });
+
+      test('test next button', done => {
+        flush(() => {
+          let loading;
+          assert.isFalse(element._hideNextArrow(loading, projects));
+          loading = true;
+          assert.isTrue(element._hideNextArrow(loading, projects));
+          loading = false;
+          assert.isFalse(element._hideNextArrow(loading, projects));
+          element._projects = [];
+          assert.isTrue(element._hideNextArrow(loading, element._projects));
+          projects = _.times(4, projectGenerator);
+          assert.isTrue(element._hideNextArrow(loading, projects));
+          done();
+        });
+      });
+
+      test('test for prev button', () => {
+        flush(() => {
+          let offset = 0;
+          assert.isTrue(element._hidePrevArrow(offset));
+          offset = 5;
+          assert.isFalse(element._hidePrevArrow(offset));
+        });
+      });
+
+      test('_shownProjects', () => {
+        assert.equal(element._shownProjects.length, 25);
+      });
+    });
+
+    suite('test with less then 25 projects', () => {
+      setup(done => {
+        projects = _.times(25, projectGenerator);
+
+        stub('gr-rest-api-interface', {
+          getProjects(num, offset) {
+            return Promise.resolve(projects);
+          },
+        });
+
+        element = fixture('basic');
+        element._paramsChanged(value).then(() => { flush(done); });
+      });
+
+      test('test next button', done => {
+        flush(() => {
+          let loading;
+          assert.isTrue(element._hideNextArrow(loading, projects));
+          projects = _.times(1, projectGenerator);
+          assert.isTrue(element._hideNextArrow(loading, projects));
+          projects = _.times(26, projectGenerator);
+          assert.isFalse(element._hideNextArrow(loading, projects));
+          done();
+        });
+      });
+
+      test('_shownProjects', () => {
+        assert.equal(element._shownProjects.length, 25);
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
index d50e0b3..6df33db 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
@@ -30,6 +30,9 @@
         display: table-row;
         border-bottom: 1px solid #eee;
       }
+      :host(:hover) {
+        background-color: #f5fafd;
+      }
       :host([selected]) {
         background-color: #ebf5fb;
       }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
index 566dfe0..1616e1f 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
@@ -40,15 +40,15 @@
       Gerrit.URLEncodingBehavior,
     ],
 
-    _computeChangeURL: function(changeNum) {
+    _computeChangeURL(changeNum) {
       if (!changeNum) { return ''; }
       return this.getBaseUrl() + '/c/' + changeNum + '/';
     },
 
-    _computeLabelTitle: function(change, labelName) {
-      var label = change.labels[labelName];
+    _computeLabelTitle(change, labelName) {
+      const label = change.labels[labelName];
       if (!label) { return 'Label not applicable'; }
-      var significantLabel = label.rejected || label.approved ||
+      const significantLabel = label.rejected || label.approved ||
           label.disliked || label.recommended;
       if (significantLabel && significantLabel.name) {
         return labelName + '\nby ' + significantLabel.name;
@@ -56,12 +56,12 @@
       return labelName;
     },
 
-    _computeLabelClass: function(change, labelName) {
-      var label = change.labels[labelName];
+    _computeLabelClass(change, labelName) {
+      const label = change.labels[labelName];
       // Mimic a Set.
-      var classes = {
-        'cell': true,
-        'label': true,
+      const classes = {
+        cell: true,
+        label: true,
       };
       if (label) {
         if (label.approved) {
@@ -83,8 +83,8 @@
       return Object.keys(classes).sort().join(' ');
     },
 
-    _computeLabelValue: function(change, labelName) {
-      var label = change.labels[labelName];
+    _computeLabelValue(change, labelName) {
+      const label = change.labels[labelName];
       if (!label) { return ''; }
       if (label.approved) {
         return '✓';
@@ -101,12 +101,12 @@
       return '';
     },
 
-    _computeProjectURL: function(project) {
+    _computeProjectURL(project) {
       return this.getBaseUrl() + '/q/status:open+project:' +
           this.encodeURL(project, false);
     },
 
-    _computeProjectBranchURL: function(project, branch) {
+    _computeProjectBranchURL(project, branch) {
       // @see Issue 4255.
       return this._computeProjectURL(project) +
           '+branch:' + this.encodeURL(branch, false);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
index ad76f10..ce7084b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
@@ -34,19 +34,19 @@
 </test-fixture>
 
 <script>
-  suite('gr-change-list-item tests', function() {
-    var element;
+  suite('gr-change-list-item tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
-        getLoggedIn: function() { return Promise.resolve(false); },
+        getConfig() { return Promise.resolve({}); },
+        getLoggedIn() { return Promise.resolve(false); },
       });
       element = fixture('basic');
     });
 
-    test('change status', function() {
-      var getStatusForChange = function(change) {
+    test('change status', () => {
+      const getStatusForChange = function(change) {
         element.change = change;
         return element.$$('.cell.status').textContent.trim();
       };
@@ -59,7 +59,7 @@
       assert.equal(getStatusForChange({status: 'DRAFT'}), 'Draft');
     });
 
-    test('computed fields', function() {
+    test('computed fields', () => {
       assert.equal(element._computeLabelClass({labels: {}}),
           'cell label u-gray-background');
       assert.equal(element._computeLabelClass(
@@ -99,19 +99,19 @@
           'Code-Review'), 'Code-Review\nby Diffy');
       assert.equal(element._computeLabelTitle(
           {labels: {'Code-Review': {recommended: {name: 'Diffy'},
-          rejected: {name: 'Admin'}}}}, 'Code-Review'),
+            rejected: {name: 'Admin'}}}}, 'Code-Review'),
           'Code-Review\nby Admin');
       assert.equal(element._computeLabelTitle(
           {labels: {'Code-Review': {approved: {name: 'Diffy'},
-          rejected: {name: 'Admin'}}}}, 'Code-Review'),
+            rejected: {name: 'Admin'}}}}, 'Code-Review'),
           'Code-Review\nby Admin');
       assert.equal(element._computeLabelTitle(
           {labels: {'Code-Review': {recommended: {name: 'Diffy'},
-          disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
+            disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
           'Code-Review\nby Admin');
       assert.equal(element._computeLabelTitle(
           {labels: {'Code-Review': {approved: {name: 'Diffy'},
-          disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
+            disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
           'Code-Review\nby Diffy');
 
       assert.equal(element._computeLabelValue({labels: {}}), '');
@@ -140,7 +140,7 @@
       assert.equal(element.changeURL, '/c/43/');
     });
 
-    test('no hidden columns', function() {
+    test('no hidden columns', () => {
       element.visibleChangeTableColumns = [
         'Subject',
         'Status',
@@ -153,13 +153,13 @@
 
       flushAsynchronousOperations();
 
-      element.columnNames.forEach(function(column) {
-        var elementClass = '.' + column.toLowerCase();
+      for (const column of element.columnNames) {
+        const elementClass = '.' + column.toLowerCase();
         assert.isFalse(element.$$(elementClass).hidden);
-      });
+      }
     });
 
-    test('no hidden columns', function() {
+    test('no hidden columns', () => {
       element.visibleChangeTableColumns = [
         'Subject',
         'Status',
@@ -172,13 +172,13 @@
 
       flushAsynchronousOperations();
 
-      element.columnNames.forEach(function(column) {
-        var elementClass = '.' + column.toLowerCase();
+      for (const column of element.columnNames) {
+        const elementClass = '.' + column.toLowerCase();
         assert.isFalse(element.$$(elementClass).hidden);
-      });
+      }
     });
 
-    test('project column hidden', function() {
+    test('project column hidden', () => {
       element.visibleChangeTableColumns = [
         'Subject',
         'Status',
@@ -190,25 +190,24 @@
 
       flushAsynchronousOperations();
 
-      element.columnNames.forEach(function(column) {
-        var elementClass = '.' + column.toLowerCase();
+      for (const column of element.columnNames) {
+        const elementClass = '.' + column.toLowerCase();
         if (column === 'Project') {
           assert.isTrue(element.$$(elementClass).hidden);
         } else {
           assert.isFalse(element.$$(elementClass).hidden);
         }
-      });
+      }
     });
 
-    test('random column does not exist', function() {
+    test('random column does not exist', () => {
       element.visibleChangeTableColumns = [
         'Bad',
       ];
 
       flushAsynchronousOperations();
-      var elementClass = '.bad';
+      const elementClass = '.bad';
       assert.isNotOk(element.$$(elementClass));
     });
-
-  });
+});
 </script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
index 82d85ac..a606203 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
@@ -14,7 +14,7 @@
 (function() {
   'use strict';
 
-  var LookupQueryPatterns = {
+  const LookupQueryPatterns = {
     CHANGE_ID: /^\s*i?[0-9a-f]{8,40}\s*$/i,
     CHANGE_NUM: /^\s*[1-9][0-9]*\s*$/g,
   };
@@ -56,7 +56,7 @@
       viewState: {
         type: Object,
         notify: true,
-        value: function() { return {}; },
+        value() { return {}; },
       },
 
       _changesPerPage: Number,
@@ -93,11 +93,11 @@
       'previous-page': '_handlePreviousPage',
     },
 
-    attached: function() {
+    attached() {
       this.fire('title-change', {title: this._query});
     },
 
-    _paramsChanged: function(value) {
+    _paramsChanged(value) {
       if (value.view != this.tagName.toLowerCase()) { return; }
 
       this._loading = true;
@@ -112,12 +112,12 @@
 
       this.fire('title-change', {title: this._query});
 
-      this._getPreferences().then(function(prefs) {
+      this._getPreferences().then(prefs => {
         this._changesPerPage = prefs.changes_per_page;
         return this._getChanges();
-      }.bind(this)).then(function(changes) {
+      }).then(changes => {
         if (this._query && changes.length === 1) {
-          for (var query in LookupQueryPatterns) {
+          for (const query in LookupQueryPatterns) {
             if (LookupQueryPatterns.hasOwnProperty(query) &&
                 this._query.match(LookupQueryPatterns[query])) {
               page.show('/c/' + changes[0]._number);
@@ -127,46 +127,46 @@
         }
         this._changes = changes;
         this._loading = false;
-      }.bind(this));
+      });
     },
 
-    _getChanges: function() {
+    _getChanges() {
       return this.$.restAPI.getChanges(this._changesPerPage, this._query,
           this._offset);
     },
 
-    _getPreferences: function() {
+    _getPreferences() {
       return this.$.restAPI.getPreferences();
     },
 
-    _computeNavLink: function(query, offset, direction, changesPerPage) {
+    _computeNavLink(query, offset, direction, changesPerPage) {
       // Offset could be a string when passed from the router.
       offset = +(offset || 0);
-      var newOffset = Math.max(0, offset + (changesPerPage * direction));
+      const newOffset = Math.max(0, offset + (changesPerPage * direction));
       // Double encode URI component.
-      var href = this.getBaseUrl() + '/q/' + this.encodeURL(query, false);
+      let href = this.getBaseUrl() + '/q/' + this.encodeURL(query, false);
       if (newOffset > 0) {
         href += ',' + newOffset;
       }
       return href;
     },
 
-    _hidePrevArrow: function(offset) {
+    _hidePrevArrow(offset) {
       return offset === 0;
     },
 
-    _hideNextArrow: function(loading) {
+    _hideNextArrow(loading) {
       return loading || !this._changes || !this._changes.length ||
           !this._changes[this._changes.length - 1]._more_changes;
     },
 
-    _handleNextPage: function() {
+    _handleNextPage() {
       if (this.$.nextArrow.hidden) { return; }
       page.show(this._computeNavLink(
           this._query, this._offset, 1, this._changesPerPage));
     },
 
-    _handlePreviousPage: function() {
+    _handlePreviousPage() {
       if (this.$.prevArrow.hidden) { return; }
       page.show(this._computeNavLink(
           this._query, this._offset, -1, this._changesPerPage));
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
index 661dd2c..a6d798d 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
@@ -34,17 +34,17 @@
 </test-fixture>
 
 <script>
-  var CHANGE_ID = 'IcA3dAB3edAB9f60B8dcdA6ef71A75980e4B7127';
-  var COMMIT_HASH = '12345678';
+  const CHANGE_ID = 'IcA3dAB3edAB9f60B8dcdA6ef71A75980e4B7127';
+  const COMMIT_HASH = '12345678';
 
-  suite('gr-change-list-view tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-change-list-view tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getLoggedIn: function() { return Promise.resolve(false); },
-        getChanges: function(num, query) {
+        getLoggedIn() { return Promise.resolve(false); },
+        getChanges(num, query) {
           return Promise.resolve([]);
         },
       });
@@ -52,14 +52,14 @@
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function(done) {
-      flush(function() {
+    teardown(done => {
+      flush(() => {
         sandbox.restore();
         done();
       });
     });
 
-    test('url is properly encoded', function() {
+    test('url is properly encoded', () => {
       assert.equal(element._computeNavLink(
           'status:open project:platform/frameworks/base', 0, -1, 25),
           '/q/status:open+project:platform%252Fframeworks%252Fbase'
@@ -70,11 +70,11 @@
       );
     });
 
-    test('_computeNavLink', function() {
-      var query = 'status:open';
-      var offset = 0;
-      var direction = 1;
-      var changesPerPage = 5;
+    test('_computeNavLink', () => {
+      const query = 'status:open';
+      let offset = 0;
+      let direction = 1;
+      const changesPerPage = 5;
       assert.equal(
           element._computeNavLink(query, offset, direction, changesPerPage),
           '/q/status:open,5');
@@ -89,12 +89,12 @@
           '/q/status:open,10');
     });
 
-    test('_computeNavLink with path', function() {
+    test('_computeNavLink with path', () => {
       window.CANONICAL_PATH = '/r';
-      var query = 'status:open';
-      var offset = 0;
-      var direction = 1;
-      var changesPerPage = 5;
+      const query = 'status:open';
+      let offset = 0;
+      let direction = 1;
+      const changesPerPage = 5;
       assert.equal(
           element._computeNavLink(query, offset, direction, changesPerPage),
           '/r/q/status:open,5');
@@ -109,34 +109,34 @@
           '/r/q/status:open,10');
     });
 
-    test('_hidePrevArrow', function() {
-      var offset = 0;
+    test('_hidePrevArrow', () => {
+      let offset = 0;
       assert.isTrue(element._hidePrevArrow(offset));
       offset = 5;
       assert.isFalse(element._hidePrevArrow(offset));
     });
 
-    test('_hideNextArrow', function() {
-      var loading = true;
+    test('_hideNextArrow', () => {
+      let loading = true;
       assert.isTrue(element._hideNextArrow(loading));
       loading = false;
       assert.isTrue(element._hideNextArrow(loading));
       element._changes = [];
       assert.isTrue(element._hideNextArrow(loading));
       element._changes =
-          Array.apply(null, Array(5)).map(Object.prototype.valueOf, {});
+          Array(...Array(5)).map(Object.prototype.valueOf, {});
       assert.isTrue(element._hideNextArrow(loading));
       element._changes =
-          Array.apply(null, Array(25)).map(Object.prototype.valueOf,
+          Array(...Array(25)).map(Object.prototype.valueOf,
           {_more_changes: true});
       assert.isFalse(element._hideNextArrow(loading));
       element._changes =
-          Array.apply(null, Array(25)).map(Object.prototype.valueOf, {});
+          Array(...Array(25)).map(Object.prototype.valueOf, {});
       assert.isTrue(element._hideNextArrow(loading));
     });
 
-    test('_handleNextPage', function() {
-      var showStub = sandbox.stub(page, 'show');
+    test('_handleNextPage', () => {
+      const showStub = sandbox.stub(page, 'show');
       element.$.nextArrow.hidden = true;
       element._handleNextPage();
       assert.isFalse(showStub.called);
@@ -145,8 +145,8 @@
       assert.isTrue(showStub.called);
     });
 
-    test('_handlePreviousPage', function() {
-      var showStub = sandbox.stub(page, 'show');
+    test('_handlePreviousPage', () => {
+      const showStub = sandbox.stub(page, 'show');
       element.$.prevArrow.hidden = true;
       element._handlePreviousPage();
       assert.isFalse(showStub.called);
@@ -155,11 +155,11 @@
       assert.isTrue(showStub.called);
     });
 
-    suite('query based navigation', function() {
-      test('Searching for a change ID redirects to change', function(done) {
+    suite('query based navigation', () => {
+      test('Searching for a change ID redirects to change', done => {
         sandbox.stub(element, '_getChanges')
             .returns(Promise.resolve([{_number: 1}]));
-        sandbox.stub(page, 'show', function(url) {
+        sandbox.stub(page, 'show', url => {
           assert.equal(url, '/c/1');
           done();
         });
@@ -167,10 +167,10 @@
         element.params = {view: 'gr-change-list-view', query: CHANGE_ID};
       });
 
-      test('Searching for a change num redirects to change', function(done) {
+      test('Searching for a change num redirects to change', done => {
         sandbox.stub(element, '_getChanges')
             .returns(Promise.resolve([{_number: 1}]));
-        sandbox.stub(page, 'show', function(url) {
+        sandbox.stub(page, 'show', url => {
           assert.equal(url, '/c/1');
           done();
         });
@@ -178,10 +178,10 @@
         element.params = {view: 'gr-change-list-view', query: '1'};
       });
 
-      test('Commit hash redirects to change', function(done) {
+      test('Commit hash redirects to change', done => {
         sandbox.stub(element, '_getChanges')
             .returns(Promise.resolve([{_number: 1}]));
-        sandbox.stub(page, 'show', function(url) {
+        sandbox.stub(page, 'show', url => {
           assert.equal(url, '/c/1');
           done();
         });
@@ -189,10 +189,10 @@
         element.params = {view: 'gr-change-list-view', query: COMMIT_HASH};
       });
 
-      test('Searching for an invalid change ID searches', function() {
+      test('Searching for an invalid change ID searches', () => {
         sandbox.stub(element, '_getChanges')
             .returns(Promise.resolve([]));
-        var stub = sandbox.stub(page, 'show');
+        const stub = sandbox.stub(page, 'show');
 
         element.params = {view: 'gr-change-list-view', query: CHANGE_ID};
         flushAsynchronousOperations();
@@ -200,10 +200,10 @@
         assert.isFalse(stub.called);
       });
 
-      test('Change ID with multiple search results searches', function() {
+      test('Change ID with multiple search results searches', () => {
         sandbox.stub(element, '_getChanges')
             .returns(Promise.resolve([{}, {}]));
-        var stub = sandbox.stub(page, 'show');
+        const stub = sandbox.stub(page, 'show');
 
         element.params = {view: 'gr-change-list-view', query: CHANGE_ID};
         flushAsynchronousOperations();
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
index 8a95fa8..362f101 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
@@ -35,6 +35,13 @@
       th {
         text-align: left;
       }
+      .groupHeader {
+        background-color: #eee;
+        border-top: 1em solid #fff;
+      }
+      .headerRow + tr {
+        border: none;
+      }
     </style>
     <style include="gr-change-list-styles"></style>
     <table id="changeList">
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
index 425ea76..5725c9c 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
@@ -14,7 +14,7 @@
 (function() {
   'use strict';
 
-  var NUMBER_FIXED_COLUMNS = 3;
+  const NUMBER_FIXED_COLUMNS = 3;
 
   Polymer({
     is: 'gr-change-list',
@@ -42,7 +42,7 @@
        */
       account: {
         type: Object,
-        value: function() { return {}; },
+        value() { return {}; },
       },
       /**
        * An array of ChangeInfo objects to render.
@@ -58,11 +58,11 @@
        */
       groups: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       groupTitles: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       labelNames: {
         type: Array,
@@ -83,8 +83,9 @@
       },
       keyEventTarget: {
         type: Object,
-        value: function() { return document.body; },
+        value() { return document.body; },
       },
+      changeTableColumns: Array,
     },
 
     behaviors: [
@@ -99,18 +100,19 @@
       'n ]': '_handleNKey',
       'o enter': '_handleEnterKey',
       'p [': '_handlePKey',
+      'shift+r': '_handleRKey',
     },
 
-    attached: function() {
+    attached() {
       this._loadPreferences();
     },
 
-    _lowerCase: function(column) {
+    _lowerCase(column) {
       return column.toLowerCase();
     },
 
-    _loadPreferences: function() {
-      return this._getLoggedIn().then(function(loggedIn) {
+    _loadPreferences() {
+      return this._getLoggedIn().then(loggedIn => {
         this.changeTableColumns = this.columnNames;
 
         if (!loggedIn) {
@@ -118,99 +120,99 @@
           this.visibleChangeTableColumns = this.columnNames;
           return;
         }
-        return this._getPreferences().then(function(preferences) {
+        return this._getPreferences().then(preferences => {
           this.showNumber = !!(preferences &&
               preferences.legacycid_in_change_table);
           this.visibleChangeTableColumns = preferences.change_table.length > 0 ?
               preferences.change_table : this.columnNames;
-        }.bind(this));
-      }.bind(this));
+        });
+      });
     },
 
-    _getLoggedIn: function() {
+    _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
     },
 
-    _getPreferences: function() {
+    _getPreferences() {
       return this.$.restAPI.getPreferences();
     },
 
-    _computeColspan: function(changeTableColumns, labelNames) {
+    _computeColspan(changeTableColumns, labelNames) {
       return changeTableColumns.length + labelNames.length +
           NUMBER_FIXED_COLUMNS;
     },
 
-    _computeLabelNames: function(groups) {
+    _computeLabelNames(groups) {
       if (!groups) { return []; }
-      var labels = [];
-      var nonExistingLabel = function(item) {
-        return labels.indexOf(item) < 0;
+      let labels = [];
+      const nonExistingLabel = function(item) {
+        return !labels.includes(item);
       };
-      for (var i = 0; i < groups.length; i++) {
-        var group = groups[i];
-        for (var j = 0; j < group.length; j++) {
-          var change = group[j];
+      for (let i = 0; i < groups.length; i++) {
+        const group = groups[i];
+        for (let j = 0; j < group.length; j++) {
+          const change = group[j];
           if (!change.labels) { continue; }
-          var currentLabels = Object.keys(change.labels);
+          const currentLabels = Object.keys(change.labels);
           labels = labels.concat(currentLabels.filter(nonExistingLabel));
         }
       }
       return labels.sort();
     },
 
-    _computeLabelShortcut: function(labelName) {
+    _computeLabelShortcut(labelName) {
       return labelName.replace(/[a-z-]/g, '');
     },
 
-    _changesChanged: function(changes) {
+    _changesChanged(changes) {
       this.groups = changes ? [changes] : [];
     },
 
-    _groupTitle: function(groupIndex) {
+    _groupTitle(groupIndex) {
       if (groupIndex > this.groupTitles.length - 1) { return null; }
       return this.groupTitles[groupIndex];
     },
 
-    _computeItemSelected: function(index, groupIndex, selectedIndex) {
-      var idx = 0;
-      for (var i = 0; i < groupIndex; i++) {
+    _computeItemSelected(index, groupIndex, selectedIndex) {
+      let idx = 0;
+      for (let i = 0; i < groupIndex; i++) {
         idx += this.groups[i].length;
       }
       idx += index;
       return idx == selectedIndex;
     },
 
-    _computeItemNeedsReview: function(account, change, showReviewedState) {
+    _computeItemNeedsReview(account, change, showReviewedState) {
       return showReviewedState && !change.reviewed &&
           this.changeIsOpen(change.status) &&
           account._account_id != change.owner._account_id;
     },
 
-    _computeItemAssigned: function(account, change) {
+    _computeItemAssigned(account, change) {
       if (!change.assignee) { return false; }
       return account._account_id === change.assignee._account_id;
     },
 
-    _getAggregateGroupsLen: function(groups) {
+    _getAggregateGroupsLen(groups) {
       groups = groups || [];
-      var len = 0;
-      this.groups.forEach(function(group) {
+      let len = 0;
+      for (const group of this.groups) {
         len += group.length;
-      });
+      }
       return len;
     },
 
-    _handleJKey: function(e) {
+    _handleJKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
       e.preventDefault();
-      var len = this._getAggregateGroupsLen(this.groups);
+      const len = this._getAggregateGroupsLen(this.groups);
       if (this.selectedIndex === len - 1) { return; }
       this.selectedIndex += 1;
     },
 
-    _handleKKey: function(e) {
+    _handleKKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -219,7 +221,7 @@
       this.selectedIndex -= 1;
     },
 
-    _handleEnterKey: function(e) {
+    _handleEnterKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -227,29 +229,38 @@
       page.show(this._changeURLForIndex(this.selectedIndex));
     },
 
-    _handleNKey: function(e) {
+    _handleNKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
       this.fire('next-page');
     },
 
-    _handlePKey: function(e) {
+    _handlePKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
       this.fire('previous-page');
     },
 
-    _changeURLForIndex: function(index) {
-      var changeEls = this._getListItems();
+    _handleRKey(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) {
+        return;
+      }
+
+      e.preventDefault();
+      window.location.reload();
+    },
+
+    _changeURLForIndex(index) {
+      const changeEls = this._getListItems();
       if (index < changeEls.length && changeEls[index]) {
         return changeEls[index].changeURL;
       }
       return '';
     },
 
-    _getListItems: function() {
+    _getListItems() {
       return Polymer.dom(this.root).querySelectorAll('gr-change-list-item');
     },
   });
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
index 1a1abff..27f0603 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
@@ -40,16 +40,16 @@
 </test-fixture>
 
 <script>
-  suite('gr-change-list basic tests', function() {
-    var element;
+  suite('gr-change-list basic tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
     });
 
     function stubRestAPI(preferences) {
-      var loggedInPromise = Promise.resolve(preferences !== null);
-      var preferencesPromise = Promise.resolve(preferences);
+      const loggedInPromise = Promise.resolve(preferences !== null);
+      const preferencesPromise = Promise.resolve(preferences);
       stub('gr-rest-api-interface', {
         getLoggedIn: sinon.stub().returns(loggedInPromise),
         getPreferences: sinon.stub().returns(preferencesPromise),
@@ -57,60 +57,60 @@
       return Promise.all([loggedInPromise, preferencesPromise]);
     }
 
-    suite('test show change number not logged in', function() {
-      setup(function() {
-        return stubRestAPI(null).then(function() {
+    suite('test show change number not logged in', () => {
+      setup(() => {
+        return stubRestAPI(null).then(() => {
           element = fixture('basic');
           return element._loadPreferences();
         });
       });
 
-      test('show number disabled', function() {
+      test('show number disabled', () => {
         assert.isFalse(element.showNumber);
       });
     });
 
-    suite('test show change number preference enabled', function() {
-      setup(function() {
+    suite('test show change number preference enabled', () => {
+      setup(() => {
         return stubRestAPI({legacycid_in_change_table: true,
-           time_format: 'HHMM_12',
-           change_table: [],
-        }).then(function() {
+          time_format: 'HHMM_12',
+          change_table: [],
+        }).then(() => {
           element = fixture('basic');
           return element._loadPreferences();
         });
       });
 
-      test('show number enabled', function() {
+      test('show number enabled', () => {
         assert.isTrue(element.showNumber);
       });
     });
 
-    suite('test show change number preference disabled', function() {
-      setup(function() {
+    suite('test show change number preference disabled', () => {
+      setup(() => {
         // legacycid_in_change_table is not set when false.
         return stubRestAPI({time_format: 'HHMM_12', change_table: []}).then(
-            function() {
-          element = fixture('basic');
-          return element._loadPreferences();
-        });
+            () => {
+              element = fixture('basic');
+              return element._loadPreferences();
+            });
       });
 
-      test('show number disabled', function() {
+      test('show number disabled', () => {
         assert.isFalse(element.showNumber);
       });
     });
 
-    test('computed fields', function() {
+    test('computed fields', () => {
       assert.equal(element._computeLabelNames(
           [[{_number: 0, labels: {}}]]).length, 0);
       assert.equal(element._computeLabelNames([[
             {_number: 0, labels: {Verified: {approved: {}}}},
-            {_number: 1, labels: {
-              Verified: {approved: {}}, 'Code-Review': {approved: {}}}},
-            {_number: 2, labels: {
-              Verified: {approved: {}}, 'Library-Compliance': {approved: {}}}},
-          ]]).length, 3);
+        {_number: 1, labels: {
+          'Verified': {approved: {}}, 'Code-Review': {approved: {}}}},
+        {_number: 2, labels: {
+          'Verified': {approved: {}}, 'Library-Compliance': {approved: {}}}},
+      ]]).length, 3);
 
       assert.equal(element._computeLabelShortcut('Code-Review'), 'CR');
       assert.equal(element._computeLabelShortcut('Verified'), 'V');
@@ -119,17 +119,17 @@
           'Some-Special-Label-7'), 'SSL7');
     });
 
-    test('colspans', function() {
-      var thItemCount = Polymer.dom(element.root).querySelectorAll(
+    test('colspans', () => {
+      const thItemCount = Polymer.dom(element.root).querySelectorAll(
           'th').length;
 
-      var changeTableColumns = [];
-      var labelNames = [];
+      const changeTableColumns = [];
+      const labelNames = [];
       assert.equal(thItemCount, element._computeColspan(
           changeTableColumns, labelNames));
     });
 
-    test('keyboard shortcuts', function(done) {
+    test('keyboard shortcuts', done => {
       element.selectedIndex = 0;
       element.changes = [
         {_number: 0},
@@ -137,17 +137,17 @@
         {_number: 2},
       ];
       flushAsynchronousOperations();
-      var elementItems = Polymer.dom(element.root).querySelectorAll(
+      const elementItems = Polymer.dom(element.root).querySelectorAll(
           'gr-change-list-item');
       assert.equal(elementItems.length, 3);
 
-      flush(function() {
+      flush(() => {
         assert.isTrue(elementItems[0].hasAttribute('selected'));
         MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
         assert.equal(element.selectedIndex, 1);
         MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
 
-        var showStub = sinon.stub(page, 'show');
+        const showStub = sinon.stub(page, 'show');
         assert.equal(element.selectedIndex, 2);
         MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
         assert(showStub.lastCall.calledWithExactly('/c/2/'),
@@ -169,7 +169,7 @@
       });
     });
 
-    test('changes needing review', function() {
+    test('changes needing review', () => {
       element.changes = [
         {
           _number: 0,
@@ -196,13 +196,13 @@
           _number: 4,
           status: 'ABANDONED',
           owner: {_account_id: 0},
-        }
+        },
       ];
       flushAsynchronousOperations();
-      var elementItems = Polymer.dom(element.root).querySelectorAll(
+      let elementItems = Polymer.dom(element.root).querySelectorAll(
           'gr-change-list-item');
       assert.equal(elementItems.length, 5);
-      for (var i = 0; i < elementItems.length; i++) {
+      for (let i = 0; i < elementItems.length; i++) {
         assert.isFalse(elementItems[i].hasAttribute('needs-review'));
       }
 
@@ -227,150 +227,150 @@
       assert.isFalse(elementItems[4].hasAttribute('needs-review'));
     });
 
-    test('no changes', function() {
+    test('no changes', () => {
       element.changes = [];
       flushAsynchronousOperations();
-      var listItems = Polymer.dom(element.root).querySelectorAll(
+      const listItems = Polymer.dom(element.root).querySelectorAll(
           'gr-change-list-item');
       assert.equal(listItems.length, 0);
-      var noChangesMsg = Polymer.dom(element.root).querySelector('.noChanges');
+      const noChangesMsg =
+          Polymer.dom(element.root).querySelector('.noChanges');
       assert.ok(noChangesMsg);
     });
 
-    test('empty groups', function() {
+    test('empty groups', () => {
       element.groups = [[], []];
       flushAsynchronousOperations();
-      var listItems = Polymer.dom(element.root).querySelectorAll(
+      const listItems = Polymer.dom(element.root).querySelectorAll(
           'gr-change-list-item');
       assert.equal(listItems.length, 0);
-      var noChangesMsg = Polymer.dom(element.root).querySelectorAll(
+      const noChangesMsg = Polymer.dom(element.root).querySelectorAll(
           '.noChanges');
       assert.equal(noChangesMsg.length, 2);
     });
 
-    suite('empty column preference', function() {
-      var element;
+    suite('empty column preference', () => {
+      let element;
 
-      setup(function() {
-        return stubRestAPI({
+      setup(() =>
+        stubRestAPI({
           legacycid_in_change_table: true,
-            time_format: 'HHMM_12',
-            change_table: [],
-          }
-        ).then(function() {
+          time_format: 'HHMM_12',
+          change_table: [],
+        }).then(() => {
           element = fixture('basic');
           return element._loadPreferences();
-        });
-      });
+        })
+      );
 
-      test('show number enabled', function() {
+      test('show number enabled', () => {
         assert.isTrue(element.showNumber);
       });
 
-      test('all columns visible', function() {
-        element.columnNames.forEach(function(column) {
-          var elementClass = '.' + element._lowerCase(column);
+      test('all columns visible', () => {
+        for (const column of element.columnNames) {
+          const elementClass = '.' + element._lowerCase(column);
           assert.isFalse(element.$$(elementClass).hidden);
-        });
+        }
       });
     });
 
-    suite('full column preference', function() {
-      var element;
+    suite('full column preference', () => {
+      let element;
 
-      setup(function() {
+      setup(() => {
         return stubRestAPI({
-            legacycid_in_change_table: true,
-            time_format: 'HHMM_12',
-            change_table: [
-              'Subject',
-              'Status',
-              'Owner',
-              'Project',
-              'Branch',
-              'Updated',
-              'Size',
-            ],
-          }).then(function() {
+          legacycid_in_change_table: true,
+          time_format: 'HHMM_12',
+          change_table: [
+            'Subject',
+            'Status',
+            'Owner',
+            'Project',
+            'Branch',
+            'Updated',
+            'Size',
+          ],
+        }).then(() => {
           element = fixture('basic');
           return element._loadPreferences();
         });
       });
 
-      test('all columns visible', function() {
-        element.changeTableColumns.forEach(function(column) {
-          var elementClass = '.' + element._lowerCase(column);
+      test('all columns visible', () => {
+        for (const column of element.changeTableColumns) {
+          const elementClass = '.' + element._lowerCase(column);
           assert.isFalse(element.$$(elementClass).hidden);
-        });
+        }
       });
     });
 
-    suite('partial column preference', function() {
-      var element;
+    suite('partial column preference', () => {
+      let element;
 
-      setup(function() {
+      setup(() => {
         return stubRestAPI({
-            legacycid_in_change_table: true,
-            time_format: 'HHMM_12',
-            change_table: [
-              'Subject',
-              'Status',
-              'Owner',
-              'Branch',
-              'Updated',
-              'Size',
-            ],
-          }).then(function() {
+          legacycid_in_change_table: true,
+          time_format: 'HHMM_12',
+          change_table: [
+            'Subject',
+            'Status',
+            'Owner',
+            'Branch',
+            'Updated',
+            'Size',
+          ],
+        }).then(() => {
           element = fixture('basic');
           return element._loadPreferences();
         });
       });
 
-      test('all columns except project visible', function() {
-        element.changeTableColumns.forEach(function(column) {
-          var elementClass = '.' + column.toLowerCase();
+      test('all columns except project visible', () => {
+        for (const column of element.changeTableColumns) {
+          const elementClass = '.' + column.toLowerCase();
           if (column === 'Project') {
             assert.isTrue(element.$$(elementClass).hidden);
           } else {
             assert.isFalse(element.$$(elementClass).hidden);
           }
-        });
+        }
       });
     });
 
-    suite('random column does not exist', function() {
-      var element;
+    suite('random column does not exist', () => {
+      let element;
 
       /* This would only exist if somebody manually updated the config
       file. */
-      setup(function() {
+      setup(() => {
         return stubRestAPI({
-            legacycid_in_change_table: true,
-            time_format: 'HHMM_12',
-            change_table: [
-              'Bad',
-            ],
-          }).then(function() {
+          legacycid_in_change_table: true,
+          time_format: 'HHMM_12',
+          change_table: [
+            'Bad',
+          ],
+        }).then(() => {
           element = fixture('basic');
           return element._loadPreferences();
         });
       });
 
-      test('bad column does not exist', function() {
-        var elementClass = '.bad';
+      test('bad column does not exist', () => {
+        const elementClass = '.bad';
         assert.isNotOk(element.$$(elementClass));
       });
     });
   });
 
-  suite('gr-change-list groups', function() {
-    var element;
+  suite('gr-change-list groups', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
     });
 
-    test('keyboard shortcuts', function() {
+    test('keyboard shortcuts', () => {
       element.selectedIndex = 0;
       element.groups = [
         [
@@ -387,11 +387,11 @@
           {_number: 6},
           {_number: 7},
           {_number: 8},
-        ]
+        ],
       ];
       element.groupTitles = ['Group 1', 'Group 2', 'Group 3'];
       flushAsynchronousOperations();
-      var elementItems = Polymer.dom(element.root).querySelectorAll(
+      const elementItems = Polymer.dom(element.root).querySelectorAll(
           'gr-change-list-item');
       assert.equal(elementItems.length, 9);
 
@@ -399,7 +399,7 @@
       assert.equal(element.selectedIndex, 1);
       MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'j'
 
-      var showStub = sinon.stub(page, 'show');
+      const showStub = sinon.stub(page, 'show');
       assert.equal(element.selectedIndex, 2);
       MockInteractions.pressAndReleaseKeyOn(element, 13);  // 'enter'
       assert(showStub.lastCall.calledWithExactly('/c/2/'),
@@ -421,7 +421,7 @@
       showStub.restore();
     });
 
-    test('assigned attribute set in each item', function() {
+    test('assigned attribute set in each item', () => {
       element.changes = [
         {
           _number: 0,
@@ -441,9 +441,9 @@
       ];
       element.account = {_account_id: 42};
       flushAsynchronousOperations();
-      var items = element._getListItems();
+      const items = element._getListItems();
       assert.equal(items.length, 3);
-      for (var i = 0; i < items.length; i++) {
+      for (let i = 0; i < items.length; i++) {
         assert.equal(items[i].hasAttribute('assigned'),
             items[i]._account_id === element.account._account_id);
       }
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
index 977552a..58533b8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
@@ -26,7 +26,7 @@
     properties: {
       account: {
         type: Object,
-        value: function() { return {}; },
+        value() { return {}; },
       },
       viewState: Object,
       params: {
@@ -53,25 +53,25 @@
       },
     },
 
-    attached: function() {
+    attached() {
       this.fire('title-change', {title: 'My Reviews'});
     },
 
     /**
      * Allows a refresh if menu item is selected again.
      */
-    _paramsChanged: function() {
+    _paramsChanged() {
       this._loading = true;
-      this._getDashboardChanges().then(function(results) {
+      this._getDashboardChanges().then(results => {
         this._results = results;
         this._loading = false;
-      }.bind(this)).catch(function(err) {
+      }).catch(err => {
         this._loading = false;
         console.warn(err.message);
-      }.bind(this));
+      });
     },
 
-    _getDashboardChanges: function() {
+    _getDashboardChanges() {
       return this.$.restAPI.getDashboardChanges();
     },
   });
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
index 718e59c..fd635df 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
@@ -32,18 +32,18 @@
 </test-fixture>
 
 <script>
-  suite('gr-dashboard-view tests', function() {
-    var element;
+  suite('gr-dashboard-view tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
     });
 
-    test('content is refreshed with same dropdown selected twice', function() {
-      var getChangesStub = sinon.stub(element, '_getDashboardChanges',
-          function() {
-        return Promise.resolve();
-      });
+    test('content is refreshed with same dropdown selected twice', () => {
+      const getChangesStub = sinon.stub(element, '_getDashboardChanges',
+          () => {
+            return Promise.resolve();
+          });
 
       element.params = {view: 'gr-dashboard-view'};
 
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
index 46f2ed2..be66587 100644
--- a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
+++ b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
@@ -22,7 +22,6 @@
      *
      * @event add
      */
-
     properties: {
       borderless: Boolean,
       change: Object,
@@ -46,7 +45,7 @@
 
       query: {
         type: Function,
-        value: function() {
+        value() {
           return this._getReviewerSuggestions.bind(this);
         },
       },
@@ -56,22 +55,30 @@
       return this.$.input.focusStart;
     },
 
-    focus: function() {
+    focus() {
       this.$.input.focus();
     },
 
-    clear: function() {
+    clear() {
       this.$.input.clear();
     },
 
-    _handleInputCommit: function(e) {
+    setText(text) {
+      this.$.input.setText(text);
+    },
+
+    getText() {
+      return this.$.input.text;
+    },
+
+    _handleInputCommit(e) {
       this.fire('add', {value: e.detail.value});
     },
 
-    _makeSuggestion: function(reviewer) {
-      var name;
-      var value;
-      var generateStatusStr = function(account) {
+    _makeSuggestion(reviewer) {
+      let name;
+      let value;
+      const generateStatusStr = function(account) {
         return account.status ? ' (' + account.status + ')' : '';
       };
       if (reviewer.account) {
@@ -89,22 +96,22 @@
             generateStatusStr(reviewer);
         value = {account: reviewer, count: 1};
       }
-      return {name: name, value: value};
+      return {name, value};
     },
 
-    _getReviewerSuggestions: function(input) {
-      var api = this.$.restAPI;
-      var xhr = this.allowAnyUser ?
+    _getReviewerSuggestions(input) {
+      const api = this.$.restAPI;
+      const xhr = this.allowAnyUser ?
           api.getSuggestedAccounts(input) :
           api.getChangeSuggestedReviewers(this.change._number, input);
 
-      return xhr.then(function(reviewers) {
+      return xhr.then(reviewers => {
         if (!reviewers) { return []; }
         if (!this.filter) { return reviewers.map(this._makeSuggestion); }
         return reviewers
             .filter(this.filter)
             .map(this._makeSuggestion.bind(this));
-      }.bind(this));
+      });
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
index 2948b4b..54fde09 100644
--- a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
+++ b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
@@ -34,11 +34,11 @@
 </test-fixture>
 
 <script>
-  suite('gr-account-entry tests', function() {
-    var sandbox;
-    var _nextAccountId = 0;
-    var makeAccount = function(opt_status) {
-      var accountId = ++_nextAccountId;
+  suite('gr-account-entry tests', () => {
+    let sandbox;
+    let _nextAccountId = 0;
+    const makeAccount = function(opt_status) {
+      const accountId = ++_nextAccountId;
       return {
         _account_id: accountId,
         name: 'name ' + accountId,
@@ -47,15 +47,15 @@
       };
     };
 
-    var owner;
-    var existingReviewer1;
-    var existingReviewer2;
-    var suggestion1;
-    var suggestion2;
-    var suggestion3;
-    var element;
+    let owner;
+    let existingReviewer1;
+    let existingReviewer2;
+    let suggestion1;
+    let suggestion2;
+    let suggestion3;
+    let element;
 
-    setup(function() {
+    setup(() => {
       owner = makeAccount();
       existingReviewer1 = makeAccount();
       existingReviewer2 = makeAccount();
@@ -70,7 +70,7 @@
 
       element = fixture('basic');
       element.change = {
-        owner: owner,
+        owner,
         reviewers: {
           CC: [existingReviewer1],
           REVIEWER: [existingReviewer2],
@@ -79,97 +79,107 @@
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    suite('stubbed values for _getReviewerSuggestions', function() {
-      setup(function() {
+    suite('stubbed values for _getReviewerSuggestions', () => {
+      setup(() => {
         stub('gr-rest-api-interface', {
-          getChangeSuggestedReviewers: function() {
-            var redundantSuggestion1 = {account: existingReviewer1};
-            var redundantSuggestion2 = {account: existingReviewer2};
-            var redundantSuggestion3 = {account: owner};
+          getChangeSuggestedReviewers() {
+            const redundantSuggestion1 = {account: existingReviewer1};
+            const redundantSuggestion2 = {account: existingReviewer2};
+            const redundantSuggestion3 = {account: owner};
             return Promise.resolve([redundantSuggestion1, redundantSuggestion2,
-                redundantSuggestion3, suggestion1, suggestion2, suggestion3]);
+              redundantSuggestion3, suggestion1, suggestion2, suggestion3]);
           },
         });
       });
 
-      test('_makeSuggestion formats account or group accordingly', function() {
-        var account = makeAccount();
-        var suggestion = element._makeSuggestion({account: account});
+      test('_makeSuggestion formats account or group accordingly', () => {
+        let account = makeAccount();
+        let suggestion = element._makeSuggestion({account});
         assert.deepEqual(suggestion, {
           name: account.name + ' <' + account.email + '>',
-          value: {account: account},
+          value: {account},
         });
 
-        var group = {name: 'test'};
-        suggestion = element._makeSuggestion({group: group});
+        const group = {name: 'test'};
+        suggestion = element._makeSuggestion({group});
         assert.deepEqual(suggestion, {
           name: group.name + ' (group)',
-          value: {group: group},
+          value: {group},
         });
 
         suggestion = element._makeSuggestion(account);
         assert.deepEqual(suggestion, {
           name: account.name + ' <' + account.email + '>',
-          value: {account: account, count: 1},
+          value: {account, count: 1},
         });
 
         account = makeAccount('OOO');
 
-        suggestion = element._makeSuggestion({account: account});
+        suggestion = element._makeSuggestion({account});
         assert.deepEqual(suggestion, {
           name: account.name + ' <' + account.email + '> (OOO)',
-          value: {account: account},
+          value: {account},
         });
 
         suggestion = element._makeSuggestion(account);
         assert.deepEqual(suggestion, {
           name: account.name + ' <' + account.email + '> (OOO)',
-          value: {account: account, count: 1},
+          value: {account, count: 1},
         });
       });
 
-      test('_getReviewerSuggestions excludes owner+reviewers', function(done) {
-        element._getReviewerSuggestions().then(function(reviewers) {
+      test('_getReviewerSuggestions excludes owner+reviewers', done => {
+        element._getReviewerSuggestions().then(reviewers => {
           // Default is no filtering.
           assert.equal(reviewers.length, 6);
 
           // Set up filter that only accepts suggestion1.
-          var accountId = suggestion1.account._account_id;
+          const accountId = suggestion1.account._account_id;
           element.filter = function(suggestion) {
             return suggestion.account &&
                 suggestion.account._account_id === accountId;
           };
 
-          element._getReviewerSuggestions().then(function(reviewers) {
+          element._getReviewerSuggestions().then(reviewers => {
             assert.deepEqual(reviewers, [element._makeSuggestion(suggestion1)]);
           }).then(done);
         });
       });
     });
 
-    test('allowAnyUser', function(done) {
-      var suggestReviewerStub =
+    test('allowAnyUser', done => {
+      const suggestReviewerStub =
           sandbox.stub(element.$.restAPI, 'getChangeSuggestedReviewers')
           .returns(Promise.resolve([]));
-      var suggestAccountStub =
+      const suggestAccountStub =
           sandbox.stub(element.$.restAPI, 'getSuggestedAccounts')
           .returns(Promise.resolve([]));
 
-      element._getReviewerSuggestions('').then(function() {
+      element._getReviewerSuggestions('').then(() => {
         assert.isTrue(suggestReviewerStub.calledOnce);
         assert.isFalse(suggestAccountStub.called);
         element.allowAnyUser = true;
 
-        element._getReviewerSuggestions('').then(function() {
+        element._getReviewerSuggestions('').then(() => {
           assert.isTrue(suggestReviewerStub.calledOnce);
           assert.isTrue(suggestAccountStub.calledOnce);
           done();
         });
       });
     });
+
+    test('setText', () => {
+      // Spy on query, as that is called when _updateSuggestions proceeds.
+      const suggestSpy = sandbox.spy(element.$.input, 'query');
+      element.setText('test text');
+      flushAsynchronousOperations();
+
+      assert.equal(element.$.input.$.input.value, 'test text');
+      assert.isFalse(suggestSpy.called);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js
index 35311f9..4e403e6 100644
--- a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js
+++ b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js
@@ -14,13 +14,21 @@
 (function() {
   'use strict';
 
+  const VALID_EMAIL_ALERT = 'Please input a valid email.';
+
   Polymer({
     is: 'gr-account-list',
 
+    /**
+     * Fired when user inputs an invalid email address.
+     *
+     * @event show-alert
+     */
+
     properties: {
       accounts: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
         notify: true,
       },
       change: Object,
@@ -35,6 +43,7 @@
         type: Boolean,
         value: false,
       },
+
       /**
        * When true, the account-entry autocomplete uses the account suggest API
        * endpoint, which suggests any account in that Gerrit instance (and does
@@ -48,6 +57,15 @@
         type: Boolean,
         value: false,
       },
+
+      /**
+       * When true, allows for non-suggested inputs to be added.
+       */
+      allowAnyInput: {
+        type: Boolean,
+        value: false,
+      },
+
       /**
        * Array of values (groups/accounts) that are removable. When this prop is
        * undefined, all values are removable.
@@ -60,7 +78,7 @@
     },
 
     listeners: {
-      'remove': '_handleRemove',
+      remove: '_handleRemove',
     },
 
     get accountChips() {
@@ -71,36 +89,52 @@
       return this.$.entry.focusStart;
     },
 
-    _handleAdd: function(e) {
-      var reviewer = e.detail.value;
+    _handleAdd(e) {
+      this._addReviewer(e.detail.value);
+    },
+
+    _addReviewer(reviewer) {
       // Append new account or group to the accounts property. We add our own
       // internal properties to the account/group here, so we clone the object
       // to avoid cluttering up the shared change object.
-      // TODO(logan): Polyfill for Object.assign in IE.
       if (reviewer.account) {
-        var account = Object.assign({}, reviewer.account, {_pendingAdd: true});
+        const account =
+            Object.assign({}, reviewer.account, {_pendingAdd: true});
         this.push('accounts', account);
       } else if (reviewer.group) {
         if (reviewer.confirm) {
           this.pendingConfirmation = reviewer;
           return;
         }
-        var group = Object.assign({}, reviewer.group,
+        const group = Object.assign({}, reviewer.group,
             {_pendingAdd: true, _group: true});
         this.push('accounts', group);
+      } else if (this.allowAnyInput) {
+        if (!reviewer.includes('@')) {
+          // Repopulate the input with what the user tried to enter and have
+          // a toast tell them why they can't enter it.
+          this.$.entry.setText(reviewer);
+          this.dispatchEvent(new CustomEvent('show-alert',
+            {detail: {message: VALID_EMAIL_ALERT}, bubbles: true}));
+          return false;
+        } else {
+          const account = {email: reviewer, _pendingAdd: true};
+          this.push('accounts', account);
+        }
       }
       this.pendingConfirmation = null;
+      return true;
     },
 
-    confirmGroup: function(group) {
+    confirmGroup(group) {
       group = Object.assign(
           {}, group, {confirmed: true, _pendingAdd: true, _group: true});
       this.push('accounts', group);
       this.pendingConfirmation = null;
     },
 
-    _computeChipClass: function(account) {
-      var classes = [];
+    _computeChipClass(account) {
+      const classes = [];
       if (account._group) {
         classes.push('group');
       }
@@ -110,11 +144,23 @@
       return classes.join(' ');
     },
 
-    _computeRemovable: function(account) {
+    _accountMatches(a, b) {
+      if (a && b) {
+        if (a._account_id) {
+          return a._account_id === b._account_id;
+        }
+        if (a.email) {
+          return a.email === b.email;
+        }
+      }
+      return a === b;
+    },
+
+    _computeRemovable(account) {
       if (this.readonly) { return false; }
       if (this.removableValues) {
-        for (var i = 0; i < this.removableValues.length; i++) {
-          if (this.removableValues[i]._account_id === account._account_id) {
+        for (let i = 0; i < this.removableValues.length; i++) {
+          if (this._accountMatches(this.removableValues[i], account)) {
             return true;
           }
         }
@@ -123,21 +169,21 @@
       return true;
     },
 
-    _handleRemove: function(e) {
-      var toRemove = e.detail.account;
+    _handleRemove(e) {
+      const toRemove = e.detail.account;
       this._removeAccount(toRemove);
       this.$.entry.focus();
     },
 
-    _removeAccount: function(toRemove) {
+    _removeAccount(toRemove) {
       if (!toRemove || !this._computeRemovable(toRemove)) { return; }
-      for (var i = 0; i < this.accounts.length; i++) {
-        var matches;
-        var account = this.accounts[i];
+      for (let i = 0; i < this.accounts.length; i++) {
+        let matches;
+        const account = this.accounts[i];
         if (toRemove._group) {
           matches = toRemove.id === account.id;
         } else {
-          matches = toRemove._account_id === account._account_id;
+          matches = this._accountMatches(toRemove, account);
         }
         if (matches) {
           this.splice('accounts', i, 1);
@@ -147,8 +193,8 @@
       console.warn('received remove event for missing account', toRemove);
     },
 
-    _handleInputKeydown: function(e) {
-      var input = e.detail.input;
+    _handleInputKeydown(e) {
+      const input = e.detail.input;
       if (input.selectionStart !== input.selectionEnd ||
           input.selectionStart !== 0) {
         return;
@@ -158,18 +204,17 @@
           this._removeAccount(this.accounts[this.accounts.length - 1]);
           break;
         case 37: // Left arrow
-          var chips = this.accountChips;
-          if (chips[chips.length - 1]) {
-            chips[chips.length - 1].focus();
+          if (this.accountChips[this.accountChips.length - 1]) {
+            this.accountChips[this.accountChips.length - 1].focus();
           }
           break;
       }
     },
 
-    _handleChipKeydown: function(e) {
-      var chip = e.target;
-      var chips = this.accountChips;
-      var index = chips.indexOf(chip);
+    _handleChipKeydown(e) {
+      const chip = e.target;
+      const chips = this.accountChips;
+      const index = chips.indexOf(chip);
       switch (e.keyCode) {
         case 8: // Backspace
         case 13: // Enter
@@ -204,19 +249,35 @@
       }
     },
 
-    additions: function() {
-      return this.accounts.filter(function(account) {
+    /**
+     * Submit the text of the entry as a reviewer value, if it exists. If it is
+     * a successful submit of the text, clear the entry value.
+     *
+     * @return {boolean} If there is text in the entry, return true if the
+     *     submission was successful and false if not. If there is no text,
+     *     return true.
+     */
+    submitEntryText() {
+      const text = this.$.entry.getText();
+      if (!text.length) { return true; }
+      const wasSubmitted = this._addReviewer(text);
+      if (wasSubmitted) { this.$.entry.clear(); }
+      return wasSubmitted;
+    },
+
+    additions() {
+      return this.accounts.filter(account => {
         return account._pendingAdd;
-      }).map(function(account) {
+      }).map(account => {
         if (account._group) {
           return {group: account};
         } else {
-          return {account: account};
+          return {account};
         }
       });
     },
 
-    _computeEntryHidden: function(maxCount, accountsRecord, readonly) {
+    _computeEntryHidden(maxCount, accountsRecord, readonly) {
       return (maxCount && maxCount <= accountsRecord.base.length) || readonly;
     },
   });
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html
index b3c9e9e..5520254 100644
--- a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html
@@ -33,64 +33,65 @@
 </test-fixture>
 
 <script>
-  suite('gr-account-list tests', function() {
-    var _nextAccountId = 0;
-    var makeAccount = function() {
-      var accountId = ++_nextAccountId;
+  suite('gr-account-list tests', () => {
+    let _nextAccountId = 0;
+    const makeAccount = function() {
+      const accountId = ++_nextAccountId;
       return {
         _account_id: accountId,
       };
     };
-    var makeGroup = function() {
-      var groupId = 'group' + (++_nextAccountId);
+    const makeGroup = function() {
+      const groupId = 'group' + (++_nextAccountId);
       return {
         id: groupId,
+        _group: true,
       };
     };
 
-    var existingReviewer1;
-    var existingReviewer2;
-    var sandbox;
-    var element;
+    let existingReviewer1;
+    let existingReviewer2;
+    let sandbox;
+    let element;
 
     function getChips() {
       return Polymer.dom(element.root).querySelectorAll('gr-account-chip');
     }
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
       existingReviewer1 = makeAccount();
       existingReviewer2 = makeAccount();
 
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
+        getConfig() { return Promise.resolve({}); },
       });
       element = fixture('basic');
       element.accounts = [existingReviewer1, existingReviewer2];
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('account entry only appears when editable', function() {
+    test('account entry only appears when editable', () => {
       element.readonly = false;
       assert.isFalse(element.$.entry.hasAttribute('hidden'));
       element.readonly = true;
       assert.isTrue(element.$.entry.hasAttribute('hidden'));
     });
 
-    test('addition and removal of account/group chips', function() {
+    test('addition and removal of account/group chips', () => {
       flushAsynchronousOperations();
       sandbox.stub(element, '_computeRemovable').returns(true);
       // Existing accounts are listed.
-      var chips = getChips();
+      let chips = getChips();
       assert.equal(chips.length, 2);
       assert.isFalse(chips[0].classList.contains('pendingAdd'));
       assert.isFalse(chips[1].classList.contains('pendingAdd'));
 
       // New accounts are added to end with pendingAdd class.
-      var newAccount = makeAccount();
+      const newAccount = makeAccount();
       element._handleAdd({
         detail: {
           value: {
@@ -122,7 +123,7 @@
       assert.isFalse(chips[0].classList.contains('pendingAdd'));
 
       // New groups are added to end with pendingAdd and group classes.
-      var newGroup = makeGroup();
+      const newGroup = makeGroup();
       element._handleAdd({
         detail: {
           value: {
@@ -144,8 +145,8 @@
       assert.isFalse(chips[0].classList.contains('pendingAdd'));
     });
 
-    test('_computeChipClass', function() {
-      var account = makeAccount();
+    test('_computeChipClass', () => {
+      const account = makeAccount();
       assert.equal(element._computeChipClass(account), '');
       account._pendingAdd = true;
       assert.equal(element._computeChipClass(account), 'pendingAdd');
@@ -155,8 +156,8 @@
       assert.equal(element._computeChipClass(account), 'group');
     });
 
-    test('_computeRemovable', function() {
-      var newAccount = makeAccount();
+    test('_computeRemovable', () => {
+      const newAccount = makeAccount();
       newAccount._pendingAdd = true;
       element.readonly = false;
       element.removableValues = [];
@@ -174,10 +175,34 @@
       assert.isFalse(element._computeRemovable(newAccount));
     });
 
-    test('additions returns sanitized new accounts and groups', function() {
+    test('submitEntryText', () => {
+      element.allowAnyInput = true;
+      flushAsynchronousOperations();
+
+      const getTextStub = sandbox.stub(element.$.entry, 'getText');
+      getTextStub.onFirstCall().returns('');
+      getTextStub.onSecondCall().returns('test');
+      getTextStub.onThirdCall().returns('test@test');
+
+      // When entry is empty, return true.
+      const clearStub = sandbox.stub(element.$.entry, 'clear');
+      assert.isTrue(element.submitEntryText());
+      assert.isFalse(clearStub.called);
+
+      // When entry is invalid, return false.
+      assert.isFalse(element.submitEntryText());
+      assert.isFalse(clearStub.called);
+
+      // When entry is valid, return true and clear text.
+      assert.isTrue(element.submitEntryText());
+      assert.isTrue(clearStub.called);
+      assert.equal(element.additions()[0].account.email, 'test@test');
+    });
+
+    test('additions returns sanitized new accounts and groups', () => {
       assert.equal(element.additions().length, 0);
 
-      var newAccount = makeAccount();
+      const newAccount = makeAccount();
       element._handleAdd({
         detail: {
           value: {
@@ -185,7 +210,7 @@
           },
         },
       });
-      var newGroup = makeGroup();
+      const newGroup = makeGroup();
       element._handleAdd({
         detail: {
           value: {
@@ -211,13 +236,13 @@
       ]);
     });
 
-    test('large group confirmations', function() {
+    test('large group confirmations', () => {
       assert.isNull(element.pendingConfirmation);
       assert.deepEqual(element.additions(), []);
 
-      var group = makeGroup();
-      var reviewer = {
-        group: group,
+      const group = makeGroup();
+      const reviewer = {
+        group,
         count: 10,
         confirm: true,
       };
@@ -244,17 +269,17 @@
       ]);
     });
 
-    test('removeAccount fails if account is not removable', function() {
+    test('removeAccount fails if account is not removable', () => {
       element.readonly = true;
-      var acct = makeAccount();
+      const acct = makeAccount();
       element.accounts = [acct];
       element._removeAccount(acct);
       assert.equal(element.accounts.length, 1);
     });
 
-    test('max-count', function() {
+    test('max-count', () => {
       element.maxCount = 1;
-      var acct = makeAccount();
+      const acct = makeAccount();
       element._handleAdd({
         detail: {
           value: {
@@ -266,10 +291,47 @@
       assert.isTrue(element.$.entry.hasAttribute('hidden'));
     });
 
-    suite('keyboard interactions', function() {
+    suite('allowAnyInput', () => {
+      let entry;
 
-      test('backspace at text input start removes last account', function() {
-        var input = element.$.entry.$.input;
+      setup(() => {
+        entry = element.$.entry;
+        sandbox.stub(entry, '_getReviewerSuggestions');
+        sandbox.stub(entry.$.input, '_updateSuggestions');
+        element.allowAnyInput = true;
+      });
+
+      test('adds emails', () => {
+        const accountLen = element.accounts.length;
+        element._handleAdd({detail: {value: 'test@test'}});
+        assert.equal(element.accounts.length, accountLen + 1);
+        assert.equal(element.accounts[accountLen].email, 'test@test');
+      });
+
+      test('toasts on invalid email', () => {
+        const toastHandler = sandbox.stub();
+        element.addEventListener('show-alert', toastHandler);
+        element._handleAdd({detail: {value: 'test'}});
+        assert.isTrue(toastHandler.called);
+      });
+    });
+
+    test('_accountMatches', () => {
+      const acct = makeAccount();
+
+      assert.isTrue(element._accountMatches(acct, acct));
+      acct.email = 'test';
+      assert.isTrue(element._accountMatches(acct, acct));
+      assert.isTrue(element._accountMatches({email: 'test'}, acct));
+
+      assert.isFalse(element._accountMatches({}, acct));
+      assert.isFalse(element._accountMatches({email: 'test2'}, acct));
+      assert.isFalse(element._accountMatches({_account_id: -1}, acct));
+    });
+
+    suite('keyboard interactions', () => {
+      test('backspace at text input start removes last account', () => {
+        const input = element.$.entry.$.input;
         sandbox.stub(element.$.entry, '_getReviewerSuggestions');
         sandbox.stub(input, '_updateSuggestions');
         sandbox.stub(element, '_computeRemovable').returns(true);
@@ -287,17 +349,17 @@
         assert.equal(element.accounts.length, 1);
       });
 
-      test('arrow key navigation', function() {
-        var input = element.$.entry.$.input;
+      test('arrow key navigation', () => {
+        const input = element.$.entry.$.input;
         input.text = '';
         element.accounts = [makeAccount(), makeAccount()];
         MockInteractions.focus(input.$.input);
         flushAsynchronousOperations();
-        var chips = element.accountChips;
-        var chipsOneSpy = sandbox.spy(chips[1], 'focus');
+        const chips = element.accountChips;
+        const chipsOneSpy = sandbox.spy(chips[1], 'focus');
         MockInteractions.pressAndReleaseKeyOn(input.$.input, 37); // Left
         assert.isTrue(chipsOneSpy.called);
-        var chipsZeroSpy = sandbox.spy(chips[0], 'focus');
+        const chipsZeroSpy = sandbox.spy(chips[0], 'focus');
         MockInteractions.pressAndReleaseKeyOn(chips[1], 37); // Left
         assert.isTrue(chipsZeroSpy.called);
         MockInteractions.pressAndReleaseKeyOn(chips[0], 37); // Left
@@ -306,12 +368,11 @@
         assert.isTrue(chipsOneSpy.calledTwice);
       });
 
-      test('delete', function(done) {
+      test('delete', done => {
         element.accounts = [makeAccount(), makeAccount()];
-        flush(function() {
-          var chips = element.accountChips;
-          var focusSpy = sandbox.spy(element.accountChips[1], 'focus');
-          var removeSpy = sandbox.spy(element, '_removeAccount');
+        flush(() => {
+          const focusSpy = sandbox.spy(element.accountChips[1], 'focus');
+          const removeSpy = sandbox.spy(element, '_removeAccount');
           MockInteractions.pressAndReleaseKeyOn(
               element.accountChips[0], 8); // Backspace
           assert.isTrue(focusSpy.called);
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 71ccb04..894ca63 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
@@ -14,6 +14,7 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-input/iron-input.html">
@@ -81,8 +82,7 @@
           down-arrow
           vertical-offset="32"
           horizontal-align="right"
-          on-tap-item-cherrypick="_handleCherrypickTap"
-          on-tap-item-delete="_handleDeleteTap"
+          on-tap-item="_handleOveflowItemTap"
           hidden$="[[_shouldHideActions(_menuActions.*, _loading)]]"
           disabled-ids="[[_disabledMenuActions]]"
           items="[[_menuActions]]">More</gr-dropdown>
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 2b0916d..e901eab 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
@@ -17,7 +17,7 @@
   /**
    * @enum {number}
    */
-  var LabelStatus = {
+  const LabelStatus = {
     /**
      * This label provides what is necessary for submission.
      */
@@ -44,41 +44,49 @@
   };
 
   // TODO(davido): Add the rest of the change actions.
-  var ChangeActions = {
+  const ChangeActions = {
     ABANDON: 'abandon',
     DELETE: '/',
+    IGNORE: 'ignore',
+    MUTE: 'mute',
+    PRIVATE: 'private',
+    PRIVATE_DELETE: 'private.delete',
     RESTORE: 'restore',
     REVERT: 'revert',
+    UNIGNORE: 'unignore',
+    UNMUTE: 'unmute',
+    WIP: 'wip',
   };
 
   // TODO(andybons): Add the rest of the revision actions.
-  var RevisionActions = {
+  const RevisionActions = {
     CHERRYPICK: 'cherrypick',
     DELETE: '/',
     PUBLISH: 'publish',
     REBASE: 'rebase',
     SUBMIT: 'submit',
+    DOWNLOAD: 'download',
   };
 
-  var ActionLoadingLabels = {
-    'abandon': 'Abandoning...',
-    'cherrypick': 'Cherry-Picking...',
-    'delete': 'Deleting...',
-    'publish': 'Publishing...',
-    'rebase': 'Rebasing...',
-    'restore': 'Restoring...',
-    'revert': 'Reverting...',
-    'submit': 'Submitting...',
+  const ActionLoadingLabels = {
+    abandon: 'Abandoning...',
+    cherrypick: 'Cherry-Picking...',
+    delete: 'Deleting...',
+    publish: 'Publishing...',
+    rebase: 'Rebasing...',
+    restore: 'Restoring...',
+    revert: 'Reverting...',
+    submit: 'Submitting...',
   };
 
-  var ActionType = {
+  const ActionType = {
     CHANGE: 'change',
     REVISION: 'revision',
   };
 
-  var ADDITIONAL_ACTION_KEY_PREFIX = '__additionalAction_';
+  const ADDITIONAL_ACTION_KEY_PREFIX = '__additionalAction_';
 
-  var QUICK_APPROVE_ACTION = {
+  const QUICK_APPROVE_ACTION = {
     __key: 'review',
     __type: 'change',
     enabled: true,
@@ -87,14 +95,22 @@
     method: 'POST',
   };
 
-  /**
-   * Keys for actions to appear in the overflow menu rather than the top-level
-   * set of action buttons.
-   */
-  var MENU_ACTION_KEYS = [
-    'cherrypick',
-    '/', // '/' is the key for the delete action.
-  ];
+  const ActionPriority = {
+    CHANGE: 2,
+    DEFAULT: 0,
+    PRIMARY: 3,
+    REVIEW: -3,
+    REVISION: 1,
+  };
+
+  const DOWNLOAD_ACTION = {
+    enabled: true,
+    label: 'Download patch',
+    title: 'Open download dialog',
+    __key: 'download',
+    __primary: false,
+    __type: 'revision',
+  };
 
   Polymer({
     is: 'gr-change-actions',
@@ -111,15 +127,21 @@
      * @event <action key>-tap
      */
 
+    /**
+     * Fires to show an alert when a send is attempted on the non-latest patch.
+     *
+     * @event show-alert
+     */
+
     properties: {
       change: Object,
       actions: {
         type: Object,
-        value: function() { return {}; },
+        value() { return {}; },
       },
       primaryActionKeys: {
         type: Array,
-        value: function() {
+        value() {
           return [
             RevisionActions.PUBLISH,
             RevisionActions.SUBMIT,
@@ -144,7 +166,7 @@
       },
       revisionActions: {
         type: Object,
-        value: function() { return {}; },
+        value() { return {}; },
       },
 
       _loading: {
@@ -158,36 +180,95 @@
       _allActionValues: {
         type: Array,
         computed: '_computeAllActions(actions.*, revisionActions.*,' +
-            'primaryActionKeys.*, _additionalActions.*, change)',
+            'primaryActionKeys.*, _additionalActions.*, change, ' +
+            '_actionPriorityOverrides.*)',
       },
       _topLevelActions: {
         type: Array,
         computed: '_computeTopLevelActions(_allActionValues.*, ' +
-            '_hiddenActions.*)',
+            '_hiddenActions.*, _overflowActions.*)',
       },
       _menuActions: {
         type: Array,
-        computed: '_computeMenuActions(_allActionValues.*, _hiddenActions.*)',
+        computed: '_computeMenuActions(_allActionValues.*, _hiddenActions.*, ' +
+            '_overflowActions.*)',
+      },
+      _overflowActions: {
+        type: Array,
+        value() {
+          const value = [
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.WIP,
+            },
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.DELETE,
+            },
+            {
+              type: ActionType.REVISION,
+              key: RevisionActions.DELETE,
+            },
+            {
+              type: ActionType.REVISION,
+              key: RevisionActions.CHERRYPICK,
+            },
+            {
+              type: ActionType.REVISION,
+              key: RevisionActions.DOWNLOAD,
+            },
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.IGNORE,
+            },
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.UNIGNORE,
+            },
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.MUTE,
+            },
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.UNMUTE,
+            },
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.PRIVATE,
+            },
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.PRIVATE_DELETE,
+            },
+          ];
+          return value;
+        },
+      },
+      _actionPriorityOverrides: {
+        type: Array,
+        value() { return []; },
       },
       _additionalActions: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       _hiddenActions: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       _disabledMenuActions: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
     },
 
-    ActionType: ActionType,
-    ChangeActions: ChangeActions,
-    RevisionActions: RevisionActions,
+    ActionType,
+    ChangeActions,
+    RevisionActions,
 
     behaviors: [
+      Gerrit.PatchSetBehavior,
       Gerrit.RESTClientBehavior,
     ],
 
@@ -195,53 +276,54 @@
       '_actionsChanged(actions.*, revisionActions.*, _additionalActions.*)',
     ],
 
-    ready: function() {
+    ready() {
       this.$.jsAPI.addElement(this.$.jsAPI.Element.CHANGE_ACTIONS, this);
       this._loading = false;
     },
 
-    reload: function() {
+    reload() {
       if (!this.changeNum || !this.patchNum) {
         return Promise.resolve();
       }
 
       this._loading = true;
-      return this._getRevisionActions().then(function(revisionActions) {
+      return this._getRevisionActions().then(revisionActions => {
         if (!revisionActions) { return; }
 
         this.revisionActions = revisionActions;
         this._loading = false;
-      }.bind(this)).catch(function(err) {
+      }).catch(err => {
         alert('Couldn’t load revision actions. Check the console ' +
             'and contact the PolyGerrit team for assistance.');
         this._loading = false;
         throw err;
-      }.bind(this));
+      });
     },
 
-    addActionButton: function(type, label) {
+    addActionButton(type, label) {
       if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
-        throw Error('Invalid action type: ' + type);
+        throw Error(`Invalid action type: ${type}`);
       }
-      var action = {
+      const action = {
         enabled: true,
-        label: label,
+        label,
         __type: type,
-        __key: ADDITIONAL_ACTION_KEY_PREFIX + Math.random().toString(36),
+        __key: ADDITIONAL_ACTION_KEY_PREFIX +
+            Math.random().toString(36).substr(2),
       };
       this.push('_additionalActions', action);
       return action.__key;
     },
 
-    removeActionButton: function(key) {
-      var idx = this._indexOfActionButtonWithKey(key);
+    removeActionButton(key) {
+      const idx = this._indexOfActionButtonWithKey(key);
       if (idx === -1) {
         return;
       }
       this.splice('_additionalActions', idx, 1);
     },
 
-    setActionButtonProp: function(key, prop, value) {
+    setActionButtonProp(key, prop, value) {
       this.set([
         '_additionalActions',
         this._indexOfActionButtonWithKey(key),
@@ -249,12 +331,48 @@
       ], value);
     },
 
-    setActionHidden: function(type, key, hidden) {
+    setActionOverflow(type, key, overflow) {
       if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
-        throw Error('Invalid action type given: ' + type);
+        throw Error(`Invalid action type given: ${type}`);
+      }
+      const index = this._getActionOverflowIndex(type, key);
+      const action = {
+        type,
+        key,
+        overflow,
+      };
+      if (!overflow && index !== -1) {
+        this.splice('_overflowActions', index, 1);
+      } else if (overflow) {
+        this.push('_overflowActions', action);
+      }
+    },
+
+    setActionPriority(type, key, priority) {
+      if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+        throw Error(`Invalid action type given: ${type}`);
+      }
+      const index = this._actionPriorityOverrides.findIndex(action => {
+        return action.type === type && action.key === key;
+      });
+      const action = {
+        type,
+        key,
+        priority,
+      };
+      if (index !== -1) {
+        this.set('_actionPriorityOverrides', index, action);
+      } else {
+        this.push('_actionPriorityOverrides', action);
+      }
+    },
+
+    setActionHidden(type, key, hidden) {
+      if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+        throw Error(`Invalid action type given: ${type}`);
       }
 
-      var idx = this._hiddenActions.indexOf(key);
+      const idx = this._hiddenActions.indexOf(key);
       if (hidden && idx === -1) {
         this.push('_hiddenActions', key);
       } else if (!hidden && idx !== -1) {
@@ -262,8 +380,8 @@
       }
     },
 
-    _indexOfActionButtonWithKey: function(key) {
-      for (var i = 0; i < this._additionalActions.length; i++) {
+    _indexOfActionButtonWithKey(key) {
+      for (let i = 0; i < this._additionalActions.length; i++) {
         if (this._additionalActions[i].__key === key) {
           return i;
         }
@@ -271,37 +389,43 @@
       return -1;
     },
 
-    _getRevisionActions: function() {
+    _getRevisionActions() {
       return this.$.restAPI.getChangeRevisionActions(this.changeNum,
           this.patchNum);
     },
 
-    _shouldHideActions: function(actions, loading) {
+    _shouldHideActions(actions, loading) {
       return loading || !actions || !actions.base || !actions.base.length;
     },
 
-    _keyCount: function(changeRecord) {
+    _keyCount(changeRecord) {
       return Object.keys((changeRecord && changeRecord.base) || {}).length;
     },
 
-    _actionsChanged: function(actionsChangeRecord, revisionActionsChangeRecord,
+    _actionsChanged(actionsChangeRecord, revisionActionsChangeRecord,
         additionalActionsChangeRecord) {
-      var additionalActions = (additionalActionsChangeRecord &&
+      const additionalActions = (additionalActionsChangeRecord &&
           additionalActionsChangeRecord.base) || [];
       this.hidden = this._keyCount(actionsChangeRecord) === 0 &&
           this._keyCount(revisionActionsChangeRecord) === 0 &&
               additionalActions.length === 0;
       this._actionLoadingMessage = null;
       this._disabledMenuActions = [];
+
+      const revisionActions = revisionActionsChangeRecord.base || {};
+      if (Object.keys(revisionActions).length !== 0 &&
+          !revisionActions.download) {
+        this.set('revisionActions.download', DOWNLOAD_ACTION);
+      }
     },
 
-    _getValuesFor: function(obj) {
-      return Object.keys(obj).map(function(key) {
+    _getValuesFor(obj) {
+      return Object.keys(obj).map(key => {
         return obj[key];
       });
     },
 
-    _getLabelStatus: function(label) {
+    _getLabelStatus(label) {
       if (label.approved) {
         return LabelStatus.OK;
       } else if (label.rejected) {
@@ -319,21 +443,21 @@
      *
      * @return {{label: string, score: string}}
      */
-    _getTopMissingApproval: function() {
+    _getTopMissingApproval() {
       if (!this.change ||
           !this.change.labels ||
           !this.change.permitted_labels) {
         return null;
       }
-      var result;
-      for (var label in this.change.labels) {
+      let result;
+      for (const label in this.change.labels) {
         if (!(label in this.change.permitted_labels)) {
           continue;
         }
         if (this.change.permitted_labels[label].length === 0) {
           continue;
         }
-        var status = this._getLabelStatus(this.change.labels[label]);
+        const status = this._getLabelStatus(this.change.labels[label]);
         if (status === LabelStatus.NEED) {
           if (result) {
             // More than one label is missing, so it's unclear which to quick
@@ -342,33 +466,33 @@
           }
           result = label;
         } else if (status === LabelStatus.REJECT ||
-                   status === LabelStatus.IMPOSSIBLE) {
+            status === LabelStatus.IMPOSSIBLE) {
           return null;
         }
       }
       if (result) {
-        var score = this.change.permitted_labels[result].slice(-1)[0];
-        var maxScore =
+        const score = this.change.permitted_labels[result].slice(-1)[0];
+        const maxScore =
             Object.keys(this.change.labels[result].values).slice(-1)[0];
         if (score === maxScore) {
           // Allow quick approve only for maximal score.
           return {
             label: result,
-            score: score,
+            score,
           };
         }
       }
       return null;
     },
 
-    _getQuickApproveAction: function() {
-      var approval = this._getTopMissingApproval();
+    _getQuickApproveAction() {
+      const approval = this._getTopMissingApproval();
       if (!approval) {
         return null;
       }
-      var action = Object.assign({}, QUICK_APPROVE_ACTION);
+      const action = Object.assign({}, QUICK_APPROVE_ACTION);
       action.label = approval.label + approval.score;
-      var review = {
+      const review = {
         drafts: 'PUBLISH_ALL_REVISIONS',
         labels: {},
       };
@@ -377,20 +501,20 @@
       return action;
     },
 
-    _getActionValues: function(actionsChangeRecord, primariesChangeRecord,
+    _getActionValues(actionsChangeRecord, primariesChangeRecord,
         additionalActionsChangeRecord, type) {
       if (!actionsChangeRecord || !primariesChangeRecord) { return []; }
 
-      var actions = actionsChangeRecord.base || {};
-      var primaryActionKeys = primariesChangeRecord.base || [];
-      var result = [];
-      var values = this._getValuesFor(
+      const actions = actionsChangeRecord.base || {};
+      const primaryActionKeys = primariesChangeRecord.base || [];
+      const result = [];
+      const values = this._getValuesFor(
           type === ActionType.CHANGE ? ChangeActions : RevisionActions);
-      for (var a in actions) {
-        if (values.indexOf(a) === -1) { continue; }
+      for (const a in actions) {
+        if (!values.includes(a)) { continue; }
         actions[a].__key = a;
         actions[a].__type = type;
-        actions[a].__primary = primaryActionKeys.indexOf(a) !== -1;
+        actions[a].__primary = primaryActionKeys.includes(a);
         if (actions[a].label === 'Delete') {
           // This label is common within change and revision actions. Make it
           // more explicit to the user.
@@ -405,32 +529,30 @@
         result.push(Object.assign({}, actions[a]));
       }
 
-      var additionalActions = (additionalActionsChangeRecord &&
+      let additionalActions = (additionalActionsChangeRecord &&
       additionalActionsChangeRecord.base) || [];
-      additionalActions = additionalActions.filter(function(a) {
+      additionalActions = additionalActions.filter(a => {
         return a.__type === type;
-      }).map(function(a) {
-        a.__primary = primaryActionKeys.indexOf(a.__key) !== -1;
+      }).map(a => {
+        a.__primary = primaryActionKeys.includes(a.__key);
         // Triggers a re-render by ensuring object inequality.
-        // TODO(andybons): Polyfill for Object.assign.
         return Object.assign({}, a);
       });
       return result.concat(additionalActions);
     },
 
-    _computeLoadingLabel: function(action) {
+    _computeLoadingLabel(action) {
       return ActionLoadingLabels[action] || 'Working...';
     },
 
-    _canSubmitChange: function() {
+    _canSubmitChange() {
       return this.$.jsAPI.canSubmitChange(this.change,
           this._getRevision(this.change, this.patchNum));
     },
 
-    _getRevision: function(change, patchNum) {
-      var num = window.parseInt(patchNum, 10);
-      for (var hash in change.revisions) {
-        var rev = change.revisions[hash];
+    _getRevision(change, patchNum) {
+      const num = window.parseInt(patchNum, 10);
+      for (const rev of Object.values(change.revisions)) {
         if (rev._number === num) {
           return rev;
         }
@@ -438,62 +560,101 @@
       return null;
     },
 
-    _modifyRevertMsg: function() {
+    _modifyRevertMsg() {
       return this.$.jsAPI.modifyRevertMsg(this.change,
           this.$.confirmRevertDialog.message, this.commitMessage);
     },
 
-    showRevertDialog: function() {
+    showRevertDialog() {
       this.$.confirmRevertDialog.populateRevertMessage(
           this.commitMessage, this.change.current_revision);
       this.$.confirmRevertDialog.message = this._modifyRevertMsg();
       this._showActionDialog(this.$.confirmRevertDialog);
     },
 
-    _handleActionTap: function(e) {
+    _handleActionTap(e) {
       e.preventDefault();
-      var el = Polymer.dom(e).rootTarget;
-      var key = el.getAttribute('data-action-key');
-      if (key.indexOf(ADDITIONAL_ACTION_KEY_PREFIX) === 0) {
-        this.fire(key + '-tap', {node: el});
+      const el = Polymer.dom(e).rootTarget;
+      const key = el.getAttribute('data-action-key');
+      if (key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX)) {
+        this.fire(`${key}-tap`, {node: el});
         return;
       }
-      var type = el.getAttribute('data-action-type');
-      if (type === ActionType.REVISION) {
-        this._handleRevisionAction(key);
-      } else if (key === ChangeActions.REVERT) {
-        this.showRevertDialog();
-      } else if (key === ChangeActions.ABANDON) {
-        this._showActionDialog(this.$.confirmAbandonDialog);
-      } else if (key === QUICK_APPROVE_ACTION.key) {
-        var action = this._allActionValues.find(function(o) {
-          return o.key === key;
-        });
-        this._fireAction(
-            this._prependSlash(key), action, true, action.payload);
-      } else {
-        this._fireAction(this._prependSlash(key), this.actions[key], false);
+      const type = el.getAttribute('data-action-type');
+      this._handleAction(type, key);
+    },
+
+    _handleOveflowItemTap(e) {
+      this._handleAction(e.detail.action.__type, e.detail.action.__key);
+    },
+
+    _handleAction(type, key) {
+      switch (type) {
+        case ActionType.REVISION:
+          this._handleRevisionAction(key);
+          break;
+        case ActionType.CHANGE:
+          this._handleChangeAction(key);
+          break;
+        default:
+          this._fireAction(this._prependSlash(key), this.actions[key], false);
       }
     },
 
-    _handleRevisionAction: function(key) {
+    _handleChangeAction(key) {
+      let action;
+      switch (key) {
+        case ChangeActions.REVERT:
+          this.showRevertDialog();
+          break;
+        case ChangeActions.ABANDON:
+          this._showActionDialog(this.$.confirmAbandonDialog);
+          break;
+        case QUICK_APPROVE_ACTION.key:
+          action = this._allActionValues.find(o => {
+            return o.key === key;
+          });
+          this._fireAction(
+              this._prependSlash(key), action, true, action.payload);
+          break;
+        case ChangeActions.DELETE:
+          this._handleDeleteTap();
+          break;
+        case ChangeActions.WIP:
+          this._handleWipTap();
+          break;
+        default:
+          this._fireAction(this._prependSlash(key), this.actions[key], false);
+      }
+    },
+
+    _handleRevisionAction(key) {
       switch (key) {
         case RevisionActions.REBASE:
           this._showActionDialog(this.$.confirmRebase);
           break;
+        case RevisionActions.DELETE:
+          this._handleDeleteConfirm();
+          break;
+        case RevisionActions.CHERRYPICK:
+          this._handleCherrypickTap();
+          break;
+        case RevisionActions.DOWNLOAD:
+          this._handleDownloadTap();
+          break;
         case RevisionActions.SUBMIT:
           if (!this._canSubmitChange()) {
             return;
           }
-        /* falls through */ // required by JSHint
+        // eslint-disable-next-line no-fallthrough
         default:
           this._fireAction(this._prependSlash(key),
               this.revisionActions[key], true);
       }
     },
 
-    _prependSlash: function(key) {
-      return key === '/' ? key : '/' + key;
+    _prependSlash(key) {
+      return key === '/' ? key : `/${key}`;
     },
 
     /**
@@ -501,40 +662,38 @@
      * returns false otherwise.
      * @return {boolean} hasParent
      */
-    _computeChainState: function(hasParent) {
+    _computeChainState(hasParent) {
       this._hasKnownChainState = true;
     },
 
-    _calculateDisabled: function(action, hasKnownChainState) {
+    _calculateDisabled(action, hasKnownChainState) {
       if (action.__key === 'rebase' && hasKnownChainState === false) {
         return true;
       }
       return !action.enabled;
     },
 
-    _handleConfirmDialogCancel: function() {
+    _handleConfirmDialogCancel() {
       this._hideAllDialogs();
     },
 
-    _hideAllDialogs: function() {
-      var dialogEls =
+    _hideAllDialogs() {
+      const dialogEls =
           Polymer.dom(this.root).querySelectorAll('.confirmDialog');
-      for (var i = 0; i < dialogEls.length; i++) {
-        dialogEls[i].hidden = true;
-      }
+      for (const dialogEl of dialogEls) { dialogEl.hidden = true; }
       this.$.overlay.close();
     },
 
-    _handleRebaseConfirm: function() {
-      var el = this.$.confirmRebase;
-      var payload = {base: el.base};
+    _handleRebaseConfirm() {
+      const el = this.$.confirmRebase;
+      const payload = {base: el.base};
       this.$.overlay.close();
       el.hidden = true;
       this._fireAction('/rebase', this.revisionActions.rebase, true, payload);
     },
 
-    _handleCherrypickConfirm: function() {
-      var el = this.$.confirmCherrypick;
+    _handleCherrypickConfirm() {
+      const el = this.$.confirmCherrypick;
       if (!el.branch) {
         // TODO(davido): Fix error handling
         alert('The destination branch can’t be empty.');
@@ -557,31 +716,37 @@
       );
     },
 
-    _handleRevertDialogConfirm: function() {
-      var el = this.$.confirmRevertDialog;
+    _handleRevertDialogConfirm() {
+      const el = this.$.confirmRevertDialog;
       this.$.overlay.close();
       el.hidden = true;
       this._fireAction('/revert', this.actions.revert, false,
           {message: el.message});
     },
 
-    _handleAbandonDialogConfirm: function() {
-      var el = this.$.confirmAbandonDialog;
+    _handleAbandonDialogConfirm() {
+      const el = this.$.confirmAbandonDialog;
       this.$.overlay.close();
       el.hidden = true;
       this._fireAction('/abandon', this.actions.abandon, false,
           {message: el.message});
     },
 
-    _handleDeleteConfirm: function() {
+    _handleDeleteConfirm() {
       this._fireAction('/', this.actions[ChangeActions.DELETE], false);
     },
 
-    _setLoadingOnButtonWithKey: function(key) {
+    _getActionOverflowIndex(type, key) {
+      return this._overflowActions.findIndex(action => {
+        return action.type === type && action.key === key;
+      });
+    },
+
+    _setLoadingOnButtonWithKey(type, key) {
       this._actionLoadingMessage = this._computeLoadingLabel(key);
 
       // If the action appears in the overflow menu.
-      if (MENU_ACTION_KEYS.indexOf(key) !== -1) {
+      if (this._getActionOverflowIndex(type, key) !== -1) {
         this.push('_disabledMenuActions', key === '/' ? 'delete' : key);
         return function() {
           this._actionLoadingMessage = null;
@@ -590,7 +755,7 @@
       }
 
       // Otherwise it's a top-level action.
-      var buttonEl = this.$$('[data-action-key="' + key + '"]');
+      const buttonEl = this.$$(`[data-action-key="${key}"]`);
       buttonEl.setAttribute('loading', true);
       buttonEl.disabled = true;
       return function() {
@@ -600,18 +765,18 @@
       }.bind(this);
     },
 
-    _fireAction: function(endpoint, action, revAction, opt_payload) {
-      var cleanupFn = this._setLoadingOnButtonWithKey(action.__key);
-
+    _fireAction(endpoint, action, revAction, opt_payload) {
+      const cleanupFn =
+          this._setLoadingOnButtonWithKey(action.__type, action.__key);
       this._send(action.method, opt_payload, endpoint, revAction, cleanupFn)
           .then(this._handleResponse.bind(this, action));
     },
 
-    _showActionDialog: function(dialog) {
+    _showActionDialog(dialog) {
       this._hideAllDialogs();
 
       dialog.hidden = false;
-      this.$.overlay.open().then(function() {
+      this.$.overlay.open().then(() => {
         if (dialog.resetFocus) {
           dialog.resetFocus();
         }
@@ -620,74 +785,103 @@
 
     // TODO(rmistry): Redo this after
     // https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved.
-    _setLabelValuesOnRevert: function(newChangeId) {
-      var labels = this.$.jsAPI.getLabelValuesPostRevert(this.change);
+    _setLabelValuesOnRevert(newChangeId) {
+      const labels = this.$.jsAPI.getLabelValuesPostRevert(this.change);
       if (labels) {
-        var url = '/changes/' + newChangeId + '/revisions/current/review';
-        this.$.restAPI.send(this.actions.revert.method, url, {labels: labels});
+        const url = `/changes/${newChangeId}/revisions/current/review`;
+        this.$.restAPI.send(this.actions.revert.method, url, {labels});
       }
     },
 
-    _handleResponse: function(action, response) {
+    _handleResponse(action, response) {
       if (!response) { return; }
-      return this.$.restAPI.getResponseObject(response).then(function(obj) {
-          switch (action.__key) {
-            case ChangeActions.REVERT:
-              this._setLabelValuesOnRevert(obj.change_id);
-              /* falls through */
-            case RevisionActions.CHERRYPICK:
-              page.show(this.changePath(obj._number));
-              break;
-            case ChangeActions.DELETE:
-            case RevisionActions.DELETE:
-              if (action.__type === ActionType.CHANGE) {
-                page.show('/');
-              } else {
-                page.show(this.changePath(this.changeNum));
-              }
-              break;
-            default:
-              this.dispatchEvent(new CustomEvent('reload-change',
-                  {detail: {action: action.__key}, bubbles: false}));
-              break;
-          }
-      }.bind(this));
+      return this.$.restAPI.getResponseObject(response).then(obj => {
+        switch (action.__key) {
+          case ChangeActions.REVERT:
+            this._setLabelValuesOnRevert(obj.change_id);
+            /* falls through */
+          case RevisionActions.CHERRYPICK:
+            page.show(this.changePath(obj._number));
+            break;
+          case ChangeActions.DELETE:
+          case RevisionActions.DELETE:
+            if (action.__type === ActionType.CHANGE) {
+              page.show('/');
+            } else {
+              page.show(this.changePath(this.changeNum));
+            }
+            break;
+          case ChangeActions.WIP:
+            page.show(this.changePath(this.changeNum));
+            break;
+          default:
+            this.dispatchEvent(new CustomEvent('reload-change',
+                {detail: {action: action.__key}, bubbles: false}));
+            break;
+        }
+      });
     },
 
-    _handleResponseError: function(response) {
-      return response.text().then(function(errText) {
+    _handleResponseError(response) {
+      return response.text().then(errText => {
         this.fire('show-alert',
-            { message: 'Could not perform action: ' + errText });
-        if (errText.indexOf('Change is already up to date') !== 0) {
+            {message: `Could not perform action: ${errText}`});
+        if (!errText.startsWith('Change is already up to date')) {
           throw Error(errText);
         }
-      }.bind(this));
+      });
     },
 
-    _send: function(method, payload, actionEndpoint, revisionAction,
-        cleanupFn, opt_errorFn) {
-      var url = this.$.restAPI.getChangeActionURL(this.changeNum,
-          revisionAction ? this.patchNum : null, actionEndpoint);
-      return this.$.restAPI.send(method, url, payload,
-          this._handleResponseError, this).then(function(response) {
-            cleanupFn.call(this);
-            return response;
-      }.bind(this));
+    _send(method, payload, actionEndpoint, revisionAction, cleanupFn,
+        opt_errorFn) {
+      return this.fetchIsLatestKnown(this.change, this.$.restAPI)
+          .then(isLatest => {
+            if (!isLatest) {
+              this.fire('show-alert', {
+                message: 'Cannot set label: a newer patch has been ' +
+                    'uploaded to this change.',
+                action: 'Reload',
+                callback: () => {
+                    // Load the current change without any patch range.
+                  location.href = `${this.getBaseUrl()}/c/${
+                      this.change._number}`;
+                },
+              });
+              cleanupFn();
+              return Promise.resolve();
+            }
+
+            const url = this.$.restAPI.getChangeActionURL(this.changeNum,
+                revisionAction ? this.patchNum : null, actionEndpoint);
+            return this.$.restAPI.send(method, url, payload,
+                this._handleResponseError, this).then(response => {
+                  cleanupFn.call(this);
+                  return response;
+                });
+          });
     },
 
-    _handleAbandonTap: function() {
+    _handleAbandonTap() {
       this._showActionDialog(this.$.confirmAbandonDialog);
     },
 
-    _handleCherrypickTap: function() {
+    _handleCherrypickTap() {
       this.$.confirmCherrypick.branch = '';
       this._showActionDialog(this.$.confirmCherrypick);
     },
 
-    _handleDeleteTap: function() {
+    _handleDownloadTap() {
+      this.fire('download-tap', null, {bubbles: false});
+    },
+
+    _handleDeleteTap() {
       this._showActionDialog(this.$.confirmDeleteDialog);
     },
 
+    _handleWipTap() {
+      this._fireAction('/wip', this.actions.wip, false);
+    },
+
     /**
      * Merge sources of change actions into a single ordered array of action
      * values.
@@ -698,70 +892,79 @@
      * @param {Object} change The change object.
      * @return {Array}
      */
-    _computeAllActions: function(changeActionsRecord, revisionActionsRecord,
+    _computeAllActions(changeActionsRecord, revisionActionsRecord,
         primariesRecord, additionalActionsRecord, change) {
-      var revisionActionValues = this._getActionValues(revisionActionsRecord,
+      const revisionActionValues = this._getActionValues(revisionActionsRecord,
           primariesRecord, additionalActionsRecord, ActionType.REVISION);
-      var changeActionValues = this._getActionValues(changeActionsRecord,
+      const changeActionValues = this._getActionValues(changeActionsRecord,
           primariesRecord, additionalActionsRecord, ActionType.CHANGE, change);
-      var quickApprove = this._getQuickApproveAction();
+      const quickApprove = this._getQuickApproveAction();
       if (quickApprove) {
         changeActionValues.unshift(quickApprove);
       }
       return revisionActionValues
           .concat(changeActionValues)
-          .sort(this._actionComparator);
+          .sort(this._actionComparator.bind(this));
+    },
+
+    _getActionPriority(action) {
+      if (action.__type && action.__key) {
+        const overrideAction = this._actionPriorityOverrides.find(i => {
+          return i.type === action.__type && i.key === action.__key;
+        });
+
+        if (overrideAction !== undefined) {
+          return overrideAction.priority;
+        }
+      }
+      if (action.__key === 'review') {
+        return ActionPriority.REVIEW;
+      } else if (action.__primary) {
+        return ActionPriority.PRIMARY;
+      } else if (action.__type === ActionType.CHANGE) {
+        return ActionPriority.CHANGE;
+      } else if (action.__type === ActionType.REVISION) {
+        return ActionPriority.REVISION;
+      }
+      return ActionPriority.DEFAULT;
     },
 
     /**
      * Sort comparator to define the order of change actions.
      */
-    _actionComparator: function(actionA, actionB) {
-      // The code review action always appears first.
-      if (actionA.__key === 'review') {
-        return -1;
-      } else if (actionB.__key === 'review') {
-        return 1;
+    _actionComparator(actionA, actionB) {
+      const priorityDelta = this._getActionPriority(actionA) -
+          this._getActionPriority(actionB);
+      // Sort by the button label if same priority.
+      if (priorityDelta === 0) {
+        return actionA.label > actionB.label ? 1 : -1;
+      } else {
+        return priorityDelta;
       }
-
-      // Primary actions always appear last.
-      if (actionA.__primary) {
-        return 1;
-      } else if (actionB.__primary) {
-        return -1;
-      }
-
-      // Change actions appear before revision actions.
-     if (actionA.__type === 'change' && actionB.__type === 'revision') {
-        return 1;
-      } else if (actionA.__type === 'revision' && actionB.__type === 'change') {
-        return -1;
-      }
-
-      // Otherwise, sort by the button label.
-      return actionA.label > actionB.label ? 1 : -1;
     },
 
-    _computeTopLevelActions: function(actionRecord, hiddenActionsRecord) {
-      var hiddenActions = hiddenActionsRecord.base || [];
-      return actionRecord.base.filter(function(a) {
-        return MENU_ACTION_KEYS.indexOf(a.__key) === -1 &&
-                hiddenActions.indexOf(a.__key) === -1;
+    _computeTopLevelActions(actionRecord, hiddenActionsRecord) {
+      const hiddenActions = hiddenActionsRecord.base || [];
+      return actionRecord.base.filter(a => {
+        const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
+        return !(overflow || hiddenActions.includes(a.__key));
       });
     },
 
-    _computeMenuActions: function(actionRecord, hiddenActionsRecord) {
-      var hiddenActions = hiddenActionsRecord.base || [];
-      return actionRecord.base
-          .filter(function(a) {
-            return MENU_ACTION_KEYS.indexOf(a.__key) !== -1 &&
-                hiddenActions.indexOf(a.__key) === -1;
-          })
-          .map(function(action) {
-            var key = action.__key;
-            if (key === '/') { key = 'delete'; }
-            return {name: action.label, id: key, };
-          });
+    _computeMenuActions(actionRecord, hiddenActionsRecord) {
+      const hiddenActions = hiddenActionsRecord.base || [];
+      return actionRecord.base.filter(a => {
+        const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
+        return overflow && !hiddenActions.includes(a.__key);
+      }).map(action => {
+        let key = action.__key;
+        if (key === '/') { key = 'delete'; }
+        return {
+          name: action.label,
+          id: `${key}-${action.__type}`,
+          action,
+        };
+      });
     },
   });
 })();
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 83a1c7e..8aa6bab 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
@@ -34,11 +34,13 @@
 </test-fixture>
 
 <script>
-  suite('gr-change-actions tests', function() {
-    var element;
-    setup(function() {
+  suite('gr-change-actions tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getChangeRevisionActions: function() {
+        getChangeRevisionActions() {
           return Promise.resolve({
             '/': {
               method: 'DELETE',
@@ -46,19 +48,19 @@
               title: 'Delete draft revision 2',
               enabled: true,
             },
-            cherrypick: {
+            'cherrypick': {
               method: 'POST',
               label: 'Cherry Pick',
               title: 'Cherry pick change to a different branch',
               enabled: true,
             },
-            rebase: {
+            'rebase': {
               method: 'POST',
               label: 'Rebase',
               title: 'Rebase onto tip of branch or parent change',
               enabled: true,
             },
-            submit: {
+            'submit': {
               method: 'POST',
               label: 'Submit',
               title: 'Submit patch set 2 into master',
@@ -66,18 +68,18 @@
             },
           });
         },
-        send: function(method, url, payload) {
+        send(method, url, payload) {
           if (method !== 'POST') { return Promise.reject('bad method'); }
 
           if (url === '/changes/42/revisions/2/submit') {
             return Promise.resolve({
               ok: true,
-              text: function() { return Promise.resolve(')]}\'\n{}'); },
+              text() { return Promise.resolve(')]}\'\n{}'); },
             });
           } else if (url === '/changes/42/revisions/2/rebase') {
             return Promise.resolve({
               ok: true,
-              text: function() { return Promise.resolve(')]}\'\n{}'); },
+              text() { return Promise.resolve(')]}\'\n{}'); },
             });
           }
 
@@ -97,18 +99,24 @@
           enabled: true,
         },
       };
+      sandbox = sinon.sandbox.create();
+
       return element.reload();
     });
 
-    test('_shouldHideActions', function() {
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('_shouldHideActions', () => {
       assert.isTrue(element._shouldHideActions(undefined, true));
       assert.isTrue(element._shouldHideActions({base: {}}, false));
       assert.isFalse(element._shouldHideActions({base: ['test']}, false));
     });
 
-    test('hide revision action', function(done) {
-      flush(function() {
-        var buttonEl = element.$$('[data-action-key="submit"]');
+    test('hide revision action', done => {
+      flush(() => {
+        const buttonEl = element.$$('[data-action-key="submit"]');
         assert.isOk(buttonEl);
         assert.throws(element.setActionHidden.bind(element, 'invalid type'));
         element.setActionHidden(element.ActionType.REVISION,
@@ -117,14 +125,14 @@
         element.setActionHidden(element.ActionType.REVISION,
             element.RevisionActions.SUBMIT, true);
         assert.lengthOf(element._hiddenActions, 1);
-        flush(function() {
-          var buttonEl = element.$$('[data-action-key="submit"]');
+        flush(() => {
+          const buttonEl = element.$$('[data-action-key="submit"]');
           assert.isNotOk(buttonEl);
 
           element.setActionHidden(element.ActionType.REVISION,
-            element.RevisionActions.SUBMIT, false);
-          flush(function() {
-            var buttonEl = element.$$('[data-action-key="submit"]');
+              element.RevisionActions.SUBMIT, false);
+          flush(() => {
+            const buttonEl = element.$$('[data-action-key="submit"]');
             assert.isOk(buttonEl);
             assert.isFalse(buttonEl.hasAttribute('hidden'));
             done();
@@ -133,9 +141,10 @@
       });
     });
 
-    test('hide menu action', function(done) {
-      flush(function() {
-        var buttonEl = element.$.moreActions.$$('span[data-id="delete"]');
+    test('hide menu action', done => {
+      flush(() => {
+        const buttonEl =
+            element.$.moreActions.$$('span[data-id="delete-revision"]');
         assert.isOk(buttonEl);
         assert.throws(element.setActionHidden.bind(element, 'invalid type'));
         element.setActionHidden(element.ActionType.CHANGE,
@@ -144,14 +153,16 @@
         element.setActionHidden(element.ActionType.CHANGE,
             element.ChangeActions.DELETE, true);
         assert.lengthOf(element._hiddenActions, 1);
-        flush(function() {
-          var buttonEl = element.$.moreActions.$$('span[data-id="delete"]');
+        flush(() => {
+          const buttonEl =
+              element.$.moreActions.$$('span[data-id="delete-revision"]');
           assert.isNotOk(buttonEl);
 
           element.setActionHidden(element.ActionType.CHANGE,
-            element.RevisionActions.DELETE, false);
-          flush(function() {
-            var buttonEl = element.$.moreActions.$$('span[data-id="delete"]');
+              element.RevisionActions.DELETE, false);
+          flush(() => {
+            const buttonEl =
+                element.$.moreActions.$$('span[data-id="delete-revision"]');
             assert.isOk(buttonEl);
             done();
           });
@@ -159,22 +170,22 @@
       });
     });
 
-    test('buttons exist', function(done) {
+    test('buttons exist', done => {
       element._loading = false;
-      flush(function() {
-        var buttonEls = Polymer.dom(element.root)
+      flush(() => {
+        const buttonEls = Polymer.dom(element.root)
             .querySelectorAll('gr-button');
-        var menuItems = element.$.moreActions.items;
-        assert.equal(buttonEls.length + menuItems.length, 6);
+        const menuItems = element.$.moreActions.items;
+        assert.equal(buttonEls.length + menuItems.length, 7);
         assert.isFalse(element.hidden);
         done();
       });
     });
 
-    test('delete buttons have explicit labels', function(done) {
-      flush(function() {
-        var deleteItems = element.$.moreActions.items.filter(function(item) {
-          return item.id === 'delete';
+    test('delete buttons have explicit labels', done => {
+      flush(() => {
+        const deleteItems = element.$.moreActions.items.filter(item => {
+          return item.id.startsWith('delete');
         });
         assert.equal(deleteItems.length, 2);
         assert.notEqual(deleteItems[0].name, deleteItems[1].name);
@@ -190,9 +201,9 @@
       });
     });
 
-    test('get revision object from change', function() {
-      var revObj = {_number: 2, foo: 'bar'};
-      var change = {
+    test('get revision object from change', () => {
+      const revObj = {_number: 2, foo: 'bar'};
+      const change = {
         revisions: {
           rev1: {_number: 1},
           rev2: revObj,
@@ -201,23 +212,24 @@
       assert.deepEqual(element._getRevision(change, '2'), revObj);
     });
 
-    test('_actionComparator sort order', function() {
-      var actions = [
+    test('_actionComparator sort order', () => {
+      const actions = [
         {label: '123', __type: 'change', __key: 'review'},
-        {label: 'abc', __type: 'revision'},
+        {label: 'abc-ro', __type: 'revision'},
         {label: 'abc', __type: 'change'},
         {label: 'def', __type: 'change'},
-        {label: 'def', __type: 'change', __primary: true},
+        {label: 'def-p', __type: 'change', __primary: true},
       ];
 
-      var result = actions.slice();
+      const result = actions.slice();
       result.reverse();
-      result.sort(element._actionComparator);
-
+      result.sort(element._actionComparator.bind(element));
       assert.deepEqual(result, actions);
     });
 
-    test('submit change', function(done) {
+    test('submit change', done => {
+      sandbox.stub(element, 'fetchIsLatestKnown',
+          () => { return Promise.resolve(true); });
       element.change = {
         revisions: {
           rev1: {_number: 1},
@@ -226,44 +238,42 @@
       };
       element.patchNum = '2';
 
-      flush(function() {
-        var submitButton = element.$$('gr-button[data-action-key="submit"]');
+      flush(() => {
+        const submitButton = element.$$('gr-button[data-action-key="submit"]');
         assert.ok(submitButton);
         MockInteractions.tap(submitButton);
 
         // Upon success it should fire the reload-change event.
-        element.addEventListener('reload-change', function(e) {
+        element.addEventListener('reload-change', () => {
           done();
         });
       });
     });
 
-    test('submit change with plugin hook', function(done) {
-      var canSubmitStub = sinon.stub(element, '_canSubmitChange',
-          function() { return false; });
-      var fireActionStub = sinon.stub(element, '_fireAction');
-      flush(function() {
-        var submitButton = element.$$('gr-button[data-action-key="submit"]');
+    test('submit change with plugin hook', done => {
+      sandbox.stub(element, '_canSubmitChange',
+          () => { return false; });
+      const fireActionStub = sandbox.stub(element, '_fireAction');
+      flush(() => {
+        const submitButton = element.$$('gr-button[data-action-key="submit"]');
         assert.ok(submitButton);
         MockInteractions.tap(submitButton);
         assert.equal(fireActionStub.callCount, 0);
 
-        canSubmitStub.restore();
-        fireActionStub.restore();
         done();
       });
     });
 
-    test('chain state', function() {
+    test('chain state', () => {
       assert.equal(element._hasKnownChainState, false);
       element.hasParent = true;
       assert.equal(element._hasKnownChainState, true);
       element.hasParent = false;
     });
 
-    test('_calculateDisabled', function() {
-      var hasKnownChainState = false;
-      var action = {__key: 'rebase', enabled: true};
+    test('_calculateDisabled', () => {
+      let hasKnownChainState = false;
+      const action = {__key: 'rebase', enabled: true};
       assert.equal(
           element._calculateDisabled(action, hasKnownChainState), true);
 
@@ -281,12 +291,12 @@
           element._calculateDisabled(action, hasKnownChainState), true);
     });
 
-    test('rebase change', function(done) {
-      var fireActionStub = sinon.stub(element, '_fireAction');
-      flush(function() {
-        var rebaseButton = element.$$('gr-button[data-action-key="rebase"]');
+    test('rebase change', done => {
+      const fireActionStub = sandbox.stub(element, '_fireAction');
+      flush(() => {
+        const rebaseButton = element.$$('gr-button[data-action-key="rebase"]');
         MockInteractions.tap(rebaseButton);
-        var rebaseAction = {
+        const rebaseAction = {
           __key: 'rebase',
           __type: 'revision',
           __primary: false,
@@ -313,15 +323,14 @@
         assert.deepEqual(fireActionStub.lastCall.args,
           ['/rebase', rebaseAction, true, {base: ''}]);
 
-        fireActionStub.restore();
         done();
       });
     });
 
-    test('two dialogs are not shown at the same time', function(done) {
+    test('two dialogs are not shown at the same time', done => {
       element._hasKnownChainState = true;
-      flush(function() {
-        var rebaseButton = element.$$('gr-button[data-action-key="rebase"]');
+      flush(() => {
+        const rebaseButton = element.$$('gr-button[data-action-key="rebase"]');
         assert.ok(rebaseButton);
         MockInteractions.tap(rebaseButton);
         flushAsynchronousOperations();
@@ -335,23 +344,17 @@
       });
     });
 
-    suite('cherry-pick', function() {
-      var fireActionStub;
-      var alertStub;
+    suite('cherry-pick', () => {
+      let fireActionStub;
 
-      setup(function() {
-        fireActionStub = sinon.stub(element, '_fireAction');
-        alertStub = sinon.stub(window, 'alert');
+      setup(() => {
+        fireActionStub = sandbox.stub(element, '_fireAction');
+        sandbox.stub(window, 'alert');
       });
 
-      teardown(function() {
-        alertStub.restore();
-        fireActionStub.restore();
-      });
-
-      test('works', function() {
+      test('works', () => {
         element._handleCherrypickTap();
-        var action = {
+        const action = {
           __key: 'cherrypick',
           __type: 'revision',
           __primary: false,
@@ -386,8 +389,8 @@
         ]);
       });
 
-      test('branch name cleared when re-open cherrypick', function() {
-        var emptyBranchName = '';
+      test('branch name cleared when re-open cherrypick', () => {
+        const emptyBranchName = '';
         element.$.confirmCherrypick.branch = 'master';
 
         element._handleCherrypickTap();
@@ -395,29 +398,30 @@
       });
     });
 
-    test('custom actions', function(done) {
+    test('custom actions', done => {
       // Add a button with the same key as a server-based one to ensure
       // collisions are taken care of.
-      var key = element.addActionButton(element.ActionType.REVISION, 'Bork!');
-      element.addEventListener(key + '-tap', function(e) {
+      const key = element.addActionButton(element.ActionType.REVISION, 'Bork!');
+      element.addEventListener(key + '-tap', e => {
         assert.equal(e.detail.node.getAttribute('data-action-key'), key);
         element.removeActionButton(key);
-        flush(function() {
+        flush(() => {
           assert.notOk(element.$$('[data-action-key="' + key + '"]'));
           done();
         });
       });
-      flush(function() {
+      flush(() => {
         MockInteractions.tap(element.$$('[data-action-key="' + key + '"]'));
       });
     });
 
-    test('_setLoadingOnButtonWithKey top-level', function() {
-      var key = 'rebase';
-      var cleanup = element._setLoadingOnButtonWithKey(key);
+    test('_setLoadingOnButtonWithKey top-level', () => {
+      const key = 'rebase';
+      const type = 'revision';
+      const cleanup = element._setLoadingOnButtonWithKey(type, key);
       assert.equal(element._actionLoadingMessage, 'Rebasing...');
 
-      var button = element.$$('[data-action-key="' + key + '"]');
+      const button = element.$$('[data-action-key="' + key + '"]');
       assert.isTrue(button.hasAttribute('loading'));
       assert.isTrue(button.disabled);
 
@@ -430,9 +434,10 @@
       assert.isNotOk(element._actionLoadingMessage);
     });
 
-    test('_setLoadingOnButtonWithKey overflow menu', function() {
-      var key = 'cherrypick';
-      var cleanup = element._setLoadingOnButtonWithKey(key);
+    test('_setLoadingOnButtonWithKey overflow menu', () => {
+      const key = 'cherrypick';
+      const type = 'revision';
+      const cleanup = element._setLoadingOnButtonWithKey(type, key);
       assert.equal(element._actionLoadingMessage, 'Cherry-Picking...');
       assert.include(element._disabledMenuActions, 'cherrypick');
       assert.isFunction(cleanup);
@@ -443,13 +448,13 @@
       assert.notInclude(element._disabledMenuActions, 'cherrypick');
     });
 
-    suite('revert change', function() {
-      var alertStub;
-      var fireActionStub;
+    suite('revert change', () => {
+      let alertStub;
+      let fireActionStub;
 
-      setup(function() {
-        fireActionStub = sinon.stub(element, '_fireAction');
-        alertStub = sinon.stub(window, 'alert');
+      setup(() => {
+        fireActionStub = sandbox.stub(element, '_fireAction');
+        alertStub = sandbox.stub(window, 'alert');
         element.actions = {
           revert: {
             method: 'POST',
@@ -461,48 +466,39 @@
         return element.reload();
       });
 
-      teardown(function() {
-        alertStub.restore();
-        fireActionStub.restore();
-      });
-
-      test('revert change with plugin hook', function(done) {
+      test('revert change with plugin hook', done => {
         element.change = {
           current_revision: 'abc1234',
         };
-        var newRevertMsg = 'Modified revert msg';
-        var modifyRevertMsgStub = sinon.stub(element, '_modifyRevertMsg',
-            function() { return newRevertMsg; });
-        var populateRevertMsgStub = sinon.stub(
-            element.$.confirmRevertDialog, 'populateRevertMessage',
-            function() { return 'original msg'; });
-        flush(function() {
-          var revertButton = element.$$('gr-button[data-action-key="revert"]');
+        const newRevertMsg = 'Modified revert msg';
+        sandbox.stub(element, '_modifyRevertMsg',
+            () => { return newRevertMsg; });
+        sandbox.stub(element.$.confirmRevertDialog, 'populateRevertMessage',
+            () => { return 'original msg'; });
+        flush(() => {
+          const revertButton =
+              element.$$('gr-button[data-action-key="revert"]');
           MockInteractions.tap(revertButton);
 
           assert.equal(element.$.confirmRevertDialog.message, newRevertMsg);
-
-          populateRevertMsgStub.restore();
-          modifyRevertMsgStub.restore();
           done();
         });
       });
 
-      test('works', function() {
+      test('works', () => {
         element.change = {
           current_revision: 'abc1234',
         };
-        var populateRevertMsgStub = sinon.stub(
-            element.$.confirmRevertDialog, 'populateRevertMessage',
-            function() { return 'original msg'; });
-        var revertButton = element.$$('gr-button[data-action-key="revert"]');
+        sandbox.stub(element.$.confirmRevertDialog, 'populateRevertMessage',
+            () => { return 'original msg'; });
+        const revertButton = element.$$('gr-button[data-action-key="revert"]');
         MockInteractions.tap(revertButton);
 
         element.$.confirmRevertDialog.message = 'foo message';
         element._handleRevertDialogConfirm();
         assert.notOk(alertStub.called);
 
-        var action = {
+        const action = {
           __key: 'revert',
           __type: 'change',
           __primary: false,
@@ -515,16 +511,109 @@
           '/revert', action, false, {
             message: 'foo message',
           }]);
-        populateRevertMsgStub.restore();
       });
     });
 
-    suite('delete change', function() {
-      var fireActionStub;
-      var deleteAction;
+    suite('mark change private', () => {
+      setup(() => {
+        const privateAction = {
+          __key: 'private',
+          __type: 'change',
+          __primary: false,
+          method: 'POST',
+          label: 'Mark private',
+          title: 'Working...',
+          enabled: true,
+        };
 
-      setup(function() {
-        fireActionStub = sinon.stub(element, '_fireAction');
+        element.actions = {
+          private: privateAction,
+        };
+
+        element.change.is_private = false;
+
+        element.changeNum = '2';
+        element.patchNum = '2';
+
+        return element.reload();
+      });
+
+      test('make sure the mark private change button is not outside of the ' +
+           'overflow menu', done => {
+        flush(() => {
+          assert.isNotOk(element.$$('[data-action-key="private"]'));
+          done();
+        });
+      });
+
+      test('private change', done => {
+        flush(() => {
+          assert.isOk(
+              element.$.moreActions.$$('span[data-id="private-change"]'));
+          element.setActionOverflow('change', 'private', false);
+          flushAsynchronousOperations();
+          assert.isOk(element.$$('[data-action-key="private"]'));
+          assert.isNotOk(
+              element.$.moreActions.$$('span[data-id="private-change"]'));
+          done();
+        });
+      });
+    });
+
+    suite('unmark private change', () => {
+      setup(() => {
+        const unmarkPrivateAction = {
+          __key: 'private.delete',
+          __type: 'change',
+          __primary: false,
+          method: 'POST',
+          label: 'Unmark private',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          'private.delete': unmarkPrivateAction,
+        };
+
+        element.change.is_private = true;
+
+        element.changeNum = '2';
+        element.patchNum = '2';
+
+        return element.reload();
+      });
+
+      test('make sure the unmark private change button is not outside of the ' +
+           'overflow menu', done => {
+        flush(() => {
+          assert.isNotOk(element.$$('[data-action-key="private.delete"]'));
+          done();
+        });
+      });
+
+      test('unmark the private change', done => {
+        flush(() => {
+          assert.isOk(
+              element.$.moreActions.$$('span[data-id="private.delete-change"]')
+          );
+          element.setActionOverflow('change', 'private.delete', false);
+          flushAsynchronousOperations();
+          assert.isOk(element.$$('[data-action-key="private.delete"]'));
+          assert.isNotOk(
+              element.$.moreActions.$$('span[data-id="private.delete-change"]')
+          );
+          done();
+        });
+      });
+    });
+
+    suite('delete change', () => {
+      let fireActionStub;
+      let deleteAction;
+
+      setup(() => {
+        fireActionStub = sandbox.stub(element, '_fireAction');
         element.change = {
           current_revision: 'abc1234',
         };
@@ -539,16 +628,12 @@
         };
       });
 
-      teardown(function() {
-        fireActionStub.restore();
-      });
-
-      test('does not delete on action', function() {
+      test('does not delete on action', () => {
         element._handleDeleteTap();
         assert.isFalse(fireActionStub.called);
       });
 
-      test('shows confirm dialog', function() {
+      test('shows confirm dialog', () => {
         element._handleDeleteTap();
         assert.isFalse(element.$$('#confirmDeleteDialog').hidden);
         MockInteractions.tap(
@@ -557,7 +642,7 @@
         assert.isTrue(fireActionStub.calledWith('/', deleteAction, false));
       });
 
-      test('hides delete confirm on cancel', function() {
+      test('hides delete confirm on cancel', () => {
         element._handleDeleteTap();
         MockInteractions.tap(
             element.$$('#confirmDeleteDialog').$$('gr-button:not([primary])'));
@@ -567,8 +652,166 @@
       });
     });
 
-    suite('quick approve', function() {
-      setup(function() {
+    suite('ignore change', () => {
+      setup(done => {
+        sandbox.stub(element, '_fireAction');
+
+        const IgnoreAction = {
+          __key: 'ignore',
+          __type: 'change',
+          __primary: false,
+          method: 'PUT',
+          label: 'Ignore',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          ignore: IgnoreAction,
+        };
+
+        element.changeNum = '2';
+        element.patchNum = '2';
+
+        element.reload().then(() => {flush(done);});
+      });
+
+      test('make sure the ignore button is not outside of the overflow menu',
+          () => {
+            assert.isNotOk(element.$$('[data-action-key="ignore"]'));
+          });
+
+      test('ignoring change', () => {
+        assert.isOk(element.$.moreActions.$$('span[data-id="ignore-change"]'));
+        element.setActionOverflow('change', 'ignore', false);
+        flushAsynchronousOperations();
+        assert.isOk(element.$$('[data-action-key="ignore"]'));
+        assert.isNotOk(
+            element.$.moreActions.$$('span[data-id="ignore-change"]'));
+      });
+    });
+
+    suite('unignore change', () => {
+      setup(done => {
+        sandbox.stub(element, '_fireAction');
+
+        const UnignoreAction = {
+          __key: 'unignore',
+          __type: 'change',
+          __primary: false,
+          method: 'PUT',
+          label: 'Unignore',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          unignore: UnignoreAction,
+        };
+
+        element.changeNum = '2';
+        element.patchNum = '2';
+
+        element.reload().then(() => {flush(done);});
+      });
+
+
+      test('unignore button is not outside of the overflow menu', () => {
+        assert.isNotOk(element.$$('[data-action-key="unignore"]'));
+      });
+
+      test('unignoring change', () => {
+        assert.isOk(
+            element.$.moreActions.$$('span[data-id="unignore-change"]'));
+        element.setActionOverflow('change', 'unignore', false);
+        flushAsynchronousOperations();
+        assert.isOk(element.$$('[data-action-key="unignore"]'));
+        assert.isNotOk(
+            element.$.moreActions.$$('span[data-id="unignore-change"]'));
+      });
+    });
+
+    suite('mute change', () => {
+      setup(done => {
+        sandbox.stub(element, '_fireAction');
+
+        const MuteAction = {
+          __key: 'mute',
+          __type: 'change',
+          __primary: false,
+          method: 'PUT',
+          label: 'Mute',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          mute: MuteAction,
+        };
+
+        element.changeNum = '2';
+        element.patchNum = '2';
+
+        element.reload().then(() => {flush(done);});
+      });
+
+      test('make sure the mute button is not outside of the overflow menu',
+          () => {
+            assert.isNotOk(element.$$('[data-action-key="mute"]'));
+          });
+
+      test('muting change', () => {
+        assert.isOk(element.$.moreActions.$$('span[data-id="mute-change"]'));
+        element.setActionOverflow('change', 'mute', false);
+        flushAsynchronousOperations();
+        assert.isOk(element.$$('[data-action-key="mute"]'));
+        assert.isNotOk(
+            element.$.moreActions.$$('span[data-id="mute-change"]'));
+      });
+    });
+
+    suite('unmute change', () => {
+      setup(done => {
+        sandbox.stub(element, '_fireAction');
+
+        const UnmuteAction = {
+          __key: 'unmute',
+          __type: 'change',
+          __primary: false,
+          method: 'PUT',
+          label: 'Unmute',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          unmute: UnmuteAction,
+        };
+
+        element.changeNum = '2';
+        element.patchNum = '2';
+
+        element.reload().then(() => {flush(done);});
+      });
+
+
+      test('unmute button not outside of the overflow menu', () => {
+        assert.isNotOk(element.$$('[data-action-key="unmute"]'));
+      });
+
+      test('unmuting change', () => {
+        assert.isOk(
+            element.$.moreActions.$$('span[data-id="unmute-change"]'));
+        element.setActionOverflow('change', 'unmute', false);
+        flushAsynchronousOperations();
+        assert.isOk(element.$$('[data-action-key="unmute"]'));
+        assert.isNotOk(
+            element.$.moreActions.$$('span[data-id="unmute-change"]'));
+      });
+    });
+
+    suite('quick approve', () => {
+      setup(() => {
         element.change = {
           current_revision: 'abc1234',
         };
@@ -590,17 +833,18 @@
         flushAsynchronousOperations();
       });
 
-      test('added when can approve', function() {
-        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+      test('added when can approve', () => {
+        const approveButton =
+            element.$$('gr-button[data-action-key=\'review\']');
         assert.isNotNull(approveButton);
       });
 
-      test('is first in list of actions', function() {
-        var approveButton = element.$$('gr-button');
+      test('is first in list of actions', () => {
+        const approveButton = element.$$('gr-button');
         assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
       });
 
-      test('not added when already approved', function() {
+      test('not added when already approved', () => {
         element.change = {
           current_revision: 'abc1234',
           labels: {
@@ -614,11 +858,12 @@
           },
         };
         flushAsynchronousOperations();
-        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+        const approveButton =
+            element.$$('gr-button[data-action-key=\'review\']');
         assert.isNull(approveButton);
       });
 
-      test('not added when label not permitted', function() {
+      test('not added when label not permitted', () => {
         element.change = {
           current_revision: 'abc1234',
           labels: {
@@ -629,23 +874,23 @@
           },
         };
         flushAsynchronousOperations();
-        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+        const approveButton =
+            element.$$('gr-button[data-action-key=\'review\']');
         assert.isNull(approveButton);
       });
 
-      test('approves when taped', function() {
-        var fireActionStub = sinon.stub(element, '_fireAction');
+      test('approves when tapped', () => {
+        const fireActionStub = sandbox.stub(element, '_fireAction');
         MockInteractions.tap(
             element.$$('gr-button[data-action-key=\'review\']'));
         flushAsynchronousOperations();
         assert.isTrue(fireActionStub.called);
         assert.isTrue(fireActionStub.calledWith('/review'));
-        var payload = fireActionStub.lastCall.args[3];
+        const payload = fireActionStub.lastCall.args[3];
         assert.deepEqual(payload.labels, {foo: '+1'});
-        fireActionStub.restore();
       });
 
-      test('not added when multiple labels are required', function() {
+      test('not added when multiple labels are required', () => {
         element.change = {
           current_revision: 'abc1234',
           labels: {
@@ -658,11 +903,12 @@
           },
         };
         flushAsynchronousOperations();
-        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+        const approveButton =
+            element.$$('gr-button[data-action-key=\'review\']');
         assert.isNull(approveButton);
       });
 
-      test('button label for missing approval', function() {
+      test('button label for missing approval', () => {
         element.change = {
           current_revision: 'abc1234',
           labels: {
@@ -680,11 +926,12 @@
           },
         };
         flushAsynchronousOperations();
-        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+        const approveButton =
+            element.$$('gr-button[data-action-key=\'review\']');
         assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
       });
 
-      test('no quick approve if score is not maximal for a label', function() {
+      test('no quick approve if score is not maximal for a label', () => {
         element.change = {
           current_revision: 'abc1234',
           labels: {
@@ -702,11 +949,12 @@
           },
         };
         flushAsynchronousOperations();
-        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+        const approveButton =
+            element.$$('gr-button[data-action-key=\'review\']');
         assert.isNull(approveButton);
       });
 
-      test('approving label with a non-max score', function() {
+      test('approving label with a non-max score', () => {
         element.change = {
           current_revision: 'abc1234',
           labels: {
@@ -724,9 +972,42 @@
           },
         };
         flushAsynchronousOperations();
-        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+        const approveButton =
+            element.$$('gr-button[data-action-key=\'review\']');
         assert.equal(approveButton.getAttribute('data-label'), 'bar+2');
       });
     });
+
+    test('adds download revision action', () => {
+      const handler = sandbox.stub();
+      element.addEventListener('download-tap', handler);
+      assert.ok(element.revisionActions.download);
+      element._handleDownloadTap();
+      flushAsynchronousOperations();
+
+      assert.isTrue(handler.called);
+    });
+
+    suite('setActionOverflow', () => {
+      test('move action from overflow', () => {
+        assert.isNotOk(element.$$('[data-action-key="cherrypick"]'));
+        assert.strictEqual(
+            element.$.moreActions.items[0].id, 'cherrypick-revision');
+        element.setActionOverflow('revision', 'cherrypick', false);
+        flushAsynchronousOperations();
+        assert.isOk(element.$$('[data-action-key="cherrypick"]'));
+        assert.notEqual(
+            element.$.moreActions.items[0].id, 'cherrypick-revision');
+      });
+
+      test('move action to overflow', () => {
+        assert.isOk(element.$$('[data-action-key="submit"]'));
+        element.setActionOverflow('revision', 'submit', true);
+        flushAsynchronousOperations();
+        assert.isNotOk(element.$$('[data-action-key="submit"]'));
+        assert.strictEqual(
+            element.$.moreActions.items[4].id, 'submit-revision');
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html
new file mode 100644
index 0000000..ae45da4
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html
@@ -0,0 +1,115 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-change-metadata</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../plugins/gr-plugin-host/gr-plugin-host.html">
+<link rel="import" href="gr-change-metadata.html">
+
+<script>void(0);</script>
+
+<test-fixture id="element">
+  <template>
+    <gr-change-metadata></gr-change-metadata>
+  </template>
+</test-fixture>
+
+<test-fixture id="plugin-host">
+  <template>
+    <gr-plugin-host></gr-plugin-host>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-change-metadata integration tests', () => {
+    let sandbox;
+    let element;
+
+    const sectionSelectors = [
+      'section.assignee',
+      'section.labelStatus',
+      'section.strategy',
+      'section.topic',
+    ];
+
+    const getStyle = function(selector, name) {
+      return window.getComputedStyle(
+          Polymer.dom(element.root).querySelector(selector))[name];
+    };
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      stub('gr-change-metadata', {
+        _computeShowLabelStatus() { return true; },
+        _computeShowReviewersByState() { return true; },
+        ready() {
+          this.change = {labels: []};
+          this.serverConfig = {};
+        },
+      });
+    });
+
+    teardown(() => {
+      Gerrit._pluginsPending = -1;
+      Gerrit._allPluginsPromise = undefined;
+      sandbox.restore();
+    });
+
+    suite('by default', () => {
+      setup(done => {
+        element = fixture('element');
+        flush(done);
+      });
+
+      for (const sectionSelector of sectionSelectors) {
+        test(sectionSelector + ' does not have display: none', () => {
+          assert.notEqual(getStyle(sectionSelector, 'display'), 'none');
+        });
+      }
+    });
+
+    suite('with plugin style', () => {
+      setup(done => {
+        const pluginHost = fixture('plugin-host');
+        pluginHost.config = {
+          js_resource_paths: [],
+          html_resource_paths: [
+            new URL('test/plugin.html', window.location.href).toString(),
+          ],
+        };
+        element = fixture('element');
+        const importSpy = sandbox.spy(element.$.externalStyle, '_import');
+        Gerrit.awaitPluginsLoaded().then(() => {
+          Promise.all(importSpy.returnValues).then(() => {
+            flush(done);
+          });
+        });
+      });
+
+      for (const sectionSelector of sectionSelectors) {
+        test(sectionSelector + ' may have display: none', () => {
+          assert.equal(getStyle(sectionSelector, 'display'), 'none');
+        });
+      }
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
index 78bcb9a..ddcdbda 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
@@ -15,12 +15,14 @@
 -->
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../plugins/gr-external-style/gr-external-style.html">
 <link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
-<link rel="import" href="../../shared/gr-label/gr-label.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
+<link rel="import" href="../../shared/gr-label/gr-label.html">
 <link rel="import" href="../../shared/gr-linked-chip/gr-linked-chip.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-reviewer-list/gr-reviewer-list.html">
@@ -38,7 +40,8 @@
       .title {
         color: #666;
         font-weight: bold;
-        white-space: nowrap;
+        max-width: 20em;
+        word-break: break-word;
       }
       gr-account-link {
         max-width: 20ch;
@@ -69,12 +72,32 @@
       .notApproved {
         background-color: #ffd4d4;
       }
-      .labelStatus {
+      .labelStatus .value {
         max-width: 9em;
       }
       .webLink {
         display: block;
       }
+      #missingLabels {
+        padding-left: 1.5em;
+      }
+
+      /* CSS Mixins should be applied last. */
+      section.assignee {
+        @apply(--change-metadata-assignee);
+      }
+      section.labelStatus {
+        @apply(--change-metadata-label-status);
+      }
+      section.strategy {
+        @apply(--change-metadata-strategy);
+      }
+      section.topic {
+        @apply(--change-metadata-topic);
+      }
+      #externalStyle {
+        display: block;
+      }
       @media screen and (max-width: 50em), screen and (min-width: 75em) {
         :host {
           display: table;
@@ -96,22 +119,22 @@
         }
       }
     </style>
-    <section>
-      <span class="title">Updated</span>
-      <span class="value">
-        <gr-date-formatter
-            has-tooltip
-            date-str="[[change.updated]]"></gr-date-formatter>
-      </span>
-    </section>
-    <section>
-      <span class="title">Owner</span>
-      <span class="value">
-        <gr-account-link account="[[change.owner]]"></gr-account-link>
-      </span>
-    </section>
-    <template is="dom-if" if="[[_showReviewersByState]]">
+    <gr-external-style id="externalStyle" name="change-metadata">
       <section>
+        <span class="title">Updated</span>
+        <span class="value">
+          <gr-date-formatter
+              has-tooltip
+              date-str="[[change.updated]]"></gr-date-formatter>
+        </span>
+      </section>
+      <section>
+        <span class="title">Owner</span>
+        <span class="value">
+          <gr-account-link account="[[change.owner]]"></gr-account-link>
+        </span>
+      </section>
+      <section class="assignee">
         <span class="title">Assignee</span>
         <span class="value">
           <gr-account-list
@@ -120,131 +143,139 @@
               placeholder="Add assignee..."
               accounts="{{_assignee}}"
               change="[[change]]"
-              readonly="[[!mutable]]"
+              readonly="[[_computeAssigneeReadOnly(mutable, change)]]"
               allow-any-user></gr-account-list>
         </span>
       </section>
+      <template is="dom-if" if="[[_showReviewersByState]]">
+        <section>
+          <span class="title">Reviewers</span>
+          <span class="value">
+            <gr-reviewer-list
+                change="{{change}}"
+                mutable="[[mutable]]"
+                reviewers-only></gr-reviewer-list>
+          </span>
+        </section>
+        <section>
+          <span class="title">CC</span>
+          <span class="value">
+            <gr-reviewer-list
+                change="{{change}}"
+                mutable="[[mutable]]"
+                ccs-only></gr-reviewer-list>
+          </span>
+        </section>
+      </template>
+      <template is="dom-if" if="[[!_showReviewersByState]]">
+        <section>
+          <span class="title">Reviewers</span>
+          <span class="value">
+            <gr-reviewer-list
+                change="{{change}}"
+                mutable="[[mutable]]"></gr-reviewer-list>
+          </span>
+        </section>
+      </template>
       <section>
-        <span class="title">Reviewers</span>
+        <span class="title">Project</span>
         <span class="value">
-          <gr-reviewer-list
-              change="{{change}}"
-              mutable="[[mutable]]"
-              reviewers-only></gr-reviewer-list>
+          <a href$="[[_computeProjectURL(change.project)]]">[[change.project]]</a>
         </span>
       </section>
       <section>
-        <span class="title">CC</span>
+        <span class="title">Branch</span>
         <span class="value">
-          <gr-reviewer-list
-              change="{{change}}"
-              mutable="[[mutable]]"
-              ccs-only></gr-reviewer-list>
+          <a href$="[[_computeBranchURL(change.project, change.branch)]]">[[change.branch]]</a>
         </span>
       </section>
-    </template>
-    <template is="dom-if" if="[[!_showReviewersByState]]">
-      <section>
-        <span class="title">Assignee</span>
+      <section class="topic">
+        <span class="title">Topic</span>
         <span class="value">
-          <gr-account-list
-              max-count="1"
-              id="assigneeValue"
-              placeholder="Add assignee..."
-              accounts="{{_assignee}}"
-              change="[[change]]"
-              readonly="[[!mutable]]"
-              allow-any-user></gr-account-list>
-        </span>
-      </section>
-      <section>
-        <span class="title">Reviewers</span>
-        <span class="value">
-          <gr-reviewer-list
-              change="{{change}}"
-              mutable="[[mutable]]"></gr-reviewer-list>
-        </span>
-      </section>
-    </template>
-    <section>
-      <span class="title">Project</span>
-      <span class="value">[[change.project]]</span>
-    </section>
-    <section>
-      <span class="title">Branch</span>
-      <span class="value">[[change.branch]]</span>
-    </section>
-    <section>
-      <span class="title">Topic</span>
-      <span class="value">
-        <template is="dom-if" if="[[change.topic]]">
-          <gr-linked-chip
-              text="[[change.topic]]"
-              href="[[_computeTopicHref(change.topic)]]"
-              removable="[[!_topicReadOnly]]"
-              on-remove="_handleTopicRemoved"></gr-linked-chip>
-        </template>
-        <template is="dom-if" if="[[!change.topic]]">
-          <gr-editable-label
-              value="{{change.topic}}"
-              placeholder="[[_computeTopicPlaceholder(_topicReadOnly)]]"
-              read-only="[[_topicReadOnly]]"
-              on-changed="_handleTopicChanged"></gr-editable-label>
-        </template>
-      </span>
-    </section>
-    <section class="strategy" hidden$="[[_computeHideStrategy(change)]]" hidden>
-      <span class="title">Strategy</span>
-      <span class="value">[[_computeStrategy(change)]]</span>
-    </section>
-    <template is="dom-repeat"
-        items="[[_computeLabelNames(change.labels)]]" as="labelName">
-      <section>
-        <span class="title">[[labelName]]</span>
-        <span class="value">
-          <template is="dom-repeat"
-              items="[[_computeLabelValues(labelName, change.labels.*)]]"
-              as="label">
-            <div class="labelValueContainer">
-              <span class$="[[label.className]]">
-                <gr-label
-                    has-tooltip
-                    title="[[_computeValueTooltip(label.value, labelName)]]"
-                    class="labelValue">
-                  [[label.value]]
-                </gr-label>
-                <gr-account-chip
-                    account="[[label.account]]"
-                    data-account-id$="[[label.account._account_id]]"
-                    label-name="[[labelName]]"
-                    removable="[[_computeCanDeleteVote(label.account, mutable)]]"
-                    transparent-background
-                    on-remove="_onDeleteVote"></gr-account-chip>
-              </span>
-            </div>
+          <template is="dom-if" if="[[change.topic]]">
+            <gr-linked-chip
+                text="[[change.topic]]"
+                href="[[_computeTopicURL(change.topic)]]"
+                removable="[[!_topicReadOnly]]"
+                on-remove="_handleTopicRemoved"></gr-linked-chip>
+          </template>
+          <template is="dom-if" if="[[!change.topic]]">
+            <gr-editable-label
+                value="{{change.topic}}"
+                placeholder="[[_computeTopicPlaceholder(_topicReadOnly)]]"
+                read-only="[[_topicReadOnly]]"
+                on-changed="_handleTopicChanged"></gr-editable-label>
           </template>
         </span>
       </section>
-    </template>
-    <template is="dom-if" if="[[_showLabelStatus]]">
-      <section>
-        <span class="title">Label Status</span>
-        <span class="value labelStatus">
-          [[_computeSubmitStatus(change.labels)]]
+      <section class="strategy" hidden$="[[_computeHideStrategy(change)]]" hidden>
+        <span class="title">Strategy</span>
+        <span class="value">[[_computeStrategy(change)]]</span>
+      </section>
+      <template is="dom-repeat"
+          items="[[_computeLabelNames(change.labels)]]" as="labelName">
+        <section>
+          <span class="title">[[labelName]]</span>
+          <span class="value">
+            <template is="dom-repeat"
+                items="[[_computeLabelValues(labelName, change.labels.*)]]"
+                as="label">
+              <div class="labelValueContainer">
+                <span class$="[[label.className]]">
+                  <gr-label
+                      has-tooltip
+                      title="[[_computeValueTooltip(label.value, labelName)]]"
+                      class="labelValue">
+                    [[label.value]]
+                  </gr-label>
+                  <gr-account-chip
+                      account="[[label.account]]"
+                      data-account-id$="[[label.account._account_id]]"
+                      label-name="[[labelName]]"
+                      removable="[[_computeCanDeleteVote(label.account, mutable)]]"
+                      transparent-background
+                      on-remove="_onDeleteVote"></gr-account-chip>
+                </span>
+              </div>
+            </template>
+          </span>
+        </section>
+      </template>
+      <template is="dom-if" if="[[_showLabelStatus]]">
+        <section class="labelStatus">
+          <span class="title">Label Status</span>
+          <span class="value">
+            <div hidden$="[[!_isWip]]">
+              Work in progress
+            </div>
+            <div hidden$="[[!_showMissingLabels(change.labels)]]">
+              [[_computeMissingLabelsHeader(change.labels)]]
+              <ul id="missingLabels">
+                <template
+                    is="dom-repeat"
+                    items="[[_computeMissingLabels(change.labels)]]">
+                  <li>[[item]]</li>
+                </template>
+              </ul>
+            </div>
+            <div hidden$="[[_showMissingRequirements(change.labels, _isWip)]]">
+              Ready to submit
+            </div>
+          </span>
+        </section>
+      </template>
+      <section id="webLinks" hidden$="[[!_computeWebLinks(commitInfo)]]">
+        <span class="title">Links</span>
+        <span class="value">
+          <template is="dom-repeat"
+              items="[[_computeWebLinks(commitInfo)]]" as="link">
+            <a href="[[link.url]]" class="webLink" rel="noopener" target="_blank">
+              [[link.name]]
+            </a>
+          </template>
         </span>
       </section>
-    </template>
-    <section id="webLinks" hidden$="[[!_computeWebLinks(commitInfo)]]">
-      <span class="title">Links</span>
-      <span class="value">
-        <template is="dom-repeat"
-            items="[[_computeWebLinks(commitInfo)]]" as="link">
-          <a href="[[link.url]]" class="webLink" rel="noopener" target="_blank">
-            [[link.name]]
-          </a>
-        </template>
-      </span>
-    </section>
+    </gr-external-style>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-change-metadata.js"></script>
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 57e25f8..62fe5ed 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
@@ -14,7 +14,7 @@
 (function() {
   'use strict';
 
-  var SubmitTypeLabel = {
+  const SubmitTypeLabel = {
     FAST_FORWARD_ONLY: 'Fast Forward Only',
     MERGE_IF_NECESSARY: 'Merge if Necessary',
     REBASE_IF_NECESSARY: 'Rebase if Necessary',
@@ -44,11 +44,16 @@
       },
 
       _assignee: Array,
+      _isWip: {
+        type: Boolean,
+        computed: '_computeIsWip(change)',
+      },
     },
 
     behaviors: [
       Gerrit.BaseUrlBehavior,
       Gerrit.RESTClientBehavior,
+      Gerrit.URLEncodingBehavior,
     ],
 
     observers: [
@@ -56,15 +61,15 @@
       '_assigneeChanged(_assignee.*)',
     ],
 
-    _changeChanged: function(change) {
+    _changeChanged(change) {
       this._assignee = change.assignee ? [change.assignee] : [];
     },
 
-    _assigneeChanged: function(assigneeRecord) {
+    _assigneeChanged(assigneeRecord) {
       if (!this.change) { return; }
-      var assignee = assigneeRecord.base;
+      const assignee = assigneeRecord.base;
       if (assignee.length) {
-        var acct = assignee[0];
+        const acct = assignee[0];
         if (this.change.assignee &&
             acct._account_id === this.change.assignee._account_id) { return; }
         this.set(['change', 'assignee'], acct);
@@ -76,7 +81,7 @@
       }
     },
 
-    _computeHideStrategy: function(change) {
+    _computeHideStrategy(change) {
       return !this.changeIsOpen(change.status);
     },
 
@@ -84,7 +89,7 @@
      * This is a whitelist of web link types that provide direct links to
      * the commit in the url property.
      */
-    _isCommitWebLink: function(link) {
+    _isCommitWebLink(link) {
       return link.name === 'gitiles' || link.name === 'gitweb';
     },
 
@@ -94,34 +99,34 @@
      * an existential check can be used to hide or show the webLinks
      * section.
      */
-    _computeWebLinks: function(commitInfo) {
-      if (!commitInfo || !commitInfo.web_links) { return null }
+    _computeWebLinks(commitInfo) {
+      if (!commitInfo || !commitInfo.web_links) { return null; }
       // We are already displaying these types of links elsewhere,
       // don't include in the metadata links section.
-      var webLinks = commitInfo.web_links.filter(
-          function(l) {return !this._isCommitWebLink(l); }.bind(this));
+      const webLinks = commitInfo.web_links.filter(
+          l => {return !this._isCommitWebLink(l); });
 
       return webLinks.length ? webLinks : null;
     },
 
-    _computeStrategy: function(change) {
+    _computeStrategy(change) {
       return SubmitTypeLabel[change.submit_type];
     },
 
-    _computeLabelNames: function(labels) {
+    _computeLabelNames(labels) {
       return Object.keys(labels).sort();
     },
 
-    _computeLabelValues: function(labelName, _labels) {
-      var result = [];
-      var labels = _labels.base;
-      var t = labels[labelName];
+    _computeLabelValues(labelName, _labels) {
+      const result = [];
+      const labels = _labels.base;
+      const t = labels[labelName];
       if (!t) { return result; }
-      var approvals = t.all || [];
-      approvals.forEach(function(label) {
+      const approvals = t.all || [];
+      for (const label of approvals) {
         if (label.value && label.value != labels[labelName].default_value) {
-          var labelClassName;
-          var labelValPrefix = '';
+          let labelClassName;
+          let labelValPrefix = '';
           if (label.value > 0) {
             labelValPrefix = '+';
             labelClassName = 'approved';
@@ -134,29 +139,35 @@
             account: label,
           });
         }
-      });
+      }
       return result;
     },
 
-    _computeValueTooltip: function(score, labelName) {
-      var values = this.change.labels[labelName].values;
+    _computeValueTooltip(score, labelName) {
+      const values = this.change.labels[labelName].values;
       return values[score];
     },
 
-    _handleTopicChanged: function(e, topic) {
+    _handleTopicChanged(e, topic) {
       if (!topic.length) { topic = null; }
       this.$.restAPI.setChangeTopic(this.change._number, topic);
     },
 
-    _computeTopicReadOnly: function(mutable, change) {
+    _computeTopicReadOnly(mutable, change) {
       return !mutable || !change.actions.topic || !change.actions.topic.enabled;
     },
 
-    _computeTopicPlaceholder: function(_topicReadOnly) {
+    _computeAssigneeReadOnly(mutable, change) {
+      return !mutable ||
+          !change.actions.assignee ||
+          !change.actions.assignee.enabled;
+    },
+
+    _computeTopicPlaceholder(_topicReadOnly) {
       return _topicReadOnly ? 'No Topic' : 'Click to add topic';
     },
 
-    _computeShowReviewersByState: function(serverConfig) {
+    _computeShowReviewersByState(serverConfig) {
       return !!serverConfig.note_db_enabled;
     },
 
@@ -170,9 +181,9 @@
      * @param {boolean} mutable this.mutable describes whether the
      *     change-metadata section is modifiable by the current user.
      */
-    _computeCanDeleteVote: function(reviewer, mutable) {
+    _computeCanDeleteVote(reviewer, mutable) {
       if (!mutable) { return false; }
-      for (var i = 0; i < this.change.removable_reviewers.length; i++) {
+      for (let i = 0; i < this.change.removable_reviewers.length; i++) {
         if (this.change.removable_reviewers[i]._account_id ===
             reviewer._account_id) {
           return true;
@@ -181,60 +192,96 @@
       return false;
     },
 
-    _onDeleteVote: function(e) {
+    _onDeleteVote(e) {
       e.preventDefault();
-      var target = Polymer.dom(e).rootTarget;
-      var labelName = target.labelName;
-      var accountID = parseInt(target.getAttribute('data-account-id'), 10);
+      const target = Polymer.dom(e).rootTarget;
+      const labelName = target.labelName;
+      const accountID = parseInt(target.getAttribute('data-account-id'), 10);
       this._xhrPromise =
           this.$.restAPI.deleteVote(this.change.id, accountID, labelName)
-          .then(function(response) {
-        if (!response.ok) { return response; }
-
-        var labels = this.change.labels[labelName].all || [];
-        for (var i = 0; i < labels.length; i++) {
-          if (labels[i]._account_id === accountID) {
-            this.splice(['change.labels', labelName, 'all'], i, 1);
-            break;
-          }
-        }
-      }.bind(this));
+          .then(response => {
+            if (!response.ok) { return response; }
+            const label = this.change.labels[labelName];
+            const labels = label.all || [];
+            for (let i = 0; i < labels.length; i++) {
+              if (labels[i]._account_id === accountID) {
+                for (const key in label) {
+                  if (label.hasOwnProperty(key) &&
+                      label[key]._account_id === accountID) {
+                    // Remove special label field, keeping change label values
+                    // in sync with the backend.
+                    this.set(['change.labels', labelName, key], null);
+                  }
+                }
+                this.splice(['change.labels', labelName, 'all'], i, 1);
+                break;
+              }
+            }
+          });
     },
 
-    _computeShowLabelStatus: function(change) {
-      var isNewChange = change.status === this.ChangeStatus.NEW;
-      var hasLabels = Object.keys(change.labels).length > 0;
+    _computeShowLabelStatus(change) {
+      const isNewChange = change.status === this.ChangeStatus.NEW;
+      const hasLabels = Object.keys(change.labels).length > 0;
       return isNewChange && hasLabels;
     },
 
-    _computeSubmitStatus: function(labels) {
-      var missingLabels = [];
-      var output = '';
-      for (var label in labels) {
-        var obj = labels[label];
+    _computeMissingLabels(labels) {
+      const missingLabels = [];
+      for (const label in labels) {
+        if (!labels.hasOwnProperty(label)) { continue; }
+        const obj = labels[label];
         if (!obj.optional && !obj.approved) {
           missingLabels.push(label);
         }
       }
-      if (missingLabels.length) {
-        output += 'Needs ';
-        output += missingLabels.join(' and ');
-        output += missingLabels.length > 1 ? ' labels' : ' label';
+      return missingLabels;
+    },
+
+    _computeMissingLabelsHeader(labels) {
+      return 'Needs label' +
+          (this._computeMissingLabels(labels).length > 1 ? 's' : '') + ':';
+    },
+
+    _showMissingLabels(labels) {
+      return !!this._computeMissingLabels(labels).length;
+    },
+
+    _showMissingRequirements(labels, workInProgress) {
+      return workInProgress || this._showMissingLabels(labels);
+    },
+
+    _computeProjectURL(project) {
+      return this.getBaseUrl() + '/q/project:' +
+        this.encodeURL(project, false);
+    },
+
+    _computeBranchURL(project, branch) {
+      let status;
+      if (this.change.status == this.ChangeStatus.NEW) {
+        status = 'open';
       } else {
-        output = 'Ready to submit';
+        status = this.change.status.toLowerCase();
       }
-      return output;
+      return this.getBaseUrl() + '/q/project:' +
+        this.encodeURL(project, false) +
+          ' branch:' + this.encodeURL(branch, false) +
+              ' status:' + this.encodeURL(status, false);
     },
 
-    _computeTopicHref: function(topic) {
-      var encodedTopic = encodeURIComponent('\"' + topic + '\"');
-      return this.getBaseUrl() + '/q/topic:' + encodeURIComponent(encodedTopic) +
-          '+(status:open OR status:merged)';
+    _computeTopicURL(topic) {
+      return this.getBaseUrl() + '/q/topic:' +
+          this.encodeURL('"' + topic + '"', false) +
+            '+(status:open OR status:merged)';
     },
 
-    _handleTopicRemoved: function() {
+    _handleTopicRemoved() {
       this.set(['change', 'topic'], '');
       this.$.restAPI.setChangeTopic(this.change._number, null);
     },
+
+    _computeIsWip(change) {
+      return !!change.work_in_progress;
+    },
   });
 })();
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 4eda281..5003a68 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
@@ -33,25 +33,25 @@
 </test-fixture>
 
 <script>
-  suite('gr-change-metadata tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-change-metadata tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
-        getLoggedIn: function() { return Promise.resolve(false); },
+        getConfig() { return Promise.resolve({}); },
+        getLoggedIn() { return Promise.resolve(false); },
       });
 
       element = fixture('basic');
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('computed fields', function() {
+    test('computed fields', () => {
       assert.isFalse(element._computeHideStrategy({status: 'NEW'}));
       assert.isFalse(element._computeHideStrategy({status: 'DRAFT'}));
       assert.isTrue(element._computeHideStrategy({status: 'MERGED'}));
@@ -60,22 +60,22 @@
           'Cherry Pick');
     });
 
-    test('show strategy for open change', function() {
+    test('show strategy for open change', () => {
       element.change = {status: 'NEW', submit_type: 'CHERRY_PICK', labels: {}};
       flushAsynchronousOperations();
-      var strategy = element.$$('.strategy');
+      const strategy = element.$$('.strategy');
       assert.ok(strategy);
       assert.isFalse(strategy.hasAttribute('hidden'));
       assert.equal(strategy.children[1].innerHTML, 'Cherry Pick');
     });
 
-    test('hide strategy for closed change', function() {
+    test('hide strategy for closed change', () => {
       element.change = {status: 'MERGED', labels: {}};
       flushAsynchronousOperations();
       assert.isTrue(element.$$('.strategy').hasAttribute('hidden'));
     });
 
-    test('show CC section when NoteDb enabled', function() {
+    test('show CC section when NoteDb enabled', () => {
       function hasCc() {
         return element._showReviewersByState;
       }
@@ -87,41 +87,54 @@
       assert.isTrue(hasCc());
     });
 
-    test('computes submit status', function() {
-      var labels = {};
-      assert.equal(element._computeSubmitStatus(labels), 'Ready to submit');
-      labels = {test: {}};
-      assert.equal(element._computeSubmitStatus(labels), 'Needs test label');
-      labels.test.approved = true;
-      assert.equal(element._computeSubmitStatus(labels), 'Ready to submit');
-      labels.test.approved = false;
-      labels.test.optional = true;
-      assert.equal(element._computeSubmitStatus(labels), 'Ready to submit');
-      labels.test.optional = false;
-      labels.test2 = {};
-      assert.equal(element._computeSubmitStatus(labels),
-          'Needs test and test2 labels');
+    test('computes submit status', () => {
+      let showMissingLabels = false;
+      sandbox.stub(element, '_showMissingLabels', () => {
+        return showMissingLabels;
+      });
+      assert.isFalse(element._showMissingRequirements(null, false));
+      assert.isTrue(element._showMissingRequirements(null, true));
+      showMissingLabels = true;
+      assert.isTrue(element._showMissingRequirements(null, false));
     });
 
-    test('weblinks hidden when no weblinks', function() {
+    test('show missing labels', () => {
+      let labels = {};
+      assert.isFalse(element._showMissingLabels(labels));
+      labels = {test: {}};
+      assert.isTrue(element._showMissingLabels(labels));
+      assert.deepEqual(element._computeMissingLabels(labels), ['test']);
+      labels.test.approved = true;
+      assert.isFalse(element._showMissingLabels(labels));
+      labels.test.approved = false;
+      labels.test.optional = true;
+      assert.isFalse(element._showMissingLabels(labels));
+      labels.test.optional = false;
+      labels.test2 = {};
+      assert.isTrue(element._showMissingLabels(labels));
+      assert.deepEqual(element._computeMissingLabels(labels),
+          ['test', 'test2']);
+    });
+
+    test('weblinks hidden when no weblinks', () => {
       element.commitInfo = {};
       flushAsynchronousOperations();
-      var webLinks = element.$.webLinks;
+      const webLinks = element.$.webLinks;
       assert.isTrue(webLinks.hasAttribute('hidden'));
     });
 
-    test('weblinks hidden when only gitiles weblink', function() {
+    test('weblinks hidden when only gitiles weblink', () => {
       element.commitInfo = {web_links: [{name: 'gitiles', url: '#'}]};
       flushAsynchronousOperations();
-      var webLinks = element.$.webLinks;
+      const webLinks = element.$.webLinks;
       assert.isTrue(webLinks.hasAttribute('hidden'));
       assert.equal(element._computeWebLinks(element.commitInfo), null);
     });
 
-    test('weblinks are visible when other weblinks', function() {
+    test('weblinks are visible when other weblinks', () => {
       element.commitInfo = {web_links: [{name: 'test', url: '#'}]};
       flushAsynchronousOperations();
-      var webLinks = element.$.webLinks;
+      const webLinks = element.$.webLinks;
       assert.isFalse(webLinks.hasAttribute('hidden'));
       assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
       // With two non-gitiles weblinks, there are two returned.
@@ -130,19 +143,32 @@
       assert.equal(element._computeWebLinks(element.commitInfo).length, 2);
     });
 
-    test('weblinks are visible when gitiles and other weblinks', function() {
+    test('weblinks are visible when gitiles and other weblinks', () => {
       element.commitInfo = {
         web_links: [{name: 'test', url: '#'}, {name: 'gitiles', url: '#'}]};
       flushAsynchronousOperations();
-      var webLinks = element.$.webLinks;
+      const webLinks = element.$.webLinks;
       assert.isFalse(webLinks.hasAttribute('hidden'));
       // Only the non-gitiles weblink is returned.
       assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
     });
 
-    suite('Topic removal', function() {
-      var change;
-      setup(function() {
+    test('determines whether to show "Ready to Submit" label', () => {
+      const showMissingSpy = sandbox.spy(element, '_showMissingRequirements');
+      element.change = {status: 'NEW', submit_type: 'CHERRY_PICK', labels: {
+        test: {
+          all: [{_account_id: 1, name: 'bojack', value: 1}],
+          default_value: 0,
+          values: [],
+        },
+      }};
+      flushAsynchronousOperations();
+      assert.isTrue(showMissingSpy.called);
+    });
+
+    suite('Topic removal', () => {
+      let change;
+      setup(() => {
         change = {
           _number: 'the number',
           actions: {
@@ -163,8 +189,8 @@
         };
       });
 
-      test('_computeTopicReadOnly', function() {
-        var mutable = false;
+      test('_computeTopicReadOnly', () => {
+        let mutable = false;
         assert.isTrue(element._computeTopicReadOnly(mutable, change));
         mutable = true;
         assert.isTrue(element._computeTopicReadOnly(mutable, change));
@@ -174,31 +200,32 @@
         assert.isTrue(element._computeTopicReadOnly(mutable, change));
       });
 
-      test('topic read only hides delete button', function() {
+      test('topic read only hides delete button', () => {
         element.mutable = false;
         element.change = change;
         flushAsynchronousOperations();
-        var button = element.$$('gr-linked-chip').$$('gr-button');
+        const button = element.$$('gr-linked-chip').$$('gr-button');
         assert.isTrue(button.hasAttribute('hidden'));
       });
 
-      test('topic not read only does not hide delete button', function() {
+      test('topic not read only does not hide delete button', () => {
         element.mutable = true;
         change.actions.topic.enabled = true;
         element.change = change;
         flushAsynchronousOperations();
-        var button = element.$$('gr-linked-chip').$$('gr-button');
+        const button = element.$$('gr-linked-chip').$$('gr-button');
         assert.isFalse(button.hasAttribute('hidden'));
       });
     });
 
-    suite('remove reviewer votes', function() {
-      setup(function() {
+    suite('remove reviewer votes', () => {
+      setup(() => {
         sandbox.stub(element, '_computeValueTooltip').returns('');
         sandbox.stub(element, '_computeTopicReadOnly').returns(true);
         element.change = {
           _number: 'the number',
           change_id: 'the id',
+          actions: [],
           topic: 'the topic',
           status: 'NEW',
           submit_type: 'CHERRY_PICK',
@@ -213,94 +240,102 @@
         };
       });
 
-      test('_computeCanDeleteVote hides delete button', function() {
+      test('_computeCanDeleteVote hides delete button', () => {
         flushAsynchronousOperations();
-        var button = element.$$('gr-account-chip').$$('gr-button');
+        const button = element.$$('gr-account-chip').$$('gr-button');
         assert.isTrue(button.hasAttribute('hidden'));
         element.mutable = true;
         assert.isTrue(button.hasAttribute('hidden'));
       });
 
-      test('_computeCanDeleteVote shows delete button', function() {
+      test('_computeCanDeleteVote shows delete button', () => {
         element.change.removable_reviewers = [
           {
             _account_id: 1,
             name: 'bojack',
-          }
+          },
         ];
         element.mutable = true;
         flushAsynchronousOperations();
-        var button = element.$$('gr-account-chip').$$('gr-button');
+        const button = element.$$('gr-account-chip').$$('gr-button');
         assert.isFalse(button.hasAttribute('hidden'));
       });
 
-      test('deletes votes', function(done) {
+      test('deletes votes', done => {
         sandbox.stub(element.$.restAPI, 'deleteVote')
-            .returns(Promise.resolve({'ok': true}));
+            .returns(Promise.resolve({ok: true}));
+        const spliceStub = sandbox.stub(element, 'splice', (path, index,
+            length) => {
+          assert.deepEqual(path, ['change.labels', 'test', 'all']);
+          assert.equal(index, 0);
+          assert.equal(length, 1);
+          assert.notOk(element.change.labels.test.recommended);
+          spliceStub.restore();
+          done();
+        });
         element.change.removable_reviewers = [
           {
             _account_id: 1,
             name: 'bojack',
-          }
+          },
         ];
+        element.change.labels.test.recommended = {_account_id: 1};
         element.mutable = true;
         flushAsynchronousOperations();
-        var button = element.$$('gr-account-chip').$$('gr-button');
+        const button = element.$$('gr-account-chip').$$('gr-button');
         MockInteractions.tap(button);
-        flushAsynchronousOperations();
-        var spliceStub = sinon.stub(element, 'splice',
-            function(path, index, length) {
-          assert.deepEqual(path, ['change.labels', 'test', 'all']);
-          assert.equal(index, 0);
-          assert.equal(length, 1);
-          spliceStub.restore();
-          done();
-        });
       });
 
-      test('changing topic calls setChangeTopic', function() {
-        var topicStub = sandbox.stub(element.$.restAPI, 'setChangeTopic',
-            function() {});
+      test('changing topic calls setChangeTopic', () => {
+        const topicStub = sandbox.stub(element.$.restAPI, 'setChangeTopic',
+            () => {});
         element._handleTopicChanged({}, 'the new topic');
         assert.isTrue(topicStub.calledWith('the number', 'the new topic'));
       });
 
-      test('topic href has quotes', function() {
-        var hrefArr = element._computeTopicHref('test')
+      test('topic href has quotes', () => {
+        const hrefArr = element._computeTopicURL('test')
             .split('%2522'); // Double-escaped quote.
         assert.equal(hrefArr[1], 'test');
       });
 
-      test('clicking x on topic chip removes topic', function() {
-        var topicStub = sandbox.stub(element.$.restAPI, 'setChangeTopic');
+      test('clicking x on topic chip removes topic', () => {
+        const topicStub = sandbox.stub(element.$.restAPI, 'setChangeTopic');
         flushAsynchronousOperations();
-        var remove = element.$$('gr-linked-chip').$.remove;
+        const remove = element.$$('gr-linked-chip').$.remove;
         MockInteractions.tap(remove);
         assert.equal(element.change.topic, '');
         assert.isTrue(topicStub.called);
       });
 
-      suite('assignee field', function() {
-        var dummyAccount = {
+      suite('assignee field', () => {
+        const dummyAccount = {
           _account_id: 1,
           name: 'bojack',
         };
-        var deleteStub;
-        var setStub;
-        setup(function() {
+        const change = {
+          actions: {
+            assignee: {enabled: false},
+          },
+          assignee: dummyAccount,
+        };
+        let deleteStub;
+        let setStub;
+
+        setup(() => {
           deleteStub = sandbox.stub(element.$.restAPI, 'deleteAssignee');
           setStub = sandbox.stub(element.$.restAPI, 'setAssignee');
         });
 
-        test('changing change recomputes _assignee', function() {
+        test('changing change recomputes _assignee', () => {
           assert.isFalse(!!element._assignee.length);
-          var change = element.change;
+          const change = element.change;
           change.assignee = dummyAccount;
           element._changeChanged(change);
           assert.deepEqual(element._assignee[0], dummyAccount);
         });
 
-        test('modifying _assignee calls API', function() {
+        test('modifying _assignee calls API', () => {
           assert.isFalse(!!element._assignee.length);
           element.set('_assignee', [dummyAccount]);
           assert.isTrue(setStub.calledOnce);
@@ -313,6 +348,17 @@
           element.set('_assignee', []);
           assert.isTrue(deleteStub.calledOnce);
         });
+
+        test('_computeAssigneeReadOnly', () => {
+          let mutable = false;
+          assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
+          mutable = true;
+          assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
+          change.actions.assignee.enabled = true;
+          assert.isFalse(element._computeAssigneeReadOnly(mutable, change));
+          mutable = false;
+          assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
+        });
       });
     });
   });
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/test/plugin.html b/polygerrit-ui/app/elements/change/gr-change-metadata/test/plugin.html
new file mode 100644
index 0000000..d0ed4a1
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/test/plugin.html
@@ -0,0 +1,26 @@
+<dom-module id="my-plugin">
+  <script>
+    Gerrit.install(plugin => {
+      plugin.registerStyleModule('change-metadata', 'my-plugin-style');
+    });
+  </script>
+</dom-module>
+
+<dom-module id="my-plugin-style">
+  <style>
+    html {
+      --change-metadata-assignee: {
+        display: none;
+      }
+      --change-metadata-label-status: {
+        display: none;
+      }
+      --change-metadata-strategy: {
+        display: none;
+      }
+      --change-metadata-topic: {
+        display: none;
+      }
+    }
+  </style>
+</dom-module>
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 8455053..4ced26e 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
@@ -19,6 +19,7 @@
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../diff/gr-diff-preferences/gr-diff-preferences.html">
 <link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
 <link rel="import" href="../../shared/gr-select/gr-select.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
@@ -68,11 +69,19 @@
         transition: box-shadow 250ms linear;
         width: 100%;
       }
+      .header.wip {
+        background-color: #fcfad6;
+        border-bottom: 1px solid #ddd;
+        margin-bottom: .5em;
+      }
       .header-title {
         flex: 1;
         font-size: 1.2em;
         font-weight: bold;
       }
+      .prefsButton {
+        float: right;
+      }
       gr-change-star {
         margin-right: .25em;
         vertical-align: -.425em;
@@ -107,14 +116,17 @@
       }
       .commitMessage {
         font-family: var(--monospace-font-family);
-        max-width: 100ch;
         margin-right: 1em;
         margin-bottom: 1em;
+        max-width: var(--commit-message-max-width, 72ch);;
       }
       .commitMessage gr-linked-text {
         overflow: auto;
         word-break: break-all;
       }
+      #commitMessageEditor {
+        min-width: 72ch;
+      }
       .editCommitMessage {
         margin-top: 1em;
       }
@@ -134,7 +146,7 @@
         flex-direction: column;
         min-width: 0;
       }
-      .commitAndRelated {
+      #commitAndRelated {
         align-content: flex-start;
         display: flex;
         flex: 1;
@@ -188,6 +200,9 @@
         height: 0;
         margin-bottom: 1em;
       }
+      #diffPrefsContainer {
+        margin: auto 0 auto auto;
+      }
       .patchInfo-header-wrapper {
         width: 100%;
       }
@@ -206,6 +221,7 @@
       .commitContainer {
         display: flex;
         flex-direction: column;
+        flex-shrink: 0;
       }
       .collapseToggleContainer {
         display: flex;
@@ -217,6 +233,21 @@
         margin-left: 1em;
         padding-top: var(--related-change-btn-top-padding, 0);
       }
+      @media screen and (min-width: 80em) {
+        .commitMessage {
+          max-width: var(--commit-message-max-width, 100ch);
+        }
+      }
+      /* NOTE: If you update this breakpoint, also update the
+      BREAKPOINT_RELATED_MED in the JS */
+      @media screen and (max-width: 60em) {
+        #commitAndRelated {
+          flex-direction: column;
+          flex-wrap: nowrap;
+        }
+      }
+      /* NOTE: If you update this breakpoint, also update the
+      BREAKPOINT_RELATED_SMALL in the JS */
       @media screen and (max-width: 50em) {
         .mobile {
           display: block;
@@ -249,7 +280,7 @@
           padding-right: 0;
         }
         .changeInfo,
-        .commitAndRelated {
+        #commitAndRelated {
           flex-direction: column;
           flex-wrap: nowrap;
         }
@@ -277,7 +308,7 @@
     </style>
     <div class="container loading" hidden$="[[!_loading]]">Loading...</div>
     <div class="container" hidden$="{{_loading}}">
-      <div class="header">
+      <div class$="[[_computeHeaderClass(_change)]]">
         <span class="header-title">
           <gr-change-star
               id="changeStar"
@@ -301,6 +332,8 @@
          --></template><!--
          -->)<!--
        --></template><!--
+       --><span hidden$="[[!_change.work_in_progress]]"> (Work in progress)</span><!--
+       --><span>[[_privateChanges(_change)]]</span><!--
        -->: [[_change.subject]]
         </span>
       </div>
@@ -333,12 +366,13 @@
                 change-num="[[_changeNum]]"
                 change-status="[[_change.status]]"
                 commit-num="[[_commitInfo.commit]]"
-                patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
+                patch-num="[[computeLatestPatchNum(_allPatchSets)]]"
                 commit-message="[[_latestCommitMessage]]"
-                on-reload-change="_handleReloadChange"></gr-change-actions>
+                on-reload-change="_handleReloadChange"
+                on-download-tap="_handleDownloadTap"></gr-change-actions>
           </div>
           <hr class="mobile">
-          <div class="commitAndRelated">
+          <div id="commitAndRelated">
             <div class="commitContainer">
               <div
                   id="commitMessage"
@@ -385,7 +419,8 @@
                   change="[[_change]]"
                   has-parent="{{hasParent}}"
                   loading="{{_relatedChangesLoading}}"
-                  patch-num="[[_computeLatestPatchNum(_allPatchSets)]]">
+                  on-update="_updateRelatedChangeMaxHeight"
+                  patch-num="[[computeLatestPatchNum(_allPatchSets)]]">
               </gr-related-changes-list>
               <div
                   id="relatedChangesToggle"
@@ -422,7 +457,8 @@
                     disabled$="[[_computePatchSetDisabled(patchNum.num, _patchRange.basePatchNum)]]">
                   [[patchNum.num]]
                   /
-                  [[_computeLatestPatchNum(_allPatchSets)]]
+                  [[computeLatestPatchNum(_allPatchSets)]]
+                  [[_computePatchSetCommentsString(_comments, patchNum.num)]]
                   [[_computePatchSetDescription(_change, patchNum.num)]]
                 </option>
               </template>
@@ -452,9 +488,17 @@
                   read-only="[[_descriptionReadOnly]]"
                   on-changed="_handleDescriptionChanged"></gr-editable-label>
             </span>
+            <span id="diffPrefsContainer"
+                hidden$="[[_computePrefsButtonHidden(_diffPrefs, _loggedIn)]]"
+                hidden>
+              <gr-button link
+                  class="prefsButton desktop"
+                  on-tap="_handlePrefsTap">Diff Preferences</gr-button>
+            </span>
           </div>
         </div>
         <gr-file-list id="fileList"
+            diff-prefs="{{_diffPrefs}}"
             change="[[_change]]"
             change-num="[[_changeNum]]"
             patch-range="{{_patchRange}}"
@@ -463,7 +507,9 @@
             revisions="[[_change.revisions]]"
             project-config="[[_projectConfig]]"
             selected-index="{{viewState.selectedFileIndex}}"
-            diff-view-mode="{{viewState.diffMode}}"></gr-file-list>
+            diff-view-mode="{{viewState.diffMode}}"
+            num-files-shown="{{_numFilesShown}}"
+            file-list-increment="{{_numFilesShown}}"></gr-file-list>
       </section>
       <gr-messages-list id="messageList"
           change-num="[[_changeNum]]"
@@ -486,19 +532,22 @@
     <gr-overlay id="replyOverlay"
         class="scrollable"
         no-cancel-on-outside-click
+        no-cancel-on-esc-key
         on-iron-overlay-opened="_handleReplyOverlayOpen"
         with-backdrop>
       <gr-reply-dialog id="replyDialog"
           change="{{_change}}"
-          patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
+          patch-num="[[computeLatestPatchNum(_allPatchSets)]]"
           permitted-labels="[[_change.permitted_labels]]"
           diff-drafts="[[_diffDrafts]]"
           server-config="[[serverConfig]]"
           project-config="[[_projectConfig]]"
+          can-be-started="[[_canStartReview]]"
           on-send="_handleReplySent"
           on-cancel="_handleReplyCancel"
           on-autogrow="_handleReplyAutogrow"
-          hidden$="[[!_loggedIn]]">Reply</gr-reply-dialog>
+          hidden$="[[!_loggedIn]]">
+      </gr-reply-dialog>
     </gr-overlay>
     <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
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 baaf0029..05c6c68 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
@@ -14,18 +14,31 @@
 (function() {
   'use strict';
 
-  var CHANGE_ID_ERROR = {
+  const CHANGE_ID_ERROR = {
     MISMATCH: 'mismatch',
     MISSING: 'missing',
   };
-  var CHANGE_ID_REGEX_PATTERN = /^Change-Id\:\s(I[0-9a-f]{8,40})/gm;
-  var COMMENT_SAVE = 'Saving... Try again after all comments are saved.';
+  const CHANGE_ID_REGEX_PATTERN = /^Change-Id\:\s(I[0-9a-f]{8,40})/gm;
+  const COMMENT_SAVE = 'Saving... Try again after all comments are saved.';
 
-  var MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
+  const MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
+  const DEFAULT_NUM_FILES_SHOWN = 200;
 
   // Maximum length for patch set descriptions.
-  var PATCH_DESC_MAX_LENGTH = 500;
-  var REVIEWERS_REGEX = /^R=/gm;
+  const PATCH_DESC_MAX_LENGTH = 500;
+  const REVIEWERS_REGEX = /^R=/gm;
+  const MIN_CHECK_INTERVAL_SECS = 0;
+
+  // These are the same as the breakpoint set in CSS. Make sure both are changed
+  // together.
+  const BREAKPOINT_RELATED_SMALL = '50em';
+  const BREAKPOINT_RELATED_MED = '60em';
+
+  // In the event that the related changes medium width calculation is too close
+  // to zero, provide some height.
+  const MINIMUM_RELATED_MAX_HEIGHT = 100;
+
+  const SMALL_RELATED_HEIGHT = 400;
 
   Polymer({
     is: 'gr-change-view',
@@ -42,6 +55,12 @@
      * @event page-error
      */
 
+    /**
+     * Fired if being logged in is required.
+     *
+     * @event show-auth-required
+     */
+
     properties: {
       /**
        * URL params passed from the router.
@@ -53,20 +72,31 @@
       viewState: {
         type: Object,
         notify: true,
-        value: function() { return {}; },
+        value() { return {}; },
       },
       backPage: String,
       hasParent: Boolean,
-      serverConfig: Object,
+      serverConfig: {
+        type: Object,
+        observer: '_startUpdateCheckTimer',
+      },
       keyEventTarget: {
         type: Object,
-        value: function() { return document.body; },
+        value() { return document.body; },
       },
-
+      _diffPrefs: Object,
+      _numFilesShown: {
+        type: Number,
+        observer: '_numFilesShownChanged',
+      },
       _account: {
         type: Object,
         value: {},
       },
+      _canStartReview: {
+        type: Boolean,
+        computed: '_computeCanStartReview(_loggedIn, _change, _account)',
+      },
       _comments: Object,
       _change: {
         type: Object,
@@ -77,7 +107,7 @@
       _changeNum: String,
       _diffDrafts: {
         type: Object,
-        value: function() { return {}; },
+        value() { return {}; },
       },
       _editingCommitMessage: {
         type: Boolean,
@@ -109,7 +139,7 @@
       _currentRevisionActions: Object,
       _allPatchSets: {
         type: Array,
-        computed: '_computeAllPatchSets(_change, _change.revisions.*)',
+        computed: 'computeAllPatchSets(_change, _change.revisions.*)',
       },
       _loggedIn: {
         type: Boolean,
@@ -121,7 +151,7 @@
       _replyButtonLabel: {
         type: String,
         value: 'Reply',
-        computed: '_computeReplyButtonLabel(_diffDrafts.*)',
+        computed: '_computeReplyButtonLabel(_diffDrafts.*, _canStartReview)',
       },
       _selectedPatchSet: String,
       _initialLoadComplete: {
@@ -149,6 +179,7 @@
         type: Boolean,
         value: true,
       },
+      _updateCheckTimerHandle: Number,
     },
 
     behaviors: [
@@ -171,17 +202,21 @@
       'u': '_handleUKey',
       'x': '_handleXKey',
       'z': '_handleZKey',
+      ',': '_handleCommaKey',
     },
 
-    attached: function() {
-      this._getLoggedIn().then(function(loggedIn) {
+    attached() {
+      this._getLoggedIn().then(loggedIn => {
         this._loggedIn = loggedIn;
         if (loggedIn) {
-          this.$.restAPI.getAccount().then(function(acct) {
+          this.$.restAPI.getAccount().then(acct => {
             this._account = acct;
-          }.bind(this));
+          });
         }
-      }.bind(this));
+      });
+
+      this._numFilesShown = this.viewState.numFilesShown ?
+          this.viewState.numFilesShown : DEFAULT_NUM_FILES_SHOWN;
 
       this.addEventListener('comment-save', this._handleCommentSave.bind(this));
       this.addEventListener('comment-discard',
@@ -191,53 +226,63 @@
       this.addEventListener('editable-content-cancel',
           this._handleCommitMessageCancel.bind(this));
       this.listen(window, 'scroll', '_handleScroll');
+      this.listen(document, 'visibilitychange', '_handleVisibilityChange');
     },
 
-    detached: function() {
+    detached() {
       this.unlisten(window, 'scroll', '_handleScroll');
+      this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
+
+      if (this._updateCheckTimerHandle) {
+        this._cancelUpdateCheckTimer();
+      }
     },
 
-    _handleEditCommitMessage: function(e) {
+    _computePrefsButtonHidden(prefs, loggedIn) {
+      return !loggedIn || !prefs;
+    },
+
+    _handleEditCommitMessage(e) {
       this._editingCommitMessage = true;
       this.$.commitMessageEditor.focusTextarea();
     },
 
-    _handleCommitMessageSave: function(e) {
-      var message = e.detail.content;
+    _handleCommitMessageSave(e) {
+      const message = e.detail.content;
 
       this.$.jsAPI.handleCommitMessage(this._change, message);
 
       this.$.commitMessageEditor.disabled = true;
-      this._saveCommitMessage(message).then(function(resp) {
+      this._saveCommitMessage(message).then(resp => {
         this.$.commitMessageEditor.disabled = false;
         if (!resp.ok) { return; }
 
         this._latestCommitMessage = this._prepareCommitMsgForLinkify(message);
         this._editingCommitMessage = false;
         this._reloadWindow();
-      }.bind(this)).catch(function(err) {
+      }).catch(err => {
         this.$.commitMessageEditor.disabled = false;
-      }.bind(this));
+      });
     },
 
-    _reloadWindow: function() {
+    _reloadWindow() {
       window.location.reload();
     },
 
-    _handleCommitMessageCancel: function(e) {
+    _handleCommitMessageCancel(e) {
       this._editingCommitMessage = false;
     },
 
-    _saveCommitMessage: function(message) {
+    _saveCommitMessage(message) {
       return this.$.restAPI.saveChangeCommitMessageEdit(
-          this._changeNum, message).then(function(resp) {
+          this._changeNum, message).then(resp => {
             if (!resp.ok) { return resp; }
 
             return this.$.restAPI.publishChangeEdit(this._changeNum);
-          }.bind(this));
+          });
     },
 
-    _computeHideEditCommitMessage: function(loggedIn, editing, change) {
+    _computeHideEditCommitMessage(loggedIn, editing, change) {
       if (!loggedIn || editing || change.status === this.ChangeStatus.MERGED) {
         return true;
       }
@@ -245,24 +290,28 @@
       return false;
     },
 
-    _handleCommentSave: function(e) {
+    _handlePrefsTap(e) {
+      e.preventDefault();
+      this.$.fileList.openDiffPrefs();
+    },
+
+    _handleCommentSave(e) {
       if (!e.target.comment.__draft) { return; }
 
-      var draft = e.target.comment;
+      const draft = e.target.comment;
       draft.patch_set = draft.patch_set || this._patchRange.patchNum;
 
       // The use of path-based notification helpers (set, push) can’t be used
       // because the paths could contain dots in them. A new object must be
       // created to satisfy Polymer’s dirty checking.
       // https://github.com/Polymer/polymer/issues/3127
-      // TODO(andybons): Polyfill for Object.assign in IE.
-      var diffDrafts = Object.assign({}, this._diffDrafts);
+      const diffDrafts = Object.assign({}, this._diffDrafts);
       if (!diffDrafts[draft.path]) {
         diffDrafts[draft.path] = [draft];
         this._diffDrafts = diffDrafts;
         return;
       }
-      for (var i = 0; i < this._diffDrafts[draft.path].length; i++) {
+      for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
         if (this._diffDrafts[draft.path][i].id === draft.id) {
           diffDrafts[draft.path][i] = draft;
           this._diffDrafts = diffDrafts;
@@ -270,7 +319,7 @@
         }
       }
       diffDrafts[draft.path].push(draft);
-      diffDrafts[draft.path].sort(function(c1, c2) {
+      diffDrafts[draft.path].sort((c1, c2) => {
         // No line number means that it’s a file comment. Sort it above the
         // others.
         return (c1.line || -1) - (c2.line || -1);
@@ -278,15 +327,15 @@
       this._diffDrafts = diffDrafts;
     },
 
-    _handleCommentDiscard: function(e) {
+    _handleCommentDiscard(e) {
       if (!e.target.comment.__draft) { return; }
 
-      var draft = e.target.comment;
+      const draft = e.target.comment;
       if (!this._diffDrafts[draft.path]) {
         return;
       }
-      var index = -1;
-      for (var i = 0; i < this._diffDrafts[draft.path].length; i++) {
+      let index = -1;
+      for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
         if (this._diffDrafts[draft.path][i].id === draft.id) {
           index = i;
           break;
@@ -304,8 +353,7 @@
       // because the paths could contain dots in them. A new object must be
       // created to satisfy Polymer’s dirty checking.
       // https://github.com/Polymer/polymer/issues/3127
-      // TODO(andybons): Polyfill for Object.assign in IE.
-      var diffDrafts = Object.assign({}, this._diffDrafts);
+      const diffDrafts = Object.assign({}, this._diffDrafts);
       diffDrafts[draft.path].splice(index, 1);
       if (diffDrafts[draft.path].length === 0) {
         delete diffDrafts[draft.path];
@@ -313,32 +361,32 @@
       this._diffDrafts = diffDrafts;
     },
 
-    _handlePatchChange: function(e) {
+    _handlePatchChange(e) {
       this._changePatchNum(parseInt(e.target.value, 10), true);
     },
 
-    _handleReplyTap: function(e) {
+    _handleReplyTap(e) {
       e.preventDefault();
       this._openReplyDialog();
     },
 
-    _handleDownloadTap: function(e) {
+    _handleDownloadTap(e) {
       e.preventDefault();
-      this.$.downloadOverlay.open().then(function() {
+      this.$.downloadOverlay.open().then(() => {
         this.$.downloadOverlay
             .setFocusStops(this.$.downloadDialog.getFocusStops());
         this.$.downloadDialog.focus();
-      }.bind(this));
+      });
     },
 
-    _handleDownloadDialogClose: function(e) {
+    _handleDownloadDialogClose(e) {
       this.$.downloadOverlay.close();
     },
 
-    _handleMessageReply: function(e) {
-      var msg = e.detail.message.message;
-      var quoteStr = msg.split('\n').map(
-          function(line) { return '> ' + line; }).join('\n') + '\n\n';
+    _handleMessageReply(e) {
+      const msg = e.detail.message.message;
+      const quoteStr = msg.split('\n').map(
+          line => { return '> ' + line; }).join('\n') + '\n\n';
 
       if (quoteStr !== this.$.replyDialog.quote) {
         this.$.replyDialog.draft = quoteStr;
@@ -347,49 +395,44 @@
       this._openReplyDialog();
     },
 
-    _handleReplyOverlayOpen: function(e) {
+    _handleReplyOverlayOpen(e) {
       this.$.replyDialog.focus();
     },
 
-    _handleReplySent: function(e) {
+    _handleReplySent(e) {
       this.$.replyOverlay.close();
       this._reload();
     },
 
-    _handleReplyCancel: function(e) {
+    _handleReplyCancel(e) {
       this.$.replyOverlay.close();
     },
 
-    _handleReplyAutogrow: function(e) {
+    _handleReplyAutogrow(e) {
       this.$.replyOverlay.refit();
     },
 
-    _handleShowReplyDialog: function(e) {
-      var target = this.$.replyDialog.FocusTarget.REVIEWERS;
+    _handleShowReplyDialog(e) {
+      let target = this.$.replyDialog.FocusTarget.REVIEWERS;
       if (e.detail.value && e.detail.value.ccsOnly) {
         target = this.$.replyDialog.FocusTarget.CCS;
       }
       this._openReplyDialog(target);
     },
 
-    _handleScroll: function() {
-      this.debounce('scroll', function() {
-        history.replaceState(
-            {
-              scrollTop: document.body.scrollTop,
-              path: location.pathname,
-            },
-            location.pathname);
+    _handleScroll() {
+      this.debounce('scroll', () => {
+        this.viewState.scrollTop = document.body.scrollTop;
       }, 150);
     },
 
-    _paramsChanged: function(value) {
+    _paramsChanged(value) {
       if (value.view !== this.tagName.toLowerCase()) {
         this._initialLoadComplete = false;
         return;
       }
 
-      var patchChanged = this._patchRange &&
+      const patchChanged = this._patchRange &&
           (value.patchNum !== undefined && value.basePatchNum !== undefined) &&
           (this._patchRange.patchNum !== value.patchNum ||
           this._patchRange.basePatchNum !== value.basePatchNum);
@@ -398,22 +441,22 @@
         this._initialLoadComplete = false;
       }
 
-      var patchRange = {
+      const patchRange = {
         patchNum: value.patchNum,
         basePatchNum: value.basePatchNum || 'PARENT',
       };
 
       if (this._initialLoadComplete && patchChanged) {
         if (patchRange.patchNum == null) {
-          patchRange.patchNum = this._computeLatestPatchNum(this._allPatchSets);
+          patchRange.patchNum = this.computeLatestPatchNum(this._allPatchSets);
         }
         this._patchRange = patchRange;
-        this._reloadPatchNumDependentResources().then(function() {
+        this._reloadPatchNumDependentResources().then(() => {
           this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.SHOW_CHANGE, {
             change: this._change,
             patchNum: patchRange.patchNum,
           });
-        }.bind(this));
+        });
         return;
       }
 
@@ -421,27 +464,27 @@
       this._patchRange = patchRange;
       this.$.relatedChanges.clear();
 
-      this._reload().then(function() {
+      this._reload().then(() => {
         this._performPostLoadTasks();
-      }.bind(this));
+      });
     },
 
-    _performPostLoadTasks: function() {
+    _performPostLoadTasks() {
       // Allow the message list and related changes to render before scrolling.
       // Related changes are loaded here (after everything else) because they
       // take the longest and are secondary information. Because the element may
       // alter the total height of the page, the call to potentially scroll to
       // a linked message is performed after related changes is fully loaded.
-      this.$.relatedChanges.reload().then(function() {
-        this.async(function() {
-          if (history.state && history.state.scrollTop) {
+      this.$.relatedChanges.reload().then(() => {
+        this.async(() => {
+          if (this.viewState.scrollTop) {
             document.documentElement.scrollTop =
-                document.body.scrollTop = history.state.scrollTop;
+                document.body.scrollTop = this.viewState.scrollTop;
           } else {
-            this._maybeScrollToMessage();
+            this._maybeScrollToMessage(window.location.hash);
           }
         }, 1);
-      }.bind(this));
+      });
 
       this._maybeShowReplyDialog();
 
@@ -455,10 +498,10 @@
       this._initialLoadComplete = true;
     },
 
-    _paramsAndChangeChanged: function(value) {
+    _paramsAndChangeChanged(value) {
       // If the change number or patch range is different, then reset the
       // selected file index.
-      var patchRangeState = this.viewState.patchRange;
+      const patchRangeState = this.viewState.patchRange;
       if (this.viewState.changeNum !== this._changeNum ||
           patchRangeState.basePatchNum !== this._patchRange.basePatchNum ||
           patchRangeState.patchNum !== this._patchRange.patchNum) {
@@ -466,24 +509,27 @@
       }
     },
 
-    _maybeScrollToMessage: function() {
-      var msgPrefix = '#message-';
-      var hash = window.location.hash;
-      if (hash.indexOf(msgPrefix) === 0) {
+    _numFilesShownChanged(numFilesShown) {
+      this.viewState.numFilesShown = numFilesShown;
+    },
+
+    _maybeScrollToMessage(hash) {
+      const msgPrefix = '#message-';
+      if (hash.startsWith(msgPrefix)) {
         this.$.messageList.scrollToMessage(hash.substr(msgPrefix.length));
       }
     },
 
-    _getLocationSearch: function() {
+    _getLocationSearch() {
       // 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('=');
+    _getUrlParameter(param) {
+      const pageURL = this._getLocationSearch().substring(1);
+      const vars = pageURL.split('&');
+      for (let i = 0; i < vars.length; i++) {
+        const name = vars[i].split('=');
         if (name[0] == param) {
           return name[0];
         }
@@ -491,57 +537,61 @@
       return null;
     },
 
-    _maybeShowRevertDialog: function() {
+    _maybeShowRevertDialog() {
       Gerrit.awaitPluginsLoaded()
-        .then(this._getLoggedIn.bind(this))
-        .then(function(loggedIn) {
-          if (!loggedIn || this._change.status !== this.ChangeStatus.MERGED) {
+          .then(this._getLoggedIn.bind(this))
+          .then(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));
+              return;
+            }
+            if (this._getUrlParameter('revert')) {
+              this.$.actions.showRevertDialog();
+            }
+          });
     },
 
-    _maybeShowReplyDialog: function() {
-      this._getLoggedIn().then(function(loggedIn) {
+    _maybeShowReplyDialog() {
+      this._getLoggedIn().then(loggedIn => {
         if (!loggedIn) { return; }
 
         if (this.viewState.showReplyDialog) {
           this._openReplyDialog();
-          this.async(function() { this.$.replyOverlay.center(); }, 1);
+          // TODO(kaspern@): Find a better signal for when to call center.
+          this.async(() => { this.$.replyOverlay.center(); }, 100);
+          this.async(() => { this.$.replyOverlay.center(); }, 1000);
           this.set('viewState.showReplyDialog', false);
         }
-      }.bind(this));
+      });
     },
 
-    _resetFileListViewState: function() {
+    _resetFileListViewState() {
       this.set('viewState.selectedFileIndex', 0);
+      this.set('viewState.scrollTop', 0);
       if (!!this.viewState.changeNum &&
           this.viewState.changeNum !== this._changeNum) {
         // Reset the diff mode to null when navigating from one change to
         // another, so that the user's preference is restored.
         this.set('viewState.diffMode', null);
+        this.set('_numFilesShown', DEFAULT_NUM_FILES_SHOWN);
       }
       this.set('viewState.changeNum', this._changeNum);
       this.set('viewState.patchRange', this._patchRange);
     },
 
-    _changeChanged: function(change) {
-      if (!change) { return; }
+    _changeChanged(change) {
+      if (!change || !this._patchRange || !this._allPatchSets) { return; }
       this.set('_patchRange.basePatchNum',
           this._patchRange.basePatchNum || 'PARENT');
       this.set('_patchRange.patchNum',
           this._patchRange.patchNum ||
-              this._computeLatestPatchNum(this._allPatchSets));
+              this.computeLatestPatchNum(this._allPatchSets));
 
       this._updateSelected();
 
-      var title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
-      this.fire('title-change', {title: title});
+      const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
+      this.fire('title-change', {title});
     },
 
     /**
@@ -551,14 +601,14 @@
      *     always include the patch range, even if the requested patchNum is
      *     known to be the latest.
      */
-    _changePatchNum: function(patchNum, opt_forceParams) {
+    _changePatchNum(patchNum, opt_forceParams) {
       if (!opt_forceParams) {
-        var currentPatchNum;
+        let currentPatchNum;
         if (this._change.current_revision) {
           currentPatchNum =
               this._change.revisions[this._change.current_revision]._number;
         } else {
-          currentPatchNum = this._computeLatestPatchNum(this._allPatchSets);
+          currentPatchNum = this.computeLatestPatchNum(this._allPatchSets);
         }
         if (patchNum === currentPatchNum &&
             this._patchRange.basePatchNum === 'PARENT') {
@@ -566,19 +616,19 @@
           return;
         }
       }
-      var patchExpr = this._patchRange.basePatchNum === 'PARENT' ? patchNum :
+      const patchExpr = this._patchRange.basePatchNum === 'PARENT' ? patchNum :
           this._patchRange.basePatchNum + '..' + patchNum;
       page.show(this.changePath(this._changeNum) + '/' + patchExpr);
     },
 
-    _computeChangePermalink: function(changeNum) {
+    _computeChangePermalink(changeNum) {
       return this.getBaseUrl() + '/' + changeNum;
     },
 
-    _computeChangeStatus: function(change, patchNum) {
-      var statusString = this.changeStatusString(change);
+    _computeChangeStatus(change, patchNum) {
+      let statusString = this.changeStatusString(change);
       if (change.status === this.ChangeStatus.NEW) {
-        var rev = this.getRevisionByPatchNum(change.revisions, patchNum);
+        const rev = this.getRevisionByPatchNum(change.revisions, patchNum);
         if (rev && rev.draft === true) {
           statusString = 'Draft';
         }
@@ -586,12 +636,16 @@
       return statusString;
     },
 
-    _computeShowCommitInfo: function(changeStatus, current_revision) {
+    _privateChanges(change) {
+      return change.is_private ? ' (Private)' : '';
+    },
+
+    _computeShowCommitInfo(changeStatus, current_revision) {
       return changeStatus === 'Merged' && current_revision;
     },
 
-    _computeMergedCommitInfo: function(current_revision, revisions) {
-      var rev = revisions[current_revision];
+    _computeMergedCommitInfo(current_revision, revisions) {
+      const rev = revisions[current_revision];
       if (!rev || !rev.commit) { return {}; }
       // CommitInfo.commit is optional. Set commit in all cases to avoid error
       // in <gr-commit-info>. @see Issue 5337
@@ -599,11 +653,11 @@
       return rev.commit;
     },
 
-    _computeChangeIdClass: function(displayChangeId) {
+    _computeChangeIdClass(displayChangeId) {
       return displayChangeId === CHANGE_ID_ERROR.MISMATCH ? 'warning' : '';
     },
 
-    _computeTitleAttributeWarning: function(displayChangeId) {
+    _computeTitleAttributeWarning(displayChangeId) {
       if (displayChangeId === CHANGE_ID_ERROR.MISMATCH) {
         return 'Change-Id mismatch';
       } else if (displayChangeId === CHANGE_ID_ERROR.MISSING) {
@@ -611,12 +665,12 @@
       }
     },
 
-    _computeChangeIdCommitMessageError: function(commitMessage, change) {
+    _computeChangeIdCommitMessageError(commitMessage, change) {
       if (!commitMessage) { return CHANGE_ID_ERROR.MISSING; }
 
       // Find the last match in the commit message:
-      var changeId;
-      var changeIdArr;
+      let changeId;
+      let changeIdArr;
 
       while (changeIdArr = CHANGE_ID_REGEX_PATTERN.exec(commitMessage)) {
         changeId = changeIdArr[1];
@@ -636,13 +690,9 @@
       return CHANGE_ID_ERROR.MISSING;
     },
 
-    _computeLatestPatchNum: function(allPatchSets) {
-      return allPatchSets[allPatchSets.length - 1].num;
-    },
-
-    _computePatchInfoClass: function(patchNum, allPatchSets) {
+    _computePatchInfoClass(patchNum, allPatchSets) {
       if (parseInt(patchNum, 10) ===
-          this._computeLatestPatchNum(allPatchSets)) {
+          this.computeLatestPatchNum(allPatchSets)) {
         return '';
       }
       return 'patchInfo--oldPatchSet';
@@ -655,37 +705,24 @@
      * @param {Number|String} basePatchNum Base patch number from file list
      * @return {Boolean}
      */
-    _computePatchSetDisabled: function(patchNum, basePatchNum) {
+    _computePatchSetDisabled(patchNum, basePatchNum) {
       basePatchNum = basePatchNum === 'PARENT' ? 0 : basePatchNum;
       return parseInt(patchNum, 10) <= parseInt(basePatchNum, 10);
     },
 
-    _computeAllPatchSets: function(change) {
-      var patchNums = [];
-      for (var commit in change.revisions) {
-        if (change.revisions.hasOwnProperty(commit)) {
-          patchNums.push({
-            num: change.revisions[commit]._number,
-            desc: change.revisions[commit].description,
-          });
-        }
-      }
-      return patchNums.sort(function(a, b) { return a.num - b.num; });
-    },
-
-    _computeLabelNames: function(labels) {
+    _computeLabelNames(labels) {
       return Object.keys(labels).sort();
     },
 
-    _computeLabelValues: function(labelName, labels) {
-      var result = [];
-      var t = labels[labelName];
+    _computeLabelValues(labelName, labels) {
+      const result = [];
+      const t = labels[labelName];
       if (!t) { return result; }
-      var approvals = t.all || [];
-      approvals.forEach(function(label) {
+      const approvals = t.all || [];
+      for (label of approvals) {
         if (label.value && label.value != labels[labelName].default_value) {
-          var labelClassName;
-          var labelValPrefix = '';
+          let labelClassName;
+          let labelValPrefix = '';
           if (label.value > 0) {
             labelValPrefix = '+';
             labelClassName = 'approved';
@@ -698,33 +735,44 @@
             account: label,
           });
         }
-      });
+      }
       return result;
     },
 
-    _computeReplyButtonLabel: function(changeRecord) {
-      var drafts = (changeRecord && changeRecord.base) || {};
-      var draftCount = Object.keys(drafts).reduce(function(count, file) {
+    _computeReplyButtonLabel(changeRecord, canStartReview) {
+      if (canStartReview) {
+        return 'Start review';
+      }
+
+      const drafts = (changeRecord && changeRecord.base) || {};
+      const draftCount = Object.keys(drafts).reduce((count, file) => {
         return count + drafts[file].length;
       }, 0);
 
-      var label = 'Reply';
+      let label = 'Reply';
       if (draftCount > 0) {
         label += ' (' + draftCount + ')';
       }
       return label;
     },
 
-    _handleAKey: function(e) {
+    _handleAKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e) ||
-          !this._loggedIn) { return; }
+          this.modifierPressed(e)) {
+        return;
+      }
+      this._getLoggedIn().then(isLoggedIn => {
+        if (!isLoggedIn) {
+          this.fire('show-auth-required');
+          return;
+        }
 
-      e.preventDefault();
-      this._openReplyDialog();
+        e.preventDefault();
+        this._openReplyDialog();
+      });
     },
 
-    _handleDKey: function(e) {
+    _handleDKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -732,13 +780,13 @@
       this.$.downloadOverlay.open();
     },
 
-    _handleCapitalRKey: function(e) {
+    _handleCapitalRKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       e.preventDefault();
       page.show('/c/' + this._change._number);
     },
 
-    _handleSKey: function(e) {
+    _handleSKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -746,14 +794,14 @@
       this.$.changeStar.toggleStar();
     },
 
-    _handleUKey: function(e) {
+    _handleUKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
       this._determinePageBack();
     },
 
-    _handleXKey: function(e) {
+    _handleXKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -761,7 +809,7 @@
       this.$.messageList.handleExpandCollapse(true);
     },
 
-    _handleZKey: function(e) {
+    _handleZKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -769,20 +817,26 @@
       this.$.messageList.handleExpandCollapse(false);
     },
 
-    _determinePageBack: function() {
+    _handleCommaKey(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
+
+      e.preventDefault();
+      this.$.fileList.openDiffPrefs();
+    },
+
+    _determinePageBack() {
       // Default backPage to '/' if user came to change view page
       // via an email link, etc.
       page.show(this.backPage || '/');
     },
 
-    _handleLabelRemoved: function(splices, path) {
-      for (var i = 0; i < splices.length; i++) {
-        var splice = splices[i];
-        for (var j = 0; j < splice.removed.length; j++) {
-          var removed = splice.removed[j];
-          var changePath = path.split('.');
-          var labelPath = changePath.splice(0, changePath.length - 2);
-          var labelDict = this.get(labelPath);
+    _handleLabelRemoved(splices, path) {
+      for (const splice of splices) {
+        for (const removed of splice.removed) {
+          const changePath = path.split('.');
+          const labelPath = changePath.splice(0, changePath.length - 2);
+          const labelDict = this.get(labelPath);
           if (labelDict.approved &&
               labelDict.approved._account_id === removed._account_id) {
             this._reload();
@@ -792,9 +846,9 @@
       }
     },
 
-    _labelsChanged: function(changeRecord) {
+    _labelsChanged(changeRecord) {
       if (!changeRecord) { return; }
-      if (changeRecord.value.indexSplices) {
+      if (changeRecord.value && changeRecord.value.indexSplices) {
         this._handleLabelRemoved(changeRecord.value.indexSplices,
             changeRecord.path);
       }
@@ -803,51 +857,53 @@
       });
     },
 
-    _openReplyDialog: function(opt_section) {
+    _openReplyDialog(opt_section) {
       if (this.$.restAPI.hasPendingDiffDrafts()) {
         this.dispatchEvent(new CustomEvent('show-alert',
             {detail: {message: COMMENT_SAVE}, bubbles: true}));
         return;
       }
-      this.$.replyOverlay.open().then(function() {
+      this.$.replyOverlay.open().then(() => {
         this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
         this.$.replyDialog.open(opt_section);
-      }.bind(this));
+        Polymer.dom.flush();
+        this.$.replyOverlay.center();
+      });
     },
 
-    _handleReloadChange: function(e) {
-      return this._reload().then(function() {
+    _handleReloadChange(e) {
+      return this._reload().then(() => {
         // If the change was rebased, we need to reload the page with the
         // latest patch.
         if (e.detail.action === 'rebase') {
           page.show(this.changePath(this._changeNum));
         }
-      }.bind(this));
+      });
     },
 
-    _handleGetChangeDetailError: function(response) {
-      this.fire('page-error', {response: response});
+    _handleGetChangeDetailError(response) {
+      this.fire('page-error', {response});
     },
 
-    _getDiffDrafts: function() {
+    _getDiffDrafts() {
       return this.$.restAPI.getDiffDrafts(this._changeNum).then(
-          function(drafts) {
+          drafts => {
             return this._diffDrafts = drafts;
-          }.bind(this));
+          });
     },
 
-    _getLoggedIn: function() {
+    _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
     },
 
-    _getProjectConfig: function() {
+    _getProjectConfig() {
       return this.$.restAPI.getProjectConfig(this._change.project).then(
-          function(config) {
+          config => {
             this._projectConfig = config;
-          }.bind(this));
+          });
     },
 
-    _updateRebaseAction: function(revisionActions) {
+    _updateRebaseAction(revisionActions) {
       if (revisionActions && revisionActions.rebase) {
         revisionActions.rebase.rebaseOnCurrent =
             !!revisionActions.rebase.enabled;
@@ -856,73 +912,73 @@
       return revisionActions;
     },
 
-    _prepareCommitMsgForLinkify: function(msg) {
+    _prepareCommitMsgForLinkify(msg) {
       // TODO(wyatta) switch linkify sequence, see issue 5526.
       // This is a zero-with space. It is added to prevent the linkify library
       // from including R= as part of the email address.
       return msg.replace(REVIEWERS_REGEX, 'R=\u200B');
     },
 
-    _getChangeDetail: function() {
+    _getChangeDetail() {
       return this.$.restAPI.getChangeDetail(this._changeNum,
           this._handleGetChangeDetailError.bind(this)).then(
-              function(change) {
+          change => {
                 // Issue 4190: Coalesce missing topics to null.
-                if (!change.topic) { change.topic = null; }
-                if (!change.reviewer_updates) {
-                  change.reviewer_updates = null;
-                }
-                var latestRevisionSha = this._getLatestRevisionSHA(change);
-                var currentRevision = change.revisions[latestRevisionSha];
-                if (currentRevision.commit && currentRevision.commit.message) {
-                  this._latestCommitMessage = this._prepareCommitMsgForLinkify(
-                      currentRevision.commit.message);
-                } else {
-                  this._latestCommitMessage = null;
-                }
-                var lineHeight = getComputedStyle(this).lineHeight;
-                this._lineHeight = lineHeight.slice(0, lineHeight.length - 2);
+            if (!change.topic) { change.topic = null; }
+            if (!change.reviewer_updates) {
+              change.reviewer_updates = null;
+            }
+            const latestRevisionSha = this._getLatestRevisionSHA(change);
+            const currentRevision = change.revisions[latestRevisionSha];
+            if (currentRevision.commit && currentRevision.commit.message) {
+              this._latestCommitMessage = this._prepareCommitMsgForLinkify(
+                  currentRevision.commit.message);
+            } else {
+              this._latestCommitMessage = null;
+            }
+            const lineHeight = getComputedStyle(this).lineHeight;
+            this._lineHeight = lineHeight.slice(0, lineHeight.length - 2);
 
-                this._change = change;
-                if (!this._patchRange || !this._patchRange.patchNum ||
+            this._change = change;
+            if (!this._patchRange || !this._patchRange.patchNum ||
                     this._patchRange.patchNum === currentRevision._number) {
                   // CommitInfo.commit is optional, and may need patching.
-                  if (!currentRevision.commit.commit) {
-                    currentRevision.commit.commit = latestRevisionSha;
-                  }
-                  this._commitInfo = currentRevision.commit;
-                  this._currentRevisionActions =
+              if (!currentRevision.commit.commit) {
+                currentRevision.commit.commit = latestRevisionSha;
+              }
+              this._commitInfo = currentRevision.commit;
+              this._currentRevisionActions =
                       this._updateRebaseAction(currentRevision.actions);
                   // TODO: Fetch and process files.
-                }
-              }.bind(this));
+            }
+          });
     },
 
-    _getComments: function() {
+    _getComments() {
       return this.$.restAPI.getDiffComments(this._changeNum).then(
-          function(comments) {
+          comments => {
             this._comments = comments;
-          }.bind(this));
+          });
     },
 
-    _getLatestCommitMessage: function() {
+    _getLatestCommitMessage() {
       return this.$.restAPI.getChangeCommitInfo(this._changeNum,
-          this._computeLatestPatchNum(this._allPatchSets)).then(
-              function(commitInfo) {
-                this._latestCommitMessage =
+          this.computeLatestPatchNum(this._allPatchSets)).then(
+          commitInfo => {
+            this._latestCommitMessage =
                     this._prepareCommitMsgForLinkify(commitInfo.message);
-              }.bind(this));
+          });
     },
 
-    _getLatestRevisionSHA: function(change) {
+    _getLatestRevisionSHA(change) {
       if (change.current_revision) {
         return change.current_revision;
       }
       // current_revision may not be present in the case where the latest rev is
       // a draft and the user doesn’t have permission to view that rev.
-      var latestRev = null;
-      var latestPatchNum = -1;
-      for (var rev in change.revisions) {
+      let latestRev = null;
+      let latestPatchNum = -1;
+      for (const rev in change.revisions) {
         if (!change.revisions.hasOwnProperty(rev)) { continue; }
 
         if (change.revisions[rev]._number > latestPatchNum) {
@@ -933,54 +989,54 @@
       return latestRev;
     },
 
-    _getCommitInfo: function() {
+    _getCommitInfo() {
       return this.$.restAPI.getChangeCommitInfo(
           this._changeNum, this._patchRange.patchNum).then(
-              function(commitInfo) {
-                this._commitInfo = commitInfo;
-              }.bind(this));
+          commitInfo => {
+            this._commitInfo = commitInfo;
+          });
     },
 
-    _reloadDiffDrafts: function() {
+    _reloadDiffDrafts() {
       this._diffDrafts = {};
-      this._getDiffDrafts().then(function() {
+      this._getDiffDrafts().then(() => {
         if (this.$.replyOverlay.opened) {
-          this.async(function() { this.$.replyOverlay.center(); }, 1);
+          this.async(() => { this.$.replyOverlay.center(); }, 1);
         }
-      }.bind(this));
+      });
     },
 
-    _reload: function() {
+    _reload() {
       this._loading = true;
       this._relatedChangesCollapsed = true;
 
-      this._getLoggedIn().then(function(loggedIn) {
+      this._getLoggedIn().then(loggedIn => {
         if (!loggedIn) { return; }
 
         this._reloadDiffDrafts();
-      }.bind(this));
+      });
 
-      var detailCompletes = this._getChangeDetail().then(function() {
+      const detailCompletes = this._getChangeDetail().then(() => {
         this._loading = false;
         this._getProjectConfig();
-      }.bind(this));
+      });
       this._getComments();
 
       if (this._patchRange.patchNum) {
         return Promise.all([
           this._reloadPatchNumDependentResources(),
           detailCompletes,
-        ]).then(function() {
+        ]).then(() => {
           return this.$.actions.reload();
-        }.bind(this));
+        });
       } else {
         // The patch number is reliant on the change detail request.
-        return detailCompletes.then(function() {
+        return detailCompletes.then(() => {
           this.$.fileList.reload();
           if (!this._latestCommitMessage) {
             this._getLatestCommitMessage();
           }
-        }.bind(this));
+        });
       }
     },
 
@@ -988,39 +1044,61 @@
      * Kicks off requests for resources that rely on the patch range
      * (`this._patchRange`) being defined.
      */
-    _reloadPatchNumDependentResources: function() {
+    _reloadPatchNumDependentResources() {
       return Promise.all([
         this._getCommitInfo(),
         this.$.fileList.reload(),
       ]);
     },
 
-    _updateSelected: function() {
+    _updateSelected() {
       this._selectedPatchSet = this._patchRange.patchNum;
     },
 
-    _computePatchSetDescription: function(change, patchNum) {
-      var rev = this.getRevisionByPatchNum(change.revisions, patchNum);
+    _computePatchSetDescription(change, patchNum) {
+      const rev = this.getRevisionByPatchNum(change.revisions, patchNum);
       return (rev && rev.description) ?
           rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
     },
 
-    _computeDescriptionPlaceholder: function(readOnly) {
+    _computePatchSetCommentsString(allComments, patchNum) {
+      let numComments = 0;
+      let numUnresolved = 0;
+      for (const file in allComments) {
+        if(allComments.hasOwnProperty(file)) {
+          numComments += this.$.fileList.getCommentsForPath(
+              allComments, patchNum, file).length;
+          numUnresolved += this.$.fileList.computeUnresolvedNum(
+              allComments, {}, patchNum, file);
+        }
+      }
+      let commentsStr = '';
+      if (numComments > 0) {
+        commentsStr = '(' + numComments + ' comments';
+        if (numUnresolved > 0) {
+          commentsStr += ', ' + numUnresolved + ' unresolved';
+        }
+        commentsStr += ')';
+      }
+      return commentsStr;
+    },
+
+    _computeDescriptionPlaceholder(readOnly) {
       return (readOnly ? 'No' : 'Add a') + ' patch set description';
     },
 
-    _handleDescriptionChanged: function(e) {
-      var desc = e.detail.trim();
-      var rev = this.getRevisionByPatchNum(this._change.revisions,
+    _handleDescriptionChanged(e) {
+      const desc = e.detail.trim();
+      const rev = this.getRevisionByPatchNum(this._change.revisions,
           this._selectedPatchSet);
-      var sha = this._getPatchsetHash(this._change.revisions, rev);
+      const sha = this._getPatchsetHash(this._change.revisions, rev);
       this.$.restAPI.setDescription(this._changeNum,
           this._selectedPatchSet, desc)
-          .then(function(res) {
+          .then(res => {
             if (res.ok) {
               this.set(['_change', 'revisions', sha, 'description'], desc);
             }
-          }.bind(this));
+          });
     },
 
 
@@ -1029,8 +1107,8 @@
      * @param {Object} patchSet A revision already fetched from {revisions}
      * @return {string} the SHA hash corresponding to the revision.
      */
-    _getPatchsetHash: function(revisions, patchSet) {
-      for (var rev in revisions) {
+    _getPatchsetHash(revisions, patchSet) {
+      for (const rev in revisions) {
         if (revisions.hasOwnProperty(rev) &&
             revisions[rev] === patchSet) {
           return rev;
@@ -1038,65 +1116,70 @@
       }
     },
 
-    _computeDescriptionReadOnly: function(loggedIn, change, account) {
+    _computeCanStartReview(loggedIn, change, account) {
+      return !!(loggedIn && change.work_in_progress &&
+          change.owner._account_id === account._account_id);
+    },
+
+    _computeDescriptionReadOnly(loggedIn, change, account) {
       return !(loggedIn && (account._account_id === change.owner._account_id));
     },
 
-    _computeReplyDisabled: function() { return false; },
+    _computeReplyDisabled() { return false; },
 
-    _computeChangePermalinkAriaLabel: function(changeNum) {
+    _computeChangePermalinkAriaLabel(changeNum) {
       return 'Change ' + changeNum;
     },
 
-    _computeCommitClass: function(collapsed, commitMessage) {
+    _computeCommitClass(collapsed, commitMessage) {
       if (this._computeCommitToggleHidden(commitMessage)) { return ''; }
       return collapsed ? 'collapsed' : '';
     },
 
-    _computeRelatedChangesClass: function(collapsed, loading) {
+    _computeRelatedChangesClass(collapsed, loading) {
       if (!loading && !this.customStyle['--relation-chain-max-height']) {
         this._updateRelatedChangeMaxHeight();
       }
       return collapsed ? 'collapsed' : '';
     },
 
-    _computeCollapseText: function(collapsed) {
+    _computeCollapseText(collapsed) {
       // Symbols are up and down triangles.
       return collapsed ? '\u25bc Show more' : '\u25b2 Show less';
     },
 
-    _toggleCommitCollapsed: function() {
+    _toggleCommitCollapsed() {
       this._commitCollapsed = !this._commitCollapsed;
       if (this._commitCollapsed) {
         window.scrollTo(0, 0);
       }
     },
 
-    _toggleRelatedChangesCollapsed: function() {
+    _toggleRelatedChangesCollapsed() {
       this._relatedChangesCollapsed = !this._relatedChangesCollapsed;
       if (this._relatedChangesCollapsed) {
         window.scrollTo(0, 0);
       }
     },
 
-    _computeCommitToggleHidden: function(commitMessage) {
+    _computeCommitToggleHidden(commitMessage) {
       if (!commitMessage) { return true; }
       return commitMessage.split('\n').length < MIN_LINES_FOR_COMMIT_COLLAPSE;
     },
 
-    _getOffsetHeight: function(element) {
+    _getOffsetHeight(element) {
       return element.offsetHeight;
     },
 
-    _getScrollHeight: function(element) {
+    _getScrollHeight(element) {
       return element.scrollHeight;
     },
 
     /**
      * Get the line height of an element to the nearest integer.
      */
-    _getLineHeight: function(element) {
-      var lineHeightStr = getComputedStyle(element).lineHeight;
+    _getLineHeight(element) {
+      const lineHeightStr = getComputedStyle(element).lineHeight;
       return Math.round(lineHeightStr.slice(0, lineHeightStr.length - 2));
     },
 
@@ -1104,41 +1187,113 @@
      * New max height for the related changes section, shorter than the existing
      * change info height.
      */
-    _updateRelatedChangeMaxHeight: function() {
+    _updateRelatedChangeMaxHeight() {
       // Takes into account approximate height for the expand button and
       // bottom margin
-      var extraHeight = 24;
-      var maxExistingHeight;
-      var hasCommitToggle =
+      const extraHeight = 24;
+      let maxExistingHeight;
+      let newHeight;
+      const hasCommitToggle =
           !this._computeCommitToggleHidden(this._latestCommitMessage);
-      if (hasCommitToggle) {
-        // Make sure the content is lined up if both areas have buttons. If the
-        // commit message is not collapsed, instead use the change info hight.
-        maxExistingHeight = this._getOffsetHeight(this.$.commitMessage);
+
+      if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_SMALL})`)
+          .matches) {
+        // In a small (mobile) view, give the relation chain some space.
+        newHeight = SMALL_RELATED_HEIGHT;
+      } else if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_MED})`)
+          .matches) {
+        // Since related changes are below the commit message, but still next to
+        // metadata, the height should be the height of the metadata minus the
+        // height of the commit message to reduce jank. However, if that doesn't
+        // result in enough space, instead use the MINIMUM_RELATED_MAX_HEIGHT.
+        // Note: extraHeight is to take into account margin/padding.
+        const medRelatedHeight = Math.max(
+            this._getOffsetHeight(this.$.mainChangeInfo) -
+            this._getOffsetHeight(this.$.commitMessage) - 2 * extraHeight,
+            MINIMUM_RELATED_MAX_HEIGHT);
+        newHeight = medRelatedHeight;
       } else {
-        maxExistingHeight = this._getOffsetHeight(this.$.mainChangeInfo) -
-            extraHeight;
+        if (hasCommitToggle) {
+          // Make sure the content is lined up if both areas have buttons. If
+          // the commit message is not collapsed, instead use the change info
+          // height.
+          maxExistingHeight = this._getOffsetHeight(this.$.commitMessage);
+        } else {
+          maxExistingHeight = this._getOffsetHeight(this.$.mainChangeInfo) -
+              extraHeight;
+        }
+        // Get the line height of related changes, and convert it to the nearest
+        // integer.
+        const lineHeight = this._getLineHeight(this.$.relatedChanges);
+
+        // Figure out a new height that is divisible by the rounded line height.
+        const remainder = maxExistingHeight % lineHeight;
+        newHeight = maxExistingHeight - remainder;
+
+        // Update the max-height of the relation chain to this new height;
+        if (hasCommitToggle) {
+          this.customStyle['--related-change-btn-top-padding'] =
+            remainder + 'px';
+        }
       }
-
-      // Get the line height of related changes, and convert it to the nearest
-      // integer.
-      var lineHeight = this._getLineHeight(this.$.relatedChanges);
-
-      // Figure out a new height that is divisible by the rounded line height.
-      var remainder = maxExistingHeight % lineHeight;
-      var newHeight = maxExistingHeight - remainder;
-
-      // Update the max-height of the relation chain to this new height;
+      if (this.$.relatedChanges.hidden) {
+        this.customStyle['--commit-message-max-width'] = 'none';
+      }
       this.customStyle['--relation-chain-max-height'] = newHeight + 'px';
-      if (hasCommitToggle) {
-        this.customStyle['--related-change-btn-top-padding'] = remainder + 'px';
-      }
       this.updateStyles();
     },
 
-    _computeRelatedChangesToggleHidden: function() {
+    _computeRelatedChangesToggleHidden() {
       return this._getScrollHeight(this.$.relatedChanges) <=
           this._getOffsetHeight(this.$.relatedChanges);
     },
+
+    _startUpdateCheckTimer() {
+      if (!this.serverConfig ||
+          !this.serverConfig.change ||
+          this.serverConfig.change.update_delay === undefined ||
+          this.serverConfig.change.update_delay <= MIN_CHECK_INTERVAL_SECS) {
+        return;
+      }
+
+      this._updateCheckTimerHandle = this.async(() => {
+        this.fetchIsLatestKnown(this._change, this.$.restAPI)
+            .then(latest => {
+              if (latest) {
+                this._startUpdateCheckTimer();
+              } else {
+                this._cancelUpdateCheckTimer();
+                this.fire('show-alert', {
+                  message: 'A newer patch has been uploaded.',
+                  // Persist this alert.
+                  dismissOnNavigation: true,
+                  action: 'Reload',
+                  callback: function() {
+                    // Load the current change without any patch range.
+                    location.href = this.getBaseUrl() + '/c/' +
+                        this._change._number;
+                  }.bind(this),
+                });
+              }
+            });
+      }, this.serverConfig.change.update_delay * 1000);
+    },
+
+    _cancelUpdateCheckTimer() {
+      this.cancelAsync(this._updateCheckTimerHandle);
+      this._updateCheckTimerHandle = null;
+    },
+
+    _handleVisibilityChange() {
+      if (document.hidden && this._updateCheckTimerHandle) {
+        this._cancelUpdateCheckTimer();
+      } else if (!this._updateCheckTimerHandle) {
+        this._startUpdateCheckTimer();
+      }
+    },
+
+    _computeHeaderClass(change) {
+      return change.work_in_progress ? 'header wip' : 'header';
+    },
   });
 })();
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 28164cd..5847e73 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
@@ -34,114 +34,172 @@
 </test-fixture>
 
 <script>
-  suite('gr-change-view tests', function() {
-    var element;
-    var sandbox;
-    var showStub;
-    var TEST_SCROLL_TOP_PX = 100;
+  suite('gr-change-view tests', () => {
+    let element;
+    let sandbox;
+    let showStub;
+    const TEST_SCROLL_TOP_PX = 100;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
       showStub = sandbox.stub(page, 'show');
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
-        getAccount: function() { return Promise.resolve(null); },
+        getConfig() { return Promise.resolve({}); },
+        getAccount() { return Promise.resolve(null); },
       });
       element = fixture('basic');
     });
 
-    teardown(function(done) {
-      flush(function() {
+    teardown(done => {
+      flush(() => {
         sandbox.restore();
         done();
       });
     });
 
-    suite('keyboard shortcuts', function() {
-      test('S should toggle the CL star', function() {
-        var starStub = sandbox.stub(element.$.changeStar, 'toggleStar');
+    suite('keyboard shortcuts', () => {
+      test('S should toggle the CL star', () => {
+        const starStub = sandbox.stub(element.$.changeStar, 'toggleStar');
         MockInteractions.pressAndReleaseKeyOn(element, 83, null, 's');
         assert(starStub.called);
       });
 
-      test('U should navigate to / if no backPage set', function() {
+      test('U should navigate to / if no backPage set', () => {
         MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
         assert(showStub.lastCall.calledWithExactly('/'));
       });
 
-      test('U should navigate to backPage if set', function() {
+      test('U should navigate to backPage if set', () => {
         element.backPage = '/dashboard/self';
         MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
         assert(showStub.lastCall.calledWithExactly('/dashboard/self'));
       });
 
-      test('A should toggle overlay', function() {
+      test('A fires an error event when not logged in', done => {
+        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
+        const loggedInErrorSpy = sandbox.spy();
+        element.addEventListener('show-auth-required', loggedInErrorSpy);
         MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-        var overlayEl = element.$.replyOverlay;
-        assert.isFalse(overlayEl.opened);
-        element._loggedIn = true;
-
-        MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
-        assert.isFalse(overlayEl.opened);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-        assert.isTrue(overlayEl.opened);
-        overlayEl.close();
-        assert.isFalse(overlayEl.opened);
+        flush(() => {
+          assert.isFalse(element.$.replyOverlay.opened);
+          assert.isTrue(loggedInErrorSpy.called);
+          done();
+        });
       });
 
-      test('X should expand all messages', function() {
-        var handleExpand =
+      test('shift A does not open reply overlay', done => {
+        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+        MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
+        flush(() => {
+          assert.isFalse(element.$.replyOverlay.opened);
+          done();
+        });
+      });
+
+      test('A toggles overlay when logged in', done => {
+        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+        sandbox.stub(element.$.replyDialog, 'fetchIsLatestKnown')
+            .returns(Promise.resolve(true));
+        element._change = {labels: {}};
+        MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+        flush(() => {
+          assert.isTrue(element.$.replyOverlay.opened);
+          element.$.replyOverlay.close();
+          assert.isFalse(element.$.replyOverlay.opened);
+          done();
+        });
+      });
+
+      test('X should expand all messages', () => {
+        const handleExpand =
             sandbox.stub(element.$.messageList, 'handleExpandCollapse');
         MockInteractions.pressAndReleaseKeyOn(element, 88, null, 'x');
         assert(handleExpand.calledWith(true));
       });
 
-      test('Z should collapse all messages', function() {
-         var handleExpand =
+      test('Z should collapse all messages', () => {
+        const handleExpand =
             sandbox.stub(element.$.messageList, 'handleExpandCollapse');
         MockInteractions.pressAndReleaseKeyOn(element, 90, null, 'z');
         assert(handleExpand.calledWith(false));
       });
 
       test('shift + R should fetch and navigate to the latest patch set',
-          function(done) {
-        element._changeNum = '42';
-        element._patchRange = {
-          basePatchNum: 'PARENT',
-          patchNum: 1,
-        };
-        element._change = {
-          change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-          _number: 42,
-          revisions: {
-            rev1: {_number: 1},
-          },
-          current_revision: 'rev1',
-          status: 'NEW',
-          labels: {},
-          actions: {},
-        };
+          done => {
+            element._changeNum = '42';
+            element._patchRange = {
+              basePatchNum: 'PARENT',
+              patchNum: 1,
+            };
+            element._change = {
+              change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+              _number: 42,
+              revisions: {
+                rev1: {_number: 1},
+              },
+              current_revision: 'rev1',
+              status: 'NEW',
+              labels: {},
+              actions: {},
+            };
 
-        sandbox.stub(element.$.actions, 'reload');
+            sandbox.stub(element.$.actions, 'reload');
 
-        showStub.restore();
-        showStub = sandbox.stub(page, 'show', function(arg) {
-          assert.equal(arg, '/c/42');
-          done();
-        });
+            showStub.restore();
+            showStub = sandbox.stub(page, 'show', arg => {
+              assert.equal(arg, '/c/42');
+              done();
+            });
 
-        MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
+            MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
+          });
+
+      test('d should open download overlay', () => {
+        const stub = sandbox.stub(element.$.downloadOverlay, 'open');
+        MockInteractions.pressAndReleaseKeyOn(element, 68, null, 'd');
+        assert.isTrue(stub.called);
       });
 
-      test('d should open download overlay', function() {
-        var stub = sandbox.stub(element.$.downloadOverlay, 'open');
-        MockInteractions.pressAndReleaseKeyOn(element, 68, null, 'd');
+      test(', should open diff preferences', () => {
+        const stub = sandbox.stub(element.$.fileList.$.diffPreferences, 'open');
+        MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
         assert.isTrue(stub.called);
       });
     });
 
-    test('_computeDescriptionReadOnly', function() {
+    test('Diff preferences hidden when no prefs or logged out', () => {
+      element._loggedIn = false;
+      flushAsynchronousOperations();
+      assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+      element._loggedIn = true;
+      flushAsynchronousOperations();
+      assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+      element._loggedIn = false;
+      element._diffPrefs = {font_size: '12'};
+      flushAsynchronousOperations();
+      assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+      element._loggedIn = true;
+      flushAsynchronousOperations();
+      assert.isFalse(element.$.diffPrefsContainer.hidden);
+    });
+
+    test('prefsButton opens gr-diff-preferences', () => {
+      const handlePrefsTapSpy = sandbox.spy(element, '_handlePrefsTap');
+      const overlayOpenStub = sandbox.stub(element.$.fileList,
+          'openDiffPrefs');
+      const prefsButton = Polymer.dom(element.root).querySelectorAll(
+          '.prefsButton')[0];
+
+      MockInteractions.tap(prefsButton);
+
+      assert.isTrue(handlePrefsTapSpy.called);
+      assert.isTrue(overlayOpenStub.called);
+    });
+
+    test('_computeDescriptionReadOnly', () => {
       assert.equal(element._computeDescriptionReadOnly(false,
           {owner: {_account_id: 1}}, {_account_id: 1}), true);
       assert.equal(element._computeDescriptionReadOnly(true,
@@ -150,16 +208,16 @@
           {owner: {_account_id: 1}}, {_account_id: 1}), false);
     });
 
-    test('_computeDescriptionPlaceholder', function() {
+    test('_computeDescriptionPlaceholder', () => {
       assert.equal(element._computeDescriptionPlaceholder(true),
           'No patch set description');
       assert.equal(element._computeDescriptionPlaceholder(false),
           'Add a patch set description');
     });
 
-    test('_computePatchSetDisabled', function() {
-      var basePatchNum = 'PARENT';
-      var patchNum = 1;
+    test('_computePatchSetDisabled', () => {
+      let basePatchNum = 'PARENT';
+      let patchNum = 1;
       assert.equal(element._computePatchSetDisabled(patchNum, basePatchNum),
           false);
       basePatchNum = 1;
@@ -170,18 +228,56 @@
           false);
     });
 
-    test('_prepareCommitMsgForLinkify', function() {
-      var commitMessage = 'R=test@google.com';
-      var result = element._prepareCommitMsgForLinkify(commitMessage);
+    test('_prepareCommitMsgForLinkify', () => {
+      let commitMessage = 'R=test@google.com';
+      let result = element._prepareCommitMsgForLinkify(commitMessage);
       assert.equal(result, 'R=\u200Btest@google.com');
 
       commitMessage = 'R=test@google.com\nR=test@google.com';
-      var result = element._prepareCommitMsgForLinkify(commitMessage);
+      result = element._prepareCommitMsgForLinkify(commitMessage);
       assert.equal(result, 'R=\u200Btest@google.com\nR=\u200Btest@google.com');
     }),
 
-    test('_handleDescriptionChanged', function() {
-      var putDescStub = sandbox.stub(element.$.restAPI, 'setDescription')
+    test('_computePatchSetCommentsString', () => {
+      // Test string with unresolved comments.
+      comments = {
+        foo: 'foo comments',
+        bar: 'bar comments',
+        xyz: 'xyz comments',
+      };
+      sandbox.stub(element.$.fileList, 'getCommentsForPath', (c, p, f) => {
+        if (f == 'foo') {
+          return ['comment1', 'comment2'];
+        } else if (f == 'bar') {
+          return ['comment1'];
+        } else {
+          return [];
+        }
+      });
+      sandbox.stub(element.$.fileList, 'computeUnresolvedNum', (c, d, p, f) => {
+        if (f == 'foo') {
+          return 0;
+        } else if (f == 'bar') {
+          return 1;
+        } else {
+          return 0;
+        }
+      });
+      assert.equal(element._computePatchSetCommentsString(comments, 1),
+          '(3 comments, 1 unresolved)');
+
+      // Test string with no unresolved comments.
+      delete comments['bar'];
+      assert.equal(element._computePatchSetCommentsString(comments, 1),
+          '(2 comments)');
+
+      // Test string with no comments.
+      delete comments['foo'];
+      assert.equal(element._computePatchSetCommentsString(comments, 1), '');
+    });
+
+    test('_handleDescriptionChanged', () => {
+      const putDescStub = sandbox.stub(element.$.restAPI, 'setDescription')
           .returns(Promise.resolve({ok: true}));
       sandbox.stub(element, '_computeDescriptionReadOnly');
 
@@ -206,7 +302,7 @@
       element._loggedIn = true;
 
       flushAsynchronousOperations();
-      var label = element.$.descriptionLabel;
+      const label = element.$.descriptionLabel;
       assert.equal(label.value, 'test');
       label.editing = true;
       label._inputText = 'test2';
@@ -216,19 +312,19 @@
       assert.equal(putDescStub.args[0][2], 'test2');
     });
 
-    test('_updateRebaseAction', function() {
-      var currentRevisionActions = {
+    test('_updateRebaseAction', () => {
+      const currentRevisionActions = {
         cherrypick: {
           enabled: true,
           label: 'Cherry Pick',
           method: 'POST',
-          title: 'cherrypick'
+          title: 'cherrypick',
         },
         rebase: {
           enabled: true,
           label: 'Rebase',
           method: 'POST',
-          title: 'Rebase onto tip of branch or parent change'
+          title: 'Rebase onto tip of branch or parent change',
         },
       };
 
@@ -236,7 +332,7 @@
       // When rebase is enabled initially, rebaseOnCurrent should be set to
       // true.
       assert.equal(element._updateRebaseAction(currentRevisionActions),
-        currentRevisionActions);
+          currentRevisionActions);
 
       assert.isTrue(currentRevisionActions.rebase.enabled);
       assert.isTrue(currentRevisionActions.rebase.rebaseOnCurrent);
@@ -246,14 +342,14 @@
       // When rebase is not enabled initially, rebaseOnCurrent should be set to
       // false.
       assert.equal(element._updateRebaseAction(currentRevisionActions),
-        currentRevisionActions);
+          currentRevisionActions);
 
       assert.isTrue(currentRevisionActions.rebase.enabled);
       assert.isFalse(currentRevisionActions.rebase.rebaseOnCurrent);
     });
 
-    test('_reload is called when an approved label is removed', function() {
-      var vote = {_account_id: 1, name: 'bojack', value: 1};
+    test('_reload is called when an approved label is removed', () => {
+      const vote = {_account_id: 1, name: 'bojack', value: 1};
       element._changeNum = '42';
       element._patchRange = {
         basePatchNum: 'PARENT',
@@ -279,7 +375,7 @@
         },
       };
       flushAsynchronousOperations();
-      var reloadStub = sandbox.stub(element, '_reload');
+      const reloadStub = sandbox.stub(element, '_reload');
       element.splice('_change.labels.test.all', 0, 1);
       assert.isFalse(reloadStub.called);
       element._change.labels.test.all.push(vote);
@@ -291,30 +387,37 @@
       assert.isTrue(reloadStub.calledOnce);
     });
 
-    test('reply button has updated count when there are drafts', function() {
-      var replyButton = element.$$('gr-button.reply');
-      assert.ok(replyButton);
-      assert.equal(replyButton.textContent, 'Reply');
+    test('reply button has updated count when there are drafts', () => {
+      const getLabel = element._computeReplyButtonLabel;
 
-      element._diffDrafts = null;
-      assert.equal(replyButton.textContent, 'Reply');
+      assert.equal(getLabel(null, false), 'Reply');
+      assert.equal(getLabel(null, true), 'Start review');
 
-      element._diffDrafts = {};
-      assert.equal(replyButton.textContent, 'Reply');
+      const changeRecord = {base: null};
+      assert.equal(getLabel(changeRecord, false), 'Reply');
 
-      element._diffDrafts = {
+      changeRecord.base = {};
+      assert.equal(getLabel(changeRecord, false), 'Reply');
+
+      changeRecord.base = {
         'file1.txt': [{}],
         'file2.txt': [{}, {}],
       };
-      assert.equal(replyButton.textContent, 'Reply (3)');
+      assert.equal(getLabel(changeRecord, false), 'Reply (3)');
     });
 
-    test('comment events properly update diff drafts', function() {
+    test('start review button when owner of WIP change', () => {
+      assert.equal(
+          element._computeReplyButtonLabel(null, true),
+          'Start review');
+    });
+
+    test('comment events properly update diff drafts', () => {
       element._patchRange = {
         basePatchNum: 'PARENT',
         patchNum: 2,
       };
-      var draft = {
+      const draft = {
         __draft: true,
         id: 'id1',
         path: '/foo/bar.txt',
@@ -328,7 +431,7 @@
       element._handleCommentSave({target: {comment: draft}});
       draft.patch_set = 2;
       assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
-      var draft2 = {
+      const draft2 = {
         __draft: true,
         id: 'id2',
         path: '/foo/bar.txt',
@@ -345,7 +448,7 @@
       assert.deepEqual(element._diffDrafts, {});
     });
 
-    test('change num change', function() {
+    test('change num change', () => {
       element._changeNum = null;
       element._patchRange = {
         basePatchNum: 'PARENT',
@@ -357,8 +460,12 @@
       };
       element.viewState.changeNum = null;
       element.viewState.diffMode = 'UNIFIED';
+      assert.equal(element.viewState.numFilesShown, 200);
+      assert.equal(element._numFilesShown, 200);
+      element._numFilesShown = 150;
       flushAsynchronousOperations();
       assert.equal(element.viewState.diffMode, 'UNIFIED');
+      assert.equal(element.viewState.numFilesShown, 150);
 
       element._changeNum = '1';
       element.params = {changeNum: '1'};
@@ -373,9 +480,11 @@
       flushAsynchronousOperations();
       assert.isNull(element.viewState.diffMode);
       assert.equal(element.viewState.changeNum, '2');
+      assert.equal(element.viewState.numFilesShown, 200);
+      assert.equal(element._numFilesShown, 200);
     });
 
-    test('patch num change', function(done) {
+    test('patch num change', done => {
       element._changeNum = '42';
       element._patchRange = {
         basePatchNum: 'PARENT',
@@ -396,19 +505,19 @@
       element.viewState.diffMode = 'UNIFIED';
       flushAsynchronousOperations();
 
-      var selectEl = element.$$('.patchInfo-header select');
+      const selectEl = element.$$('.patchInfo-header select');
       assert.ok(selectEl);
-      var optionEls = Polymer.dom(element.root).querySelectorAll(
+      const optionEls = Polymer.dom(element.root).querySelectorAll(
           '.patchInfo-header option');
       assert.equal(optionEls.length, 4);
-      var select = element.$$('.patchInfo-header #patchSetSelect').bindValue;
+      const select = element.$$('.patchInfo-header #patchSetSelect').bindValue;
       assert.notEqual(select, 1);
       assert.equal(select, 2);
       assert.notEqual(select, 3);
       assert.equal(optionEls[3].value, 13);
 
-      var numEvents = 0;
-      selectEl.addEventListener('change', function(e) {
+      let numEvents = 0;
+      selectEl.addEventListener('change', e => {
         assert.equal(element.viewState.diffMode, 'UNIFIED');
         numEvents++;
         if (numEvents == 1) {
@@ -426,7 +535,7 @@
       element.fire('change', {}, {node: selectEl});
     });
 
-    test('patch num change with missing current_revision', function(done) {
+    test('patch num change with missing current_revision', done => {
       element._changeNum = '42';
       element._patchRange = {
         basePatchNum: 'PARENT',
@@ -444,21 +553,21 @@
         labels: {},
       };
       flushAsynchronousOperations();
-      var selectEl = element.$$('.patchInfo-header select');
+      const selectEl = element.$$('.patchInfo-header select');
       assert.ok(selectEl);
-      var optionEls = Polymer.dom(element.root).querySelectorAll(
+      const optionEls = Polymer.dom(element.root).querySelectorAll(
           '.patchInfo-header option');
       assert.equal(optionEls.length, 4);
       assert.notEqual(
-        element.$$('.patchInfo-header #patchSetSelect').bindValue, 1);
+          element.$$('.patchInfo-header #patchSetSelect').bindValue, 1);
       assert.equal(
-        element.$$('.patchInfo-header #patchSetSelect').bindValue, 2);
+          element.$$('.patchInfo-header #patchSetSelect').bindValue, 2);
       assert.notEqual(
-        element.$$('.patchInfo-header #patchSetSelect').bindValue, 3);
+          element.$$('.patchInfo-header #patchSetSelect').bindValue, 3);
       assert.equal(optionEls[3].value, 13);
 
-      var numEvents = 0;
-      selectEl.addEventListener('change', function(e) {
+      let numEvents = 0;
+      selectEl.addEventListener('change', e => {
         numEvents++;
         if (numEvents == 1) {
           assert(showStub.lastCall.calledWithExactly('/c/42/1'),
@@ -475,15 +584,15 @@
       element.fire('change', {}, {node: selectEl});
     });
 
-    test('don’t reload entire page when patchRange changes', function() {
-      var reloadStub = sandbox.stub(element, '_reload',
-          function() { return Promise.resolve(); });
-      var reloadPatchDependentStub = sandbox.stub(element,
+    test('don’t reload entire page when patchRange changes', () => {
+      const reloadStub = sandbox.stub(element, '_reload',
+          () => { return Promise.resolve(); });
+      const reloadPatchDependentStub = sandbox.stub(element,
           '_reloadPatchNumDependentResources',
-          function() { return Promise.resolve(); });
-      var relatedClearSpy = sandbox.spy(element.$.relatedChanges, 'clear');
+          () => { return Promise.resolve(); });
+      const relatedClearSpy = sandbox.spy(element.$.relatedChanges, 'clear');
 
-      var value = {
+      const value = {
         view: 'gr-change-view',
         patchNum: '1',
       };
@@ -501,11 +610,11 @@
       assert.isTrue(relatedClearSpy.calledOnce);
     });
 
-    test('reload entire page when patchRange doesnt change', function() {
-      var reloadStub = sandbox.stub(element, '_reload',
-          function() { return Promise.resolve(); });
+    test('reload entire page when patchRange doesnt change', () => {
+      const reloadStub = sandbox.stub(element, '_reload',
+          () => { return Promise.resolve(); });
 
-      var value = {
+      const value = {
         view: 'gr-change-view',
       };
       element._paramsChanged(value);
@@ -515,7 +624,7 @@
       assert.isTrue(reloadStub.calledTwice);
     });
 
-    test('include base patch when not parent', function() {
+    test('include base patch when not parent', () => {
       element._changeNum = '42';
       element._patchRange = {
         basePatchNum: '2',
@@ -543,33 +652,32 @@
     });
 
     test('related changes are updated and new patch selected after rebase',
-        function(done) {
-      element._changeNum = '42';
-      sandbox.stub(element, '_computeLatestPatchNum', function() {
-        return 1;
-      });
-      sandbox.stub(element, '_reload',
-          function() { return Promise.resolve(); });
-      var e = {detail: {action: 'rebase'}};
-      element._handleReloadChange(e).then(function() {
-        assert.isTrue(showStub.lastCall.calledWithExactly('/c/42'));
-        done();
-      });
-    });
+        done => {
+          element._changeNum = '42';
+          sandbox.stub(element, 'computeLatestPatchNum', () => {
+            return 1;
+          });
+          sandbox.stub(element, '_reload',
+              () => { return Promise.resolve(); });
+          const e = {detail: {action: 'rebase'}};
+          element._handleReloadChange(e).then(() => {
+            assert.isTrue(showStub.lastCall.calledWithExactly('/c/42'));
+            done();
+          });
+        });
 
-    test('related changes are not updated after other action', function(done) {
-      sandbox.stub(element, '_reload',
-          function() { return Promise.resolve(); });
+    test('related changes are not updated after other action', done => {
+      sandbox.stub(element, '_reload', () => { return Promise.resolve(); });
       sandbox.stub(element, '_updateSelected');
       sandbox.stub(element.$.relatedChanges, 'reload');
-      var e = {detail: {action: 'abandon'}};
-      element._handleReloadChange(e).then(function() {
+      const e = {detail: {action: 'abandon'}};
+      element._handleReloadChange(e).then(() => {
         assert.isFalse(showStub.called);
         done();
       });
     });
 
-    test('change status new', function() {
+    test('change status new', () => {
       element._changeNum = '1';
       element._patchRange = {
         basePatchNum: 'PARENT',
@@ -584,11 +692,11 @@
         status: 'NEW',
         labels: {},
       };
-      var status = element._computeChangeStatus(element._change, '1');
+      const status = element._computeChangeStatus(element._change, '1');
       assert.equal(status, '');
     });
 
-    test('change status draft', function() {
+    test('change status draft', () => {
       element._changeNum = '1';
       element._patchRange = {
         basePatchNum: 'PARENT',
@@ -603,11 +711,11 @@
         status: 'DRAFT',
         labels: {},
       };
-      var status = element._computeChangeStatus(element._change, '1');
+      const status = element._computeChangeStatus(element._change, '1');
       assert.equal(status, 'Draft');
     });
 
-    test('change status conflict', function() {
+    test('change status conflict', () => {
       element._changeNum = '1';
       element._patchRange = {
         basePatchNum: 'PARENT',
@@ -623,11 +731,51 @@
         status: 'NEW',
         labels: {},
       };
-      var status = element._computeChangeStatus(element._change, '1');
+      const status = element._computeChangeStatus(element._change, '1');
       assert.equal(status, 'Merge Conflict');
     });
 
-    test('change status merged', function() {
+    test('change status private', () => {
+      element._changeNum = '1';
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 1,
+      };
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev1: {_number: 1},
+        },
+        current_revision: 'rev1',
+        status: 'NEW',
+        labels: {},
+        is_private: true,
+      };
+      const status = element._privateChanges(element._change);
+      assert.equal(status, ' (Private)');
+    });
+
+    test('change status without private', () => {
+      element._changeNum = '1';
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 1,
+      };
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev1: {_number: 1},
+        },
+        current_revision: 'rev1',
+        status: 'NEW',
+        labels: {},
+        is_private: false,
+      };
+      const status = element._privateChanges(element._change);
+      assert.equal(status, '');
+    });
+
+    test('change status merged', () => {
       element._changeNum = '1';
       element._patchRange = {
         basePatchNum: 'PARENT',
@@ -642,11 +790,11 @@
         status: element.ChangeStatus.MERGED,
         labels: {},
       };
-      var status = element._computeChangeStatus(element._change, '1');
+      const status = element._computeChangeStatus(element._change, '1');
       assert.equal(status, 'Merged');
     });
 
-    test('revision status draft', function() {
+    test('revision status draft', () => {
       element._changeNum = '1';
       element._patchRange = {
         basePatchNum: 'PARENT',
@@ -665,12 +813,12 @@
         status: 'NEW',
         labels: {},
       };
-      var status = element._computeChangeStatus(element._change, '2');
+      const status = element._computeChangeStatus(element._change, '2');
       assert.equal(status, 'Draft');
     });
 
-    test('_computeMergedCommitInfo', function() {
-      var dummyRevs = {
+    test('_computeMergedCommitInfo', () => {
+      const dummyRevs = {
         1: {commit: {commit: 1}},
         2: {commit: {}},
       };
@@ -679,13 +827,13 @@
           dummyRevs[1].commit);
 
       // Regression test for issue 5337.
-      var commit = element._computeMergedCommitInfo(2, dummyRevs);
+      const commit = element._computeMergedCommitInfo(2, dummyRevs);
       assert.notDeepEqual(commit, dummyRevs[2]);
       assert.deepEqual(commit, {commit: 2});
     });
 
-    test('get latest revision', function() {
-      var change = {
+    test('get latest revision', () => {
+      let change = {
         revisions: {
           rev1: {_number: 1},
           rev3: {_number: 3},
@@ -701,8 +849,8 @@
       assert.equal(element._getLatestRevisionSHA(change), 'rev1');
     });
 
-    test('show commit message edit button', function() {
-      var _change = {
+    test('show commit message edit button', () => {
+      const _change = {
         status: element.ChangeStatus.MERGED,
       };
       assert.isTrue(element._computeHideEditCommitMessage(false, false, {}));
@@ -713,80 +861,80 @@
           _change));
     });
 
-    test('_computeChangeIdCommitMessageError', function() {
-      var commitMessage =
+    test('_computeChangeIdCommitMessageError', () => {
+      let commitMessage =
         'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483';
-      var change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
+      let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
       assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        null);
+          element._computeChangeIdCommitMessageError(commitMessage, change),
+          null);
 
       change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
       assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        'mismatch');
+          element._computeChangeIdCommitMessageError(commitMessage, change),
+          'mismatch');
 
       commitMessage = 'This is the greatest change.';
       assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        'missing');
+          element._computeChangeIdCommitMessageError(commitMessage, change),
+          'missing');
     });
 
-    test('multiple change Ids in commit message picks last', function() {
-      var commitMessage = [
-       'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
-       'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
+    test('multiple change Ids in commit message picks last', () => {
+      const commitMessage = [
+        'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
+        'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
       ].join('\n');
-      var change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
+      let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
       assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        null);
+          element._computeChangeIdCommitMessageError(commitMessage, change),
+          null);
       change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
       assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        'mismatch');
+          element._computeChangeIdCommitMessageError(commitMessage, change),
+          'mismatch');
     });
 
-    test('does not count change Id that starts mid line', function() {
-      var commitMessage = [
-       'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
-       'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
+    test('does not count change Id that starts mid line', () => {
+      const commitMessage = [
+        'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
+        'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
       ].join(' and ');
-      var change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
+      let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
       assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        null);
+          element._computeChangeIdCommitMessageError(commitMessage, change),
+          null);
       change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
       assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        'mismatch');
+          element._computeChangeIdCommitMessageError(commitMessage, change),
+          'mismatch');
     });
 
-    test('_computeTitleAttributeWarning', function() {
-      var changeIdCommitMessageError = 'missing';
+    test('_computeTitleAttributeWarning', () => {
+      let changeIdCommitMessageError = 'missing';
       assert.equal(
           element._computeTitleAttributeWarning(changeIdCommitMessageError),
           'No Change-Id in commit message');
 
-      var changeIdCommitMessageError = 'mismatch';
+      changeIdCommitMessageError = 'mismatch';
       assert.equal(
           element._computeTitleAttributeWarning(changeIdCommitMessageError),
           'Change-Id mismatch');
     });
 
-    test('_computeChangeIdClass', function() {
-      var changeIdCommitMessageError = 'missing';
+    test('_computeChangeIdClass', () => {
+      let changeIdCommitMessageError = 'missing';
       assert.equal(
-        element._computeChangeIdClass(changeIdCommitMessageError), '');
+          element._computeChangeIdClass(changeIdCommitMessageError), '');
 
-      var changeIdCommitMessageError = 'mismatch';
+      changeIdCommitMessageError = 'mismatch';
       assert.equal(
-        element._computeChangeIdClass(changeIdCommitMessageError), 'warning');
+          element._computeChangeIdClass(changeIdCommitMessageError), 'warning');
     });
 
-    test('topic is coalesced to null', function(done) {
+    test('topic is coalesced to null', done => {
       sandbox.stub(element, '_changeChanged');
-      sandbox.stub(element.$.restAPI, 'getChangeDetail', function() {
+      sandbox.stub(element.$.restAPI, 'getChangeDetail', () => {
         return Promise.resolve({
           id: '123456789',
           labels: {},
@@ -795,15 +943,15 @@
         });
       });
 
-      element._getChangeDetail().then(function() {
+      element._getChangeDetail().then(() => {
         assert.isNull(element._change.topic);
         done();
       });
     });
 
-    test('commit sha is populated from getChangeDetail', function(done) {
+    test('commit sha is populated from getChangeDetail', done => {
       sandbox.stub(element, '_changeChanged');
-      sandbox.stub(element.$.restAPI, 'getChangeDetail', function() {
+      sandbox.stub(element.$.restAPI, 'getChangeDetail', () => {
         return Promise.resolve({
           id: '123456789',
           labels: {},
@@ -812,17 +960,17 @@
         });
       });
 
-      element._getChangeDetail().then(function() {
+      element._getChangeDetail().then(() => {
         assert.equal('foo', element._commitInfo.commit);
         done();
       });
     });
 
-    test('reply dialog focus can be controlled', function() {
-      var FocusTarget = element.$.replyDialog.FocusTarget;
-      var openStub = sandbox.stub(element, '_openReplyDialog');
+    test('reply dialog focus can be controlled', () => {
+      const FocusTarget = element.$.replyDialog.FocusTarget;
+      const openStub = sandbox.stub(element, '_openReplyDialog');
 
-      var e = {detail: {}};
+      const e = {detail: {}};
       element._handleShowReplyDialog(e);
       assert(openStub.lastCall.calledWithExactly(FocusTarget.REVIEWERS),
           '_openReplyDialog should have been passed REVIEWERS');
@@ -833,8 +981,8 @@
           '_openReplyDialog should have been passed CCS');
     });
 
-    test('class is applied to file list on old patch set', function() {
-      var allPatchSets = [{num: 1}, {num: 2}, {num: 4}];
+    test('class is applied to file list on old patch set', () => {
+      const allPatchSets = [{num: 1}, {num: 2}, {num: 4}];
       assert.equal(element._computePatchInfoClass('1', allPatchSets),
           'patchInfo--oldPatchSet');
       assert.equal(element._computePatchInfoClass('2', allPatchSets),
@@ -842,8 +990,8 @@
       assert.equal(element._computePatchInfoClass('4', allPatchSets), '');
     });
 
-    test('getUrlParameter functionality', function() {
-      var locationStub = sandbox.stub(element, '_getLocationSearch');
+    test('getUrlParameter functionality', () => {
+      const locationStub = sandbox.stub(element, '_getLocationSearch');
 
       locationStub.returns('?test');
       assert.equal(element._getUrlParameter('test'), 'test');
@@ -855,14 +1003,13 @@
       assert.isNull(element._getUrlParameter('test'));
       locationStub.returns('?test2');
       assert.isNull(element._getUrlParameter('test'));
-
     });
 
-    test('revert dialog opened with revert param', function(done) {
-      sandbox.stub(element.$.restAPI, 'getLoggedIn', function() {
+    test('revert dialog opened with revert param', done => {
+      sandbox.stub(element.$.restAPI, 'getLoggedIn', () => {
         return Promise.resolve(true);
       });
-      sandbox.stub(Gerrit, 'awaitPluginsLoaded', function() {
+      sandbox.stub(Gerrit, 'awaitPluginsLoaded', () => {
         return Promise.resolve();
       });
 
@@ -881,95 +1028,94 @@
         actions: {},
       };
 
-      var urlParamStub = sandbox.stub(element, '_getUrlParameter',
-          function(param) {
+      sandbox.stub(element, '_getUrlParameter',
+          param => {
             assert.equal(param, 'revert');
             return param;
           });
 
-      var revertDialogStub = sandbox.stub(element.$.actions, 'showRevertDialog',
+      sandbox.stub(element.$.actions, 'showRevertDialog',
           done);
 
       element._maybeShowRevertDialog();
       assert.isTrue(Gerrit.awaitPluginsLoaded.called);
     });
 
-    suite('scroll related tests', function() {
-      test('document scrolling calls function to set scroll height',
-          function(done) {
-            var originalHeight = document.body.scrollHeight;
-            var scrollStub = sandbox.stub(element, '_handleScroll',
-                function() {
-                  assert.isTrue(scrollStub.called);
-                  document.body.style.height =
-                      originalHeight + 'px';
-                  scrollStub.restore();
-                  done();
-                });
-            document.body.style.height = '10000px';
-            document.body.scrollTop = TEST_SCROLL_TOP_PX;
-            element._handleScroll();
-          });
-
-      test('history is loaded correctly', function() {
-        history.replaceState(
-            {
-              scrollTop: 100,
-              path: location.pathname,
-            },
-            location.pathname);
-
-        var reloadStub = sandbox.stub(element, '_reload',
-            function() {
-              // When element is reloaded, ensure that the history
-              // state has the scrollTop set earlier. This will then
-              // be reset.
-              assert.isTrue(history.state.scrollTop == 100);
-              return Promise.resolve({});
+    suite('scroll related tests', () => {
+      test('document scrolling calls function to set scroll height', done => {
+        const originalHeight = document.body.scrollHeight;
+        const scrollStub = sandbox.stub(element, '_handleScroll',
+            () => {
+              assert.isTrue(scrollStub.called);
+              document.body.style.height = originalHeight + 'px';
+              scrollStub.restore();
+              done();
             });
+        document.body.style.height = '10000px';
+        element._handleScroll();
+      });
+
+      test('scrollTop is set correctly', () => {
+        element.viewState = {scrollTop: TEST_SCROLL_TOP_PX};
+
+        sandbox.stub(element, '_reload', () => {
+          // When element is reloaded, ensure that the history
+          // state has the scrollTop set earlier. This will then
+          // be reset.
+          assert.isTrue(element.viewState.scrollTop == TEST_SCROLL_TOP_PX);
+          return Promise.resolve({});
+        });
 
         // simulate reloading component, which is done when route
         // changes to match a regex of change view type.
         element._paramsChanged({view: 'gr-change-view'});
       });
+
+      test('scrollTop is reset when new change is loaded', () => {
+        element._resetFileListViewState();
+        assert.equal(element.viewState.scrollTop, 0);
+      });
     });
 
-    suite('reply dialog tests', function() {
-      setup(function() {
+    suite('reply dialog tests', () => {
+      setup(() => {
         sandbox.stub(element.$.replyDialog, '_draftChanged');
+        sandbox.stub(element.$.replyDialog, 'fetchIsLatestKnown',
+            () => { return Promise.resolve(true); });
+        element._change = {labels: {}};
       });
 
-      test('reply from comment adds quote text', function() {
-        var e = {detail: {message: {message: 'quote text'}}};
+      test('reply from comment adds quote text', () => {
+        const e = {detail: {message: {message: 'quote text'}}};
         element._handleMessageReply(e);
         assert.equal(element.$.replyDialog.draft, '> quote text\n\n');
         assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
       });
 
-      test('reply from comment replaces quote text', function() {
+      test('reply from comment replaces quote text', () => {
         element.$.replyDialog.draft = '> old quote text\n\n some draft text';
         element.$.replyDialog.quote = '> old quote text\n\n';
-        var e = {detail: {message: {message: 'quote text'}}};
+        const e = {detail: {message: {message: 'quote text'}}};
         element._handleMessageReply(e);
         assert.equal(element.$.replyDialog.draft, '> quote text\n\n');
         assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
       });
 
-      test('reply from same comment preserves quote text', function() {
+      test('reply from same comment preserves quote text', () => {
         element.$.replyDialog.draft = '> quote text\n\n some draft text';
         element.$.replyDialog.quote = '> quote text\n\n';
-        var e = {detail: {message: {message: 'quote text'}}};
+        const e = {detail: {message: {message: 'quote text'}}};
         element._handleMessageReply(e);
         assert.equal(element.$.replyDialog.draft,
             '> quote text\n\n some draft text');
         assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
       });
 
-      test('reply from top of page contains previous draft', function() {
-        var div = document.createElement('div');
+      test('reply from top of page contains previous draft', () => {
+        const div = document.createElement('div');
         element.$.replyDialog.draft = '> quote text\n\n some draft text';
         element.$.replyDialog.quote = '> quote text\n\n';
-        var e = {target: div, preventDefault: sandbox.spy()};
+        const e = {target: div, preventDefault: sandbox.spy()};
         element._handleReplyTap(e);
         assert.equal(element.$.replyDialog.draft,
             '> quote text\n\n some draft text');
@@ -977,24 +1123,29 @@
       });
     });
 
-    test('reply button is disabled until server config is loaded', function() {
+    test('reply button is disabled until server config is loaded', () => {
       assert.isTrue(element._replyDisabled);
       element.serverConfig = {};
       assert.isFalse(element._replyDisabled);
     });
 
-    suite('commit message expand/collapse', function() {
-      test('commitCollapseToggle hidden for short commit message', function() {
+    suite('commit message expand/collapse', () => {
+      setup(() => {
+        sandbox.stub(element, 'fetchIsLatestKnown',
+            () => { return Promise.resolve(false); });
+      });
+
+      test('commitCollapseToggle hidden for short commit message', () => {
         element._latestCommitMessage = '';
         assert.isTrue(element.$.commitCollapseToggle.hasAttribute('hidden'));
       });
 
-      test('commitCollapseToggle shown for long commit message', function() {
+      test('commitCollapseToggle shown for long commit message', () => {
         element._latestCommitMessage = _.times(31, String).join('\n');
         assert.isFalse(element.$.commitCollapseToggle.hasAttribute('hidden'));
       });
 
-      test('commitCollapseToggle functions', function() {
+      test('commitCollapseToggle functions', () => {
         element._latestCommitMessage = _.times(31, String).join('\n');
         assert.isTrue(element._commitCollapsed);
         assert.isTrue(
@@ -1006,51 +1157,37 @@
       });
     });
 
-    suite('related changes expand/collapse', function() {
-      var updateHeightSpy;
-      setup(function() {
+    suite('related changes expand/collapse', () => {
+      let updateHeightSpy;
+      setup(() => {
         updateHeightSpy = sandbox.spy(element, '_updateRelatedChangeMaxHeight');
       });
 
       test('relatedChangesToggle shown height greater than changeInfo height',
-          function() {
-        assert.isTrue(element.$.relatedChangesToggle.hasAttribute('hidden'));
-
-        sandbox.stub(element, '_getOffsetHeight', function() {
-          return 50;
-        });
-
-        sandbox.stub(element, '_getScrollHeight', function() {
-          return 60;
-        });
-        element._relatedChangesLoading = false;
-        assert.isFalse(element.$.relatedChangesToggle.hasAttribute('hidden'));
-        assert.equal(updateHeightSpy.callCount, 1);
-      });
+          () => {
+            assert.isTrue(element.$.relatedChangesToggle.hasAttribute('hidden'));
+            sandbox.stub(element, '_getOffsetHeight', () => 50);
+            sandbox.stub(element, '_getScrollHeight', () => 60);
+            sandbox.stub(window, 'matchMedia', () => ({matches: true}));
+            element._relatedChangesLoading = false;
+            assert.isFalse(element.$.relatedChangesToggle.hasAttribute('hidden'));
+            assert.equal(updateHeightSpy.callCount, 1);
+          });
 
       test('relatedChangesToggle hidden height less than changeInfo height',
-            function() {
-        assert.isTrue(element.$.relatedChangesToggle.hasAttribute('hidden'));
-        sandbox.stub(element, '_getOffsetHeight', function() {
-          return 50;
-        });
+          () => {
+            assert.isTrue(element.$.relatedChangesToggle.hasAttribute('hidden'));
+            sandbox.stub(element, '_getOffsetHeight', () => 50);
+            sandbox.stub(element, '_getScrollHeight', () => 40);
+            sandbox.stub(window, 'matchMedia', () => ({matches: true}));
+            element._relatedChangesLoading = false;
+            assert.isTrue(element.$.relatedChangesToggle.hasAttribute('hidden'));
+            assert.equal(updateHeightSpy.callCount, 1);
+          });
 
-        sandbox.stub(element, '_getScrollHeight', function() {
-          return 40;
-        });
-        element._relatedChangesLoading = false;
-        assert.isTrue(element.$.relatedChangesToggle.hasAttribute('hidden'));
-        assert.equal(updateHeightSpy.callCount, 1);
-      });
-
-      test('relatedChangesToggle functions', function() {
-        sandbox.stub(element, '_getOffsetHeight', function() {
-          return 50;
-        });
-
-        sandbox.stub(element, '_getScrollHeight', function() {
-          return 60;
-        });
+      test('relatedChangesToggle functions', () => {
+        sandbox.stub(element, '_getOffsetHeight', () => 50);
+        sandbox.stub(window, 'matchMedia', () => ({matches: false}));
         element._relatedChangesLoading = false;
         assert.isTrue(element._relatedChangesCollapsed);
         assert.isTrue(
@@ -1061,14 +1198,10 @@
             element.$.relatedChanges.classList.contains('collapsed'));
       });
 
-      test('_updateRelatedChangeMaxHeight without commit toggle', function() {
-        sandbox.stub(element, '_getOffsetHeight', function() {
-          return 50;
-        });
-
-        sandbox.stub(element, '_getLineHeight', function() {
-          return 12;
-        });
+      test('_updateRelatedChangeMaxHeight without commit toggle', () => {
+        sandbox.stub(element, '_getOffsetHeight', () => 50);
+        sandbox.stub(element, '_getLineHeight', () => 12);
+        sandbox.stub(window, 'matchMedia', () => ({matches: false}));
 
         // 50 (existing height) - 24 (extra height) = 26 (adjusted height).
         // 50 (existing height)  % 12 (line height) = 2 (remainder).
@@ -1081,15 +1214,11 @@
             undefined);
       });
 
-      test('_updateRelatedChangeMaxHeight with commit toggle', function() {
+      test('_updateRelatedChangeMaxHeight with commit toggle', () => {
         element._latestCommitMessage = _.times(31, String).join('\n');
-        sandbox.stub(element, '_getOffsetHeight', function() {
-          return 50;
-        });
-
-        sandbox.stub(element, '_getLineHeight', function() {
-          return 12;
-        });
+        sandbox.stub(element, '_getOffsetHeight', () => 50);
+        sandbox.stub(element, '_getLineHeight', () => 12);
+        sandbox.stub(window, 'matchMedia', () => ({matches: false}));
 
         // 50 (existing height) % 12 (line height) = 2 (remainder).
         // 50 (existing height)  - 2 (remainder) = 48 (max height to set).
@@ -1100,6 +1229,110 @@
         assert.equal(element.customStyle['--related-change-btn-top-padding'],
             '2px');
       });
+
+      test('_updateRelatedChangeMaxHeight in small screen mode', () => {
+        element._latestCommitMessage = _.times(31, String).join('\n');
+        sandbox.stub(element, '_getOffsetHeight', () => 50);
+        sandbox.stub(element, '_getLineHeight', () => 12);
+        sandbox.stub(window, 'matchMedia', () => ({matches: true}));
+
+        element._updateRelatedChangeMaxHeight();
+        assert.equal(element.customStyle['--relation-chain-max-height'],
+            '400px');
+      });
+
+      test('_updateRelatedChangeMaxHeight in medium screen mode', () => {
+        element._latestCommitMessage = _.times(31, String).join('\n');
+        sandbox.stub(element, '_getOffsetHeight', () => 50);
+        sandbox.stub(element, '_getLineHeight', () => 12);
+        sandbox.stub(window, 'matchMedia', () => {
+          if (window.matchMedia.lastCall.args[0] === '(max-width: 60em)') {
+            return {matches: true};
+          } else {
+            return {matches: false};
+          }
+        });
+
+        element._updateRelatedChangeMaxHeight();
+        assert.equal(element.customStyle['--relation-chain-max-height'],
+            '100px');
+      });
+
+
+      suite('update checks', () => {
+        setup(() => {
+          sandbox.spy(element, '_startUpdateCheckTimer');
+          sandbox.stub(element, 'async', f => {
+            // Only fire the async callback one time.
+            if (element.async.callCount > 1) { return; }
+            f.call(element);
+          });
+        });
+
+        test('_startUpdateCheckTimer negative delay', () => {
+          sandbox.stub(element, 'fetchIsLatestKnown');
+
+          element.serverConfig = {change: {update_delay: -1}};
+
+          assert.isTrue(element._startUpdateCheckTimer.called);
+          assert.isFalse(element.fetchIsLatestKnown.called);
+        });
+
+        test('_startUpdateCheckTimer up-to-date', () => {
+          sandbox.stub(element, 'fetchIsLatestKnown',
+              () => { return Promise.resolve(true); });
+
+          element.serverConfig = {change: {update_delay: 12345}};
+
+          assert.isTrue(element._startUpdateCheckTimer.called);
+          assert.isTrue(element.fetchIsLatestKnown.called);
+          assert.equal(element.async.lastCall.args[1], 12345 * 1000);
+        });
+
+        test('_startUpdateCheckTimer out-of-date shows an alert', done => {
+          sandbox.stub(element, 'fetchIsLatestKnown',
+              () => { return Promise.resolve(false); });
+          element.addEventListener('show-alert', () => {
+            done();
+          });
+          element.serverConfig = {change: {update_delay: 12345}};
+        });
+      });
+
+      test('canStartReview computation', () => {
+        const account1 = {_account_id: 1};
+        const account2 = {_account_id: 2};
+        const change = {
+          owner: {_account_id: 1},
+        };
+        assert.isFalse(element._computeCanStartReview(true, change, account1));
+        change.work_in_progress = false;
+        assert.isFalse(element._computeCanStartReview(true, change, account1));
+        change.work_in_progress = true;
+        assert.isTrue(element._computeCanStartReview(true, change, account1));
+        assert.isFalse(element._computeCanStartReview(false, change, account1));
+        assert.isFalse(element._computeCanStartReview(true, change, account2));
+      });
+
+      test('header class computation', () => {
+        assert.equal(element._computeHeaderClass({}), 'header');
+        assert.equal(element._computeHeaderClass({work_in_progress: true}),
+            'header wip');
+      });
+    });
+
+    test('_maybeScrollToMessage', () => {
+      const scrollStub = sandbox.stub(element.$.messageList, 'scrollToMessage');
+
+      element._maybeScrollToMessage('');
+      assert.isFalse(scrollStub.called);
+
+      element._maybeScrollToMessage('message');
+      assert.isFalse(scrollStub.called);
+
+      element._maybeScrollToMessage('#message-TEST');
+      assert.isTrue(scrollStub.called);
+      assert.equal(scrollStub.lastCall.args[0], 'TEST');
     });
   });
 </script>
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 a03f6cc..8ad2c19 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
@@ -19,6 +19,11 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
 
+<!--
+  The custom CSS property `--gr-formatted-text-prose-max-width` controls the max
+  width of formatted text blocks that are not code.
+-->
+
 <dom-module id="gr-comment-list">
   <template>
     <style>
@@ -43,7 +48,7 @@
       }
       .message {
         flex: 1;
-        max-width: 80ch;
+        --gr-formatted-text-prose-max-width: 80ch;
       }
     </style>
     <template is="dom-repeat" items="[[_computeFilesFromComments(comments)]]" as="file">
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
index 98a2508..ab2ad11 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
@@ -14,8 +14,8 @@
 (function() {
   'use strict';
 
-  var COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
-  var MERGE_LIST_PATH = '/MERGE_LIST';
+  const COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
+  const MERGE_LIST_PATH = '/MERGE_LIST';
 
   Polymer({
     is: 'gr-comment-list',
@@ -32,17 +32,16 @@
       projectConfig: Object,
     },
 
-    _computeFilesFromComments: function(comments) {
-      var arr = Object.keys(comments || {});
+    _computeFilesFromComments(comments) {
+      const arr = Object.keys(comments || {});
       return arr.sort(this.specialFilePathCompare);
     },
 
-    _computeFileDiffURL: function(file, changeNum, patchNum) {
-      return this.getBaseUrl() + '/c/' + changeNum +
-        '/' + patchNum + '/' + file;
+    _computeFileDiffURL(file, changeNum, patchNum) {
+      return `${this.getBaseUrl()}/c/${changeNum}/${patchNum}/${file}`;
     },
 
-    _computeFileDisplayName: function(path) {
+    _computeFileDisplayName(path) {
       if (path === COMMIT_MESSAGE_PATH) {
         return 'Commit message';
       } else if (path === MERGE_LIST_PATH) {
@@ -51,12 +50,12 @@
       return path;
     },
 
-    _isOnParent: function(comment) {
+    _isOnParent(comment) {
       return comment.side === 'PARENT';
     },
 
-    _computeDiffLineURL: function(file, changeNum, patchNum, comment) {
-      var diffURL = this._computeFileDiffURL(file, changeNum, patchNum);
+    _computeDiffLineURL(file, changeNum, patchNum, comment) {
+      let diffURL = this._computeFileDiffURL(file, changeNum, patchNum);
       if (comment.line) {
         diffURL += '#';
         if (this._isOnParent(comment)) { diffURL += 'b'; }
@@ -65,18 +64,18 @@
       return diffURL;
     },
 
-    _computeCommentsForFile: function(comments, file) {
+    _computeCommentsForFile(comments, file) {
       // Changes are not picked up by the dom-repeat due to the array instance
       // identity not changing even when it has elements added/removed from it.
       return (comments[file] || []).slice();
     },
 
-    _computePatchDisplayName: function(comment) {
+    _computePatchDisplayName(comment) {
       if (this._isOnParent(comment)) {
         return 'Base, ';
       }
       if (comment.patch_set != this.patchNum) {
-        return 'PS' + comment.patch_set + ', ';
+        return `PS${comment.patch_set}, `;
       }
       return '';
     },
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
index e27bad0..5275f48 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
@@ -31,41 +31,42 @@
 </test-fixture>
 
 <script>
-  suite('gr-comment-list tests', function() {
-    var element;
+  suite('gr-comment-list tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
     });
 
-    test('_computeFilesFromComments w/ special file path sorting', function() {
-      var comments = {
+    test('_computeFilesFromComments w/ special file path sorting', () => {
+      const comments = {
         'file_b.html': [],
         'file_c.css': [],
         'file_a.js': [],
         'test.cc': [],
         'test.h': [],
       };
-      var expected = [
+      const expected = [
         'file_a.js',
         'file_b.html',
         'file_c.css',
         'test.h',
-        'test.cc'
+        'test.cc',
       ];
-      var actual = element._computeFilesFromComments(comments);
+      const actual = element._computeFilesFromComments(comments);
       assert.deepEqual(actual, expected);
 
       assert.deepEqual(element._computeFilesFromComments(null), []);
     });
 
-    test('_computeFileDiffURL', function() {
-      var expected = '/c/<change>/<patch>/<file>';
-      var actual = element._computeFileDiffURL('<file>', '<change>', '<patch>');
+    test('_computeFileDiffURL', () => {
+      const expected = '/c/<change>/<patch>/<file>';
+      const actual =
+          element._computeFileDiffURL('<file>', '<change>', '<patch>');
       assert.equal(actual, expected);
     });
 
-    test('_computeFileDisplayName', function() {
+    test('_computeFileDisplayName', () => {
       assert.equal(element._computeFileDisplayName('/COMMIT_MSG'),
           'Commit message');
       assert.equal(element._computeFileDisplayName('/MERGE_LIST'),
@@ -74,10 +75,10 @@
           '/foo/bar/baz');
     });
 
-    test('_computeDiffLineURL', function() {
-      var comment = {line: 123, side: 'REVISION', patch_set: 10};
-      var expected = '/c/<change>/<patch>/<file>#123';
-      var actual = element._computeDiffLineURL('<file>', '<change>', '<patch>',
+    test('_computeDiffLineURL', () => {
+      const comment = {line: 123, side: 'REVISION', patch_set: 10};
+      let expected = '/c/<change>/<patch>/<file>#123';
+      let actual = element._computeDiffLineURL('<file>', '<change>', '<patch>',
           comment);
       assert.equal(actual, expected);
 
@@ -89,8 +90,8 @@
           comment);
     });
 
-    test('_computePatchDisplayName', function() {
-      var comment = {line: 123, side: 'REVISION', patch_set: 10};
+    test('_computePatchDisplayName', () => {
+      const comment = {line: 123, side: 'REVISION', patch_set: 10};
 
       element.patchNum = 10;
       assert.equal(element._computePatchDisplayName(comment), '');
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
index 5aa8601..c55e8c7 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
@@ -31,13 +31,13 @@
       },
     },
 
-    _isWebLink: function(link) {
+    _isWebLink(link) {
       // This is a whitelist of web link types that provide direct links to
       // the commit in the url property.
       return link.name === 'gitiles' || link.name === 'gitweb';
     },
 
-    _computeShowWebLink: function(change, commitInfo, serverConfig) {
+    _computeShowWebLink(change, commitInfo, serverConfig) {
       if (serverConfig.gitweb && serverConfig.gitweb.url &&
           serverConfig.gitweb.type && serverConfig.gitweb.type.revision) {
         return true;
@@ -47,8 +47,8 @@
         return false;
       }
 
-      for (var i = 0; i < commitInfo.web_links.length; i++) {
-        if (this._isWebLink(commitInfo.web_links[i])) {
+      for (const link of commitInfo.web_links) {
+        if (this._isWebLink(link)) {
           return true;
         }
       }
@@ -56,7 +56,7 @@
       return false;
     },
 
-    _computeWebLink: function(change, commitInfo, serverConfig) {
+    _computeWebLink(change, commitInfo, serverConfig) {
       if (!this._computeShowWebLink(change, commitInfo, serverConfig)) {
         return;
       }
@@ -69,10 +69,10 @@
                 .replace('${commit}', commitInfo.commit);
       }
 
-      var webLink = null;
-      for (var i = 0; i < commitInfo.web_links.length; i++) {
-        if (this._isWebLink(commitInfo.web_links[i])) {
-          webLink = commitInfo.web_links[i].url;
+      let webLink = null;
+      for (const link of commitInfo.web_links) {
+        if (this._isWebLink(link)) {
+          webLink = link.url;
           break;
         }
       }
@@ -88,7 +88,7 @@
       return webLink;
     },
 
-    _computeShortHash: function(commitInfo) {
+    _computeShortHash(commitInfo) {
       if (!commitInfo || !commitInfo.commit) {
         return;
       }
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
index c8faff5..fc45f2a 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
@@ -33,14 +33,14 @@
 </test-fixture>
 
 <script>
-  suite('gr-commit-info tests', function() {
-    var element;
+  suite('gr-commit-info tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
     });
 
-    test('no web link when unavailable', function() {
+    test('no web link when unavailable', () => {
       element.commitInfo = {};
       element.serverConfig = {};
       element.change = {labels: []};
@@ -49,7 +49,7 @@
           element.commitInfo, element.serverConfig));
     });
 
-    test('use web link when available', function() {
+    test('use web link when available', () => {
       element.commitInfo = {web_links: [{name: 'gitweb', url: 'link-url'}]};
       element.serverConfig = {};
 
@@ -59,9 +59,9 @@
           element.serverConfig), '../../link-url');
     });
 
-    test('does not relativize web links that begin with scheme', function() {
+    test('does not relativize web links that begin with scheme', () => {
       element.commitInfo = {
-        web_links: [{name: 'gitweb', url: 'https://link-url'}]
+        web_links: [{name: 'gitweb', url: 'https://link-url'}],
       };
       element.serverConfig = {};
 
@@ -71,7 +71,7 @@
           element.serverConfig), 'https://link-url');
     });
 
-    test('use gitweb when available', function() {
+    test('use gitweb when available', () => {
       element.commitInfo = {commit: 'commit-sha'};
       element.serverConfig = {gitweb: {
         url: 'url-base/',
@@ -80,7 +80,7 @@
       element.change = {
         project: 'project-name',
         labels: [],
-        current_revision: element.commitInfo.commit
+        current_revision: element.commitInfo.commit,
       };
 
       assert.isOk(element._computeShowWebLink(element.change,
@@ -90,10 +90,10 @@
           element.serverConfig), 'url-base/xx project-name xx commit-sha xx');
     });
 
-    test('prefer gitweb when both are available', function() {
+    test('prefer gitweb when both are available', () => {
       element.commitInfo = {
         commit: 'commit-sha',
-        web_links: [{url: 'link-url'}]
+        web_links: [{url: 'link-url'}],
       };
       element.serverConfig = {gitweb: {
         url: 'url-base/',
@@ -102,20 +102,20 @@
       element.change = {
         project: 'project-name',
         labels: [],
-        current_revision: element.commitInfo.commit
+        current_revision: element.commitInfo.commit,
       };
 
       assert.isOk(element._computeShowWebLink(element.change,
           element.commitInfo, element.serverConfig));
 
-      var link = element._computeWebLink(element.change, element.commitInfo,
+      const link = element._computeWebLink(element.change, element.commitInfo,
           element.serverConfig);
 
       assert.equal(link, 'url-base/xx project-name xx commit-sha xx');
       assert.notEqual(link, '../../link-url');
     });
 
-    test('ignore web links that are neither gitweb nor gitiles', function() {
+    test('ignore web links that are neither gitweb nor gitiles', () => {
       element.commitInfo = {
         commit: 'commit-sha',
         web_links: [
@@ -126,7 +126,7 @@
           {
             name: 'gitiles',
             url: 'https://link-url',
-          }
+          },
         ],
       };
       element.serverConfig = {};
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
index e47f14f..074e39e 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
@@ -33,16 +33,16 @@
       message: String,
     },
 
-    resetFocus: function() {
+    resetFocus() {
       this.$.messageInput.textarea.focus();
     },
 
-    _handleConfirmTap: function(e) {
+    _handleConfirmTap(e) {
       e.preventDefault();
       this.fire('confirm', {reason: this.message}, {bubbles: false});
     },
 
-    _handleCancelTap: function(e) {
+    _handleCancelTap(e) {
       e.preventDefault();
       this.fire('cancel', null, {bubbles: false});
     },
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
index 716e29c..48a3f76 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
@@ -41,8 +41,8 @@
       '_computeMessage(changeStatus, commitNum, commitMessage)',
     ],
 
-    _computeMessage: function(changeStatus, commitNum, commitMessage) {
-      var newMessage = commitMessage;
+    _computeMessage(changeStatus, commitNum, commitMessage) {
+      let newMessage = commitMessage;
 
       if (changeStatus === 'MERGED') {
         newMessage += '(cherry picked from commit ' + commitNum + ')';
@@ -50,12 +50,12 @@
       this.message = newMessage;
     },
 
-    _handleConfirmTap: function(e) {
+    _handleConfirmTap(e) {
       e.preventDefault();
       this.fire('confirm', null, {bubbles: false});
     },
 
-    _handleCancelTap: function(e) {
+    _handleCancelTap(e) {
       e.preventDefault();
       this.fire('cancel', null, {bubbles: false});
     },
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
index 3c1cf2b..2d6defd 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
@@ -33,42 +33,41 @@
 </test-fixture>
 
 <script>
-  suite('gr-confirm-cherrypick-dialog tests', function() {
-    var element;
+  suite('gr-confirm-cherrypick-dialog tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
     });
 
-    test('with merged change', function() {
+    test('with merged change', () => {
       element.changeStatus = 'MERGED';
       element.commitMessage = 'message\n';
       element.commitNum = '123';
       element.branch = 'master';
       flushAsynchronousOperations();
-      var expectedMessage = 'message\n(cherry picked from commit 123)';
+      const expectedMessage = 'message\n(cherry picked from commit 123)';
       assert.equal(element.message, expectedMessage);
     });
 
-    test('with unmerged change', function() {
+    test('with unmerged change', () => {
       element.changeStatus = 'OPEN';
       element.commitMessage = 'message\n';
       element.commitNum = '123';
       element.branch = 'master';
       flushAsynchronousOperations();
-      var expectedMessage = 'message\n';
+      const expectedMessage = 'message\n';
       assert.equal(element.message, expectedMessage);
     });
 
-    test('with updated commit message', function() {
+    test('with updated commit message', () => {
       element.changeStatus = 'OPEN';
       element.commitMessage = 'message\n';
       element.commitNum = '123';
       element.branch = 'master';
-      var myNewMessage = 'updated commit message';
+      const myNewMessage = 'updated commit message';
       element.message = myNewMessage;
       flushAsynchronousOperations();
-      var expectedMessage = 'message\n';
       assert.equal(element.message, myNewMessage);
     });
   });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
index b27e6ba..ba42ab0 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
@@ -75,7 +75,8 @@
               disabled$="[[!_displayTipOption(rebaseOnCurrent, hasParent)]]"
               on-tap="_handleRebaseOnTip">
           <label id="rebaseOnTipLabel" for="rebaseOnTipInput">
-            Rebase on top of the [[branch]] branch<span hidden="[[!hasParent]]">
+            Rebase on top of the [[branch]]
+            branch<span hidden$="[[!hasParent]]">
               (breaks relation chain)
             </span>
           </label>
@@ -90,7 +91,7 @@
               type="radio"
               on-tap="_handleRebaseOnOther">
           <label id="rebaseOnOtherLabel" for="rebaseOnOtherInput">
-            Rebase on a specific change or ref <span hidden="[[!hasParent]]">
+            Rebase on a specific change or ref <span hidden$="[[!hasParent]]">
               (breaks relation chain)
             </span>
           </label>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
index 4ecb31f..6cb7a9b 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
@@ -40,29 +40,29 @@
       '_updateSelectedOption(rebaseOnCurrent, hasParent)',
     ],
 
-    _displayParentOption: function(rebaseOnCurrent, hasParent) {
+    _displayParentOption(rebaseOnCurrent, hasParent) {
       return hasParent && rebaseOnCurrent;
     },
 
-    _displayParentUpToDateMsg: function(rebaseOnCurrent, hasParent) {
+    _displayParentUpToDateMsg(rebaseOnCurrent, hasParent) {
       return hasParent && !rebaseOnCurrent;
     },
 
-    _displayTipOption: function(rebaseOnCurrent, hasParent) {
+    _displayTipOption(rebaseOnCurrent, hasParent) {
       return !(!rebaseOnCurrent && !hasParent);
     },
 
-    _handleConfirmTap: function(e) {
+    _handleConfirmTap(e) {
       e.preventDefault();
       this.fire('confirm', null, {bubbles: false});
     },
 
-    _handleCancelTap: function(e) {
+    _handleCancelTap(e) {
       e.preventDefault();
       this.fire('cancel', null, {bubbles: false});
     },
 
-    _handleRebaseOnOther: function(e) {
+    _handleRebaseOnOther(e) {
       this.$.parentInput.focus();
     },
 
@@ -73,15 +73,15 @@
      * rebased on top of the target branch. Leaving out the base implies that it
      * should be rebased on top of its current parent.
      */
-    _handleRebaseOnTip: function(e) {
+    _handleRebaseOnTip(e) {
       this.base = '';
     },
 
-    _handleRebaseOnParent: function(e) {
+    _handleRebaseOnParent(e) {
       this.base = null;
     },
 
-    _handleEnterChangeNumberTap: function(e) {
+    _handleEnterChangeNumberTap(e) {
       this.$.rebaseOnOtherInput.checked = true;
     },
 
@@ -89,7 +89,7 @@
      * Sets the default radio button based on the state of the app and
      * the corresponding value to be submitted.
      */
-    _updateSelectedOption: function(rebaseOnCurrent, hasParent) {
+    _updateSelectedOption(rebaseOnCurrent, hasParent) {
       if (this._displayParentOption(rebaseOnCurrent, hasParent)) {
         this.$.rebaseOnParentInput.checked = true;
         this._handleRebaseOnParent();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
index 37eb812..bb9651b 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
@@ -33,14 +33,14 @@
 </test-fixture>
 
 <script>
-  suite('gr-confirm-rebase-dialog tests', function() {
-    var element;
+  suite('gr-confirm-rebase-dialog tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
     });
 
-    test('controls with parent and rebase on current available', function() {
+    test('controls with parent and rebase on current available', () => {
       element.rebaseOnCurrent = true;
       element.hasParent = true;
       flushAsynchronousOperations();
@@ -51,7 +51,7 @@
       assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
     });
 
-    test('controls with parent rebase on current not available', function() {
+    test('controls with parent rebase on current not available', () => {
       element.rebaseOnCurrent = false;
       element.hasParent = true;
       flushAsynchronousOperations();
@@ -62,7 +62,7 @@
       assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
     });
 
-    test('controls without parent and rebase on current available', function() {
+    test('controls without parent and rebase on current available', () => {
       element.rebaseOnCurrent = true;
       element.hasParent = false;
       flushAsynchronousOperations();
@@ -73,7 +73,7 @@
       assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
     });
 
-    test('controls without parent rebase on current not available', function() {
+    test('controls without parent rebase on current not available', () => {
       element.rebaseOnCurrent = false;
       element.hasParent = false;
       flushAsynchronousOperations();
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 8f621f0..4a1bbb8 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,27 +33,26 @@
       message: String,
     },
 
-    populateRevertMessage: function(message, commitHash) {
+    populateRevertMessage(message, commitHash) {
       // Figure out what the revert title should be.
-      var originalTitle = message.split('\n')[0];
-      var revertTitle = 'Revert "' + originalTitle + '"';
+      const originalTitle = message.split('\n')[0];
+      const revertTitle = `Revert "${originalTitle}"`;
       if (!commitHash) {
         alert('Unable to find the commit hash of this change.');
         return;
       }
-      var revertCommitText = 'This reverts commit ' + commitHash + '.';
+      const revertCommitText = `This reverts commit ${commitHash}.`;
 
-      this.message = revertTitle + '\n\n' +
-                     revertCommitText + '\n\n' +
-                     'Reason for revert: <INSERT REASONING HERE>\n';
+      this.message = `${revertTitle}\n\n${revertCommitText}\n\n` +
+          `Reason for revert: <INSERT REASONING HERE>\n`;
     },
 
-    _handleConfirmTap: function(e) {
+    _handleConfirmTap(e) {
       e.preventDefault();
       this.fire('confirm', null, {bubbles: false});
     },
 
-    _handleCancelTap: function(e) {
+    _handleCancelTap(e) {
       e.preventDefault();
       this.fire('cancel', null, {bubbles: false});
     },
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 d5c459b..78b0dfc 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
@@ -33,62 +33,62 @@
 </test-fixture>
 
 <script>
-  suite('gr-confirm-revert-dialog tests', function() {
-    var element;
+  suite('gr-confirm-revert-dialog tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
     });
 
-    test('no match', function() {
+    test('no match', () => {
       assert.isNotOk(element.message);
-      var alertStub = sinon.stub(window, 'alert');
+      const alertStub = sinon.stub(window, 'alert');
       element.populateRevertMessage('not a commitHash in sight', undefined);
       assert.isTrue(alertStub.calledOnce);
       alertStub.restore();
     });
 
-    test('single line', function() {
+    test('single line', () => {
       assert.isNotOk(element.message);
       element.populateRevertMessage(
           'one line commit\n\nChange-Id: abcdefg\n',
           'abcd123');
-      var expected = 'Revert "one line commit"\n\n' +
-                     'This reverts commit abcd123.\n\n' +
-                     'Reason for revert: <INSERT REASONING HERE>\n';
+      const expected = 'Revert "one line commit"\n\n' +
+          'This reverts commit abcd123.\n\n' +
+          'Reason for revert: <INSERT REASONING HERE>\n';
       assert.equal(element.message, expected);
     });
 
-    test('multi line', function() {
+    test('multi line', () => {
       assert.isNotOk(element.message);
       element.populateRevertMessage(
           'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
           'abcd123');
-      var expected = 'Revert "many lines"\n\n' +
-                     'This reverts commit abcd123.\n\n' +
-                     'Reason for revert: <INSERT REASONING HERE>\n';
+      const expected = 'Revert "many lines"\n\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() {
+    test('issue above change id', () => {
       assert.isNotOk(element.message);
       element.populateRevertMessage(
           'much lines\nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
           'abcd123');
-      var expected = 'Revert "much lines"\n\n' +
-                     'This reverts commit abcd123.\n\n' +
-                     'Reason for revert: <INSERT REASONING HERE>\n';
+      const expected = 'Revert "much lines"\n\n' +
+          'This reverts commit abcd123.\n\n' +
+          'Reason for revert: <INSERT REASONING HERE>\n';
       assert.equal(element.message, expected);
     });
 
-    test('revert a revert', function() {
+    test('revert a revert', () => {
       assert.isNotOk(element.message);
       element.populateRevertMessage(
           'Revert "one line commit"\n\nChange-Id: abcdefg\n',
           'abcd123');
-      var expected = 'Revert "Revert "one line commit""\n\n' +
-                     'This reverts commit abcd123.\n\n' +
-                     'Reason for revert: <INSERT REASONING HERE>\n';
+      const expected = 'Revert "Revert "one line commit""\n\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-download-dialog/gr-download-dialog.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
index 0e97d36..d95c1f8 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
@@ -84,7 +84,7 @@
       }
       .closeButtonContainer {
         display: flex;
-        flex: 1;
+        flex: 0;
         justify-content: flex-end;
       }
       .patchFiles {
@@ -99,6 +99,10 @@
       .archives a:last-of-type {
         margin-right: 0;
       }
+      .title {
+        text-align: center;
+        flex: 1;
+      }
     </style>
     <header>
       <ul hidden$="[[!_schemes.length]]" hidden>
@@ -110,6 +114,9 @@
           </li>
         </template>
       </ul>
+      <span class="title">
+        Patch set [[patchNum]] of [[_computePatchSetQuantity(change.revisions)]]
+      </span>
       <span class="closeButtonContainer">
         <gr-button id="closeButton"
             link
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
index 41f6792..a89480a 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
@@ -35,7 +35,7 @@
 
       _schemes: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
         computed: '_computeSchemes(change, patchNum)',
         observer: '_schemesChanged',
       },
@@ -50,7 +50,7 @@
       Gerrit.RESTClientBehavior,
     ],
 
-    focus: function() {
+    focus() {
       if (this._schemes.length) {
         this.$$('.copyToClipboard').focus();
       } else {
@@ -58,60 +58,61 @@
       }
     },
 
-    getFocusStops: function() {
-      var links = this.$$('#archives').querySelectorAll('a');
+    getFocusStops() {
+      const links = this.$$('#archives').querySelectorAll('a');
       return {
         start: this.$.closeButton,
         end: links[links.length - 1],
       };
     },
 
-    _loggedInChanged: function(loggedIn) {
+    _loggedInChanged(loggedIn) {
       if (!loggedIn) { return; }
-      this.$.restAPI.getPreferences().then(function(prefs) {
+      this.$.restAPI.getPreferences().then(prefs => {
         if (prefs.download_scheme) {
           // Note (issue 5180): normalize the download scheme with lower-case.
           this._selectedScheme = prefs.download_scheme.toLowerCase();
         }
-      }.bind(this));
+      });
     },
 
-    _computeDownloadCommands: function(change, patchNum, _selectedScheme) {
-      var commandObj;
-      for (var rev in change.revisions) {
-        if (change.revisions[rev]._number == patchNum &&
+    _computeDownloadCommands(change, patchNum, _selectedScheme) {
+      let commandObj;
+      for (const rev in change.revisions) {
+        if (change.revisions[rev]._number === parseInt(patchNum, 10) &&
             change.revisions[rev].fetch.hasOwnProperty(_selectedScheme)) {
           commandObj = change.revisions[rev].fetch[_selectedScheme].commands;
           break;
         }
       }
-      var commands = [];
-      for (var title in commandObj) {
+      const commands = [];
+      for (const title in commandObj) {
+        if (!commandObj.hasOwnProperty(title)) { continue; }
         commands.push({
-          title: title,
+          title,
           command: commandObj[title],
         });
       }
       return commands;
     },
 
-    _computeZipDownloadLink: function(change, patchNum) {
+    _computeZipDownloadLink(change, patchNum) {
       return this._computeDownloadLink(change, patchNum, true);
     },
 
-    _computeZipDownloadFilename: function(change, patchNum) {
+    _computeZipDownloadFilename(change, patchNum) {
       return this._computeDownloadFilename(change, patchNum, true);
     },
 
-    _computeDownloadLink: function(change, patchNum, zip) {
+    _computeDownloadLink(change, patchNum, zip) {
       return this.changeBaseURL(change._number, patchNum) + '/patch?' +
           (zip ? 'zip' : 'download');
     },
 
-    _computeDownloadFilename: function(change, patchNum, zip) {
-      var shortRev;
-      for (var rev in change.revisions) {
-        if (change.revisions[rev]._number == patchNum) {
+    _computeDownloadFilename(change, patchNum, zip) {
+      let shortRev;
+      for (const rev in change.revisions) {
+        if (change.revisions[rev]._number === parseInt(patchNum, 10)) {
           shortRev = rev.substr(0, 7);
           break;
         }
@@ -119,15 +120,15 @@
       return shortRev + '.diff.' + (zip ? 'zip' : 'base64');
     },
 
-    _computeArchiveDownloadLink: function(change, patchNum, format) {
+    _computeArchiveDownloadLink(change, patchNum, format) {
       return this.changeBaseURL(change._number, patchNum) +
           '/archive?format=' + format;
     },
 
-    _computeSchemes: function(change, patchNum) {
-      for (var rev in change.revisions) {
-        if (change.revisions[rev]._number == patchNum) {
-          var fetch = change.revisions[rev].fetch;
+    _computeSchemes(change, patchNum) {
+      for (const rev of Object.values(change.revisions || {})) {
+        if (rev._number === parseInt(patchNum, 10)) {
+          const fetch = rev.fetch;
           if (fetch) {
             return Object.keys(fetch).sort();
           }
@@ -137,42 +138,47 @@
       return [];
     },
 
-    _computeSchemeSelected: function(scheme, selectedScheme) {
-      return scheme == selectedScheme;
+    _computePatchSetQuantity(revisions) {
+      if (!revisions) { return 0; }
+      return Object.keys(revisions).length;
     },
 
-    _handleSchemeTap: function(e) {
+    _computeSchemeSelected(scheme, selectedScheme) {
+      return scheme === selectedScheme;
+    },
+
+    _handleSchemeTap(e) {
       e.preventDefault();
-      var el = Polymer.dom(e).rootTarget;
+      const el = Polymer.dom(e).rootTarget;
       this._selectedScheme = el.getAttribute('data-scheme');
       if (this.loggedIn) {
         this.$.restAPI.savePreferences({download_scheme: this._selectedScheme});
       }
     },
 
-    _handleInputTap: function(e) {
+    _handleInputTap(e) {
       e.preventDefault();
       Polymer.dom(e).rootTarget.select();
     },
 
-    _handleCloseTap: function(e) {
+    _handleCloseTap(e) {
       e.preventDefault();
       this.fire('close', null, {bubbles: false});
     },
 
-    _schemesChanged: function(schemes) {
-      if (schemes.length == 0) { return; }
-      if (schemes.indexOf(this._selectedScheme) == -1) {
+    _schemesChanged(schemes) {
+      if (schemes.length === 0) { return; }
+      if (!schemes.includes(this._selectedScheme)) {
         this._selectedScheme = schemes.sort()[0];
       }
     },
 
-    _copyToClipboard: function(e) {
+    _copyToClipboard(e) {
       e.target.parentElement.querySelector('.copyCommand').select();
       document.execCommand('copy');
       getSelection().removeAllRanges();
       e.target.textContent = 'done';
-      setTimeout(function() { e.target.textContent = 'copy'; }, 1000);
+      setTimeout(() => { e.target.textContent = 'copy'; }, 1000);
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
index 0635d6d..63d45c6 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
@@ -48,8 +48,8 @@
           fetch: {
             repo: {
               commands: {
-                repo: 'repo download test-project 5/1'
-              }
+                repo: 'repo download test-project 5/1',
+              },
             },
             ssh: {
               commands: {
@@ -69,8 +69,8 @@
                 'Pull':
                   'git pull ' +
                   'ssh://andybons@localhost:29418/test-project ' +
-                  'refs/changes/05/5/1'
-              }
+                  'refs/changes/05/5/1',
+              },
             },
             http: {
               commands: {
@@ -90,12 +90,12 @@
                 'Pull':
                   'git pull ' +
                   'http://andybons@localhost:8080/a/test-project ' +
-                  'refs/changes/05/5/1'
-              }
-            }
-          }
-        }
-      }
+                  'refs/changes/05/5/1',
+              },
+            },
+          },
+        },
+      },
     };
   }
 
@@ -106,74 +106,74 @@
         '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
           _number: 1,
           fetch: {},
-        }
-      }
+        },
+      },
     };
   }
 
-  suite('gr-download-dialog tests with no fetch options', function() {
-    var element;
+  suite('gr-download-dialog tests with no fetch options', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       element.change = getChangeObjectNoFetch();
-      element.patchNum = 1;
+      element.patchNum = '1';
       element.config = {
         schemes: {
           'anonymous http': {},
-          http: {},
-          repo: {},
-          ssh: {},
+          'http': {},
+          'repo': {},
+          'ssh': {},
         },
         archives: ['tgz', 'tar', 'tbz2', 'txz'],
       };
     });
 
-    test('focuses on first download link if no copy links', function() {
+    test('focuses on first download link if no copy links', () => {
       flushAsynchronousOperations();
-      var focusStub = sinon.stub(element.$.download, 'focus');
+      const focusStub = sinon.stub(element.$.download, 'focus');
       element.focus();
       assert.isTrue(focusStub.called);
       focusStub.restore();
     });
   });
 
-  suite('gr-download-dialog tests', function() {
-    var element;
+  suite('gr-download-dialog tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       element.change = getChangeObject();
-      element.patchNum = 1;
+      element.patchNum = '1';
       element.config = {
         schemes: {
           'anonymous http': {},
-          http: {},
-          repo: {},
-          ssh: {},
+          'http': {},
+          'repo': {},
+          'ssh': {},
         },
         archives: ['tgz', 'tar', 'tbz2', 'txz'],
       };
     });
 
-    test('focuses on first copy link', function() {
+    test('focuses on first copy link', () => {
       flushAsynchronousOperations();
-      var focusStub = sinon.stub(element.$$('.copyToClipboard'), 'focus');
+      const focusStub = sinon.stub(element.$$('.copyToClipboard'), 'focus');
       element.focus();
       flushAsynchronousOperations();
       assert.isTrue(focusStub.called);
       focusStub.restore();
     });
 
-    test('copy to clipboard', function() {
+    test('copy to clipboard', () => {
       flushAsynchronousOperations();
-      var clipboardSpy = sinon.spy(element, '_copyToClipboard');
-      var copyBtn = element.$$('.copyToClipboard');
+      const clipboardSpy = sinon.spy(element, '_copyToClipboard');
+      const copyBtn = element.$$('.copyToClipboard');
       MockInteractions.tap(copyBtn);
       assert.isTrue(clipboardSpy.called);
     });
 
-    test('element visibility', function() {
+    test('element visibility', () => {
       assert.isFalse(element.$$('ul').hasAttribute('hidden'));
       assert.isFalse(element.$$('main').hasAttribute('hidden'));
       assert.isFalse(element.$$('.archivesContainer').hasAttribute('hidden'));
@@ -182,40 +182,40 @@
       assert.isTrue(element.$$('.archivesContainer').hasAttribute('hidden'));
     });
 
-    test('computed fields', function() {
+    test('computed fields', () => {
       assert.equal(element._computeArchiveDownloadLink(
           {_number: 123}, 2, 'tgz'),
           '/changes/123/revisions/2/archive?format=tgz');
     });
 
-    test('close event', function(done) {
-      element.addEventListener('close', function() {
+    test('close event', done => {
+      element.addEventListener('close', () => {
         done();
       });
       MockInteractions.tap(element.$$('.closeButtonContainer gr-button'));
     });
 
-    test('tab selection', function() {
+    test('tab selection', () => {
       flushAsynchronousOperations();
-      var el = element.$$('[data-scheme="http"]').parentElement;
+      let el = element.$$('[data-scheme="http"]').parentElement;
       assert.isTrue(el.hasAttribute('selected'));
-      ['repo', 'ssh'].forEach(function(scheme) {
-        var el = element.$$('[data-scheme="' + scheme + '"]').parentElement;
+      for (const scheme of ['repo', 'ssh']) {
+        const el = element.$$('[data-scheme="' + scheme + '"]').parentElement;
         assert.isFalse(el.hasAttribute('selected'));
-      });
+      }
 
       MockInteractions.tap(element.$$('[data-scheme="ssh"]'));
       el = element.$$('[data-scheme="ssh"]').parentElement;
       assert.isTrue(el.hasAttribute('selected'));
-      ['http', 'repo'].forEach(function(scheme) {
-        var el = element.$$('[data-scheme="' + scheme + '"]').parentElement;
+      for (const scheme of ['http', 'repo']) {
+        const el = element.$$('[data-scheme="' + scheme + '"]').parentElement;
         assert.isFalse(el.hasAttribute('selected'));
-      });
+      }
     });
 
-    test('loads scheme from preferences w/o initial login', function(done) {
+    test('loads scheme from preferences w/o initial login', done => {
       stub('gr-rest-api-interface', {
-        getPreferences: function() {
+        getPreferences() {
           return Promise.resolve({download_scheme: 'repo'});
         },
       });
@@ -223,51 +223,51 @@
       element.loggedIn = true;
 
       assert.isTrue(element.$.restAPI.getPreferences.called);
-      element.$.restAPI.getPreferences.lastCall.returnValue.then(function() {
+      element.$.restAPI.getPreferences.lastCall.returnValue.then(() => {
         assert.equal(element._selectedScheme, 'repo');
         done();
       });
     });
   });
 
-  suite('gr-download-dialog tests', function() {
-    var element;
+  suite('gr-download-dialog tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getPreferences: function() {
+        getPreferences() {
           return Promise.resolve({download_scheme: 'repo'});
         },
       });
 
       element = fixture('loggedIn');
       element.change = getChangeObject();
-      element.patchNum = 1;
+      element.patchNum = '1';
       element.config = {
         schemes: {
           'anonymous http': {},
-          http: {},
-          repo: {},
-          ssh: {},
+          'http': {},
+          'repo': {},
+          'ssh': {},
         },
         archives: ['tgz', 'tar', 'tbz2', 'txz'],
       };
     });
 
-    test('loads scheme from preferences', function(done) {
-      element.$.restAPI.getPreferences.lastCall.returnValue.then(function() {
+    test('loads scheme from preferences', done => {
+      element.$.restAPI.getPreferences.lastCall.returnValue.then(() => {
         assert.equal(element._selectedScheme, 'repo');
         done();
       });
     });
 
-    test('saves scheme to preferences', function() {
-      var savePrefsStub = sinon.stub(element.$.restAPI, 'savePreferences',
-          function() { return Promise.resolve(); });
+    test('saves scheme to preferences', () => {
+      const savePrefsStub = sinon.stub(element.$.restAPI, 'savePreferences',
+          () => { return Promise.resolve(); });
 
       Polymer.dom.flush();
 
-      var firstSchemeButton = element.$$('li gr-button[data-scheme]');
+      const firstSchemeButton = element.$$('li gr-button[data-scheme]');
 
       MockInteractions.tap(firstSchemeButton);
 
@@ -277,20 +277,20 @@
     });
   });
 
-  test('normalize scheme from preferences', function(done) {
+  test('normalize scheme from preferences', done => {
     stub('gr-rest-api-interface', {
-      getPreferences: function() {
+      getPreferences() {
         return Promise.resolve({download_scheme: 'REPO'});
       },
     });
     element = fixture('loggedIn');
     element.change = getChangeObject();
-    element.patchNum = 1;
+    element.patchNum = '1';
     element.config = {
-      schemes: {'anonymous http': {}, http: {}, repo: {}, ssh: {}},
+      schemes: {'anonymous http': {}, 'http': {}, 'repo': {}, 'ssh': {}},
       archives: ['tgz', 'tar', 'tbz2', 'txz'],
     };
-    element.$.restAPI.getPreferences.lastCall.returnValue.then(function() {
+    element.$.restAPI.getPreferences.lastCall.returnValue.then(() => {
       assert.equal(element._selectedScheme, 'repo');
       done();
     });
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 e324078..80bf06c 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
@@ -27,6 +27,7 @@
 <link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-select/gr-select.html">
+<link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
 
 <dom-module id="gr-file-list">
   <template>
@@ -35,6 +36,7 @@
         display: block;
       }
       .row {
+        border-top: 1px solid #eee;
         display: flex;
         padding: .1em .25em;
       }
@@ -71,6 +73,7 @@
         background-color: #ebf5fb;
       }
       .path {
+        cursor: pointer;
         flex: 1;
         padding-left: .35em;
         text-decoration: none;
@@ -221,10 +224,11 @@
             <option value="PARENT">Base</option>
             <template
                 is="dom-repeat"
-                items="[[_computePatchSets(revisions.*, patchRange.*)]]"
+                items="[[computeAllPatchSets(change)]]"
                 as="patchNum">
-              <option value$="[[patchNum.num]]"
-                  disabled$="[[_computePatchSetDisabled(patchNum.num, patchRange.patchNum)]]">
+              <option
+                  disabled$="[[_computePatchSetDisabled(patchNum.num, patchRange.patchNum)]]"
+                  value$="[[patchNum.num]]">
                 [[patchNum.num]]
                 [[patchNum.desc]]
               </option>
@@ -233,97 +237,108 @@
         </label>
       </div>
     </header>
-    <template is="dom-repeat"
-        items="[[_shownFiles]]"
-        as="file"
-        initial-count="[[_fileListIncrement]]">
-      <div class="file-row row">
-        <div class="reviewed" hidden$="[[!_loggedIn]]" hidden>
-          <input type="checkbox" checked="[[file.isReviewed]]"
-              data-path$="[[file.__path]]" on-change="_handleReviewedChange"
-              class="reviewed" aria-label="Reviewed checkbox">
-        </div>
-        <div class$="[[_computeClass('status', file.__path)]]"
-            tabindex="0"
-            aria-label$="[[_computeFileStatusLabel(file.status)]]">
-          [[_computeFileStatus(file.status)]]
-        </div>
-        <a class$="[[_computePathClass(file.__path, _expandedFilePaths.*)]]"
-            href$="[[_computeDiffURL(changeNum, patchRange, file.__path)]]"
-            on-tap="_handleFileTap">
-          <div title$="[[_computeFileDisplayName(file.__path)]]"
-              class="fullFileName">
-            [[_computeFileDisplayName(file.__path)]]
+    <div on-tap="_handleFileListTap">
+      <template is="dom-repeat"
+          items="[[_shownFiles]]"
+          id="files"
+          as="file"
+          initial-count="[[fileListIncrement]]"
+          target-framerate="1">
+        <div class="file-row row" data-path$="[[file.__path]]">
+          <div class="reviewed" hidden$="[[!_loggedIn]]" hidden>
+            <input type="checkbox" checked="[[file.isReviewed]]"
+                class="reviewed" aria-label="Reviewed checkbox">
           </div>
-          <div title$="[[_computeFileDisplayName(file.__path)]]"
-              class="truncatedFileName">
-            [[_computeTruncatedFileDisplayName(file.__path)]]
+          <div class$="[[_computeClass('status', file.__path)]]"
+              tabindex="0"
+              aria-label$="[[_computeFileStatusLabel(file.status)]]">
+            [[_computeFileStatus(file.status)]]
           </div>
-          <div class="oldPath" hidden$="[[!file.old_path]]" hidden
-              title$="[[file.old_path]]">
-            [[file.old_path]]
-          </div>
-        </a>
-        <div class="comments desktop">
-          <span class="drafts">
-            [[_computeDraftsString(drafts, patchRange.patchNum, file.__path)]]
+          <span
+              data-url="[[_computeDiffURL(changeNum, patchRange, file.__path)]]"
+              class$="[[_computePathClass(file.__path, _expandedFilePaths.*)]]">
+            <a href$="[[_computeDiffURL(changeNum, patchRange, file.__path)]]">
+              <span title$="[[_computeFileDisplayName(file.__path)]]"
+                  class="fullFileName">
+                [[_computeFileDisplayName(file.__path)]]
+              </span>
+              <span title$="[[_computeFileDisplayName(file.__path)]]"
+                  class="truncatedFileName">
+                [[_computeTruncatedFileDisplayName(file.__path)]]
+              </span>
+            </a>
+            <div class="oldPath" hidden$="[[!file.old_path]]" hidden
+                title$="[[file.old_path]]">
+              [[file.old_path]]
+            </div>
           </span>
-          [[_computeCommentsString(comments, patchRange.patchNum, file.__path)]]
-          [[_computeUnresolvedString(comments, drafts, patchRange.patchNum, file.__path)]]
-        </div>
-        <div class="comments mobile">
-          <span class="drafts">
-            [[_computeDraftsStringMobile(drafts, patchRange.patchNum,
+          <div class="comments desktop">
+            <span class="drafts">
+              [[_computeDraftsString(drafts, patchRange.patchNum, file.__path)]]
+            </span>
+            [[_computeCommentsString(comments, patchRange.patchNum, file.__path)]]
+            [[_computeUnresolvedString(comments, drafts, patchRange.patchNum, file.__path)]]
+          </div>
+          <div class="comments mobile">
+            <span class="drafts">
+              [[_computeDraftsStringMobile(drafts, patchRange.patchNum,
+                  file.__path)]]
+            </span>
+            [[_computeCommentsStringMobile(comments, patchRange.patchNum,
                 file.__path)]]
-          </span>
-          [[_computeCommentsStringMobile(comments, patchRange.patchNum,
-              file.__path)]]
+          </div>
+          <div class$="[[_computeClass('stats', file.__path)]]">
+            <span
+                class="added"
+                tabindex="0"
+                aria-label$="[[file.lines_inserted]] lines added"
+                hidden$=[[file.binary]]>
+              +[[file.lines_inserted]]
+            </span>
+            <span
+                class="removed"
+                tabindex="0"
+                aria-label$="[[file.lines_deleted]] lines removed"
+                hidden$=[[file.binary]]>
+              -[[file.lines_deleted]]
+            </span>
+            <span class$="[[_computeBinaryClass(file.size_delta)]]"
+                hidden$=[[!file.binary]]>
+              [[_formatBytes(file.size_delta)]]
+              [[_formatPercentage(file.size, file.size_delta)]]
+            </span>
+          </div>
+          <div class="show-hide" hidden$="[[_userPrefs.expand_inline_diffs]]">
+            <label class="show-hide" data-path$="[[file.__path]]"
+                data-expand=true>
+              <input type="checkbox" class="show-hide"
+                  checked$="[[_isFileExpanded(file.__path, _expandedFilePaths.*)]]"
+                  data-path$="[[file.__path]]" data-expand=true>
+              [[_computeShowHideText(file.__path, _expandedFilePaths.*)]]
+            </label>
+          </div>
         </div>
-        <div class$="[[_computeClass('stats', file.__path)]]">
-          <span
-              class="added"
-              tabindex="0"
-              aria-label$="[[file.lines_inserted]] lines added"
-              hidden$=[[file.binary]]>
-            +[[file.lines_inserted]]
-          </span>
-          <span
-              class="removed"
-              tabindex="0"
-              aria-label$="[[file.lines_deleted]] lines removed"
-              hidden$=[[file.binary]]>
-            -[[file.lines_deleted]]
-          </span>
-          <span class$="[[_computeBinaryClass(file.size_delta)]]"
-              hidden$=[[!file.binary]]>
-            [[_formatBytes(file.size_delta)]]
-            [[_formatPercentage(file.size, file.size_delta)]]
-          </span>
-        </div>
-        <div class="show-hide" hidden$="[[_userPrefs.expand_inline_diffs]]">
-          <label class="show-hide">
-            <input type="checkbox" class="show-hide"
-                checked$="[[_isFileExpanded(file.__path, _expandedFilePaths.*)]]"
-                data-path$="[[file.__path]]"
-                on-change="_handleHiddenChange">
-            [[_computeShowHideText(file.__path, _expandedFilePaths.*)]]
-          </label>
-        </div>
-      </div>
-      <gr-diff
-          no-auto-render
-          hidden="[[!_isFileExpanded(file.__path, _expandedFilePaths.*)]]"
-          project="[[change.project]]"
-          commit="[[change.current_revision]]"
-          change-num="[[changeNum]]"
-          patch-range="[[patchRange]]"
-          path="[[file.__path]]"
-          prefs="[[_diffPrefs]]"
-          project-config="[[projectConfig]]"
-          view-mode="[[_getDiffViewMode(diffViewMode, _userPrefs)]]"></gr-diff>
-    </template>
-    <div class$="row totalChanges [[_computeExpandInlineClass(_userPrefs)]]">
-      <div class="total-stats" hidden$="[[_hideChangeTotals]]">
+        <template is="dom-if"
+            if="[[_isFileExpanded(file.__path, _expandedFilePaths.*)]]">
+          <gr-diff
+              no-auto-render
+              inline-index=[[index]]
+              hidden="[[!_isFileExpanded(file.__path, _expandedFilePaths.*)]]"
+              change-num="[[changeNum]]"
+              patch-range="[[patchRange]]"
+              path="[[file.__path]]"
+              prefs="[[diffPrefs]]"
+              project-config="[[projectConfig]]"
+              on-line-selected="_onLineSelected"
+              no-render-on-prefs-change
+              view-mode="[[_getDiffViewMode(diffViewMode, _userPrefs)]]"></gr-diff>
+        </template>
+      </template>
+    </div>
+    <div
+        class$="row totalChanges [[_computeExpandInlineClass(_userPrefs)]]"
+        hidden$="[[_hideChangeTotals]]">
+      <div class="total-stats">
         <span
             class="added"
             tabindex="0"
@@ -338,8 +353,10 @@
         </span>
       </div>
     </div>
-    <div class$="row totalChanges [[_computeExpandInlineClass(_userPrefs)]]">
-      <div class="total-stats" hidden$="[[_hideBinaryChangeTotals]]">
+    <div
+        class$="row totalChanges [[_computeExpandInlineClass(_userPrefs)]]"
+        hidden$="[[_hideBinaryChangeTotals]]">
+      <div class="total-stats">
         <span class="added" aria-label="Total lines added">
           [[_formatBytes(_patchChange.size_delta_inserted)]]
           [[_formatPercentage(_patchChange.total_size,
@@ -355,17 +372,26 @@
     <gr-button
         class="fileListButton"
         id="incrementButton"
-        hidden$="[[_computeFileListButtonHidden(_numFilesShown, _files)]]"
+        hidden$="[[_computeFileListButtonHidden(numFilesShown, _files)]]"
         link on-tap="_incrementNumFilesShown">
-      [[_computeIncrementText(_numFilesShown, _files)]]
+      [[_computeIncrementText(numFilesShown, _files)]]
     </gr-button>
-    <gr-button
-        class="fileListButton"
-        id="showAllButton"
-        hidden$="[[_computeFileListButtonHidden(_numFilesShown, _files)]]"
-        link on-tap="_showAllFiles">
-      [[_computeShowAllText(_files)]]
-    </gr-button>
+    <gr-tooltip-content
+        has-tooltip="[[_computeWarnShowAll(_files)]]"
+        show-icon="[[_computeWarnShowAll(_files)]]"
+        title$="[[_computeShowAllWarning(_files)]]">
+      <gr-button
+          class="fileListButton"
+          id="showAllButton"
+          hidden$="[[_computeFileListButtonHidden(numFilesShown, _files)]]"
+          link on-tap="_showAllFiles">
+        [[_computeShowAllText(_files)]]
+      </gr-button><!--
+ --></gr-tooltip-content>
+    <gr-diff-preferences
+        id="diffPreferences"
+        prefs="{{diffPrefs}}"
+        local-prefs="{{_localPrefs}}"></gr-diff-preferences>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-storage id="storage"></gr-storage>
     <gr-diff-cursor id="diffCursor"></gr-diff-cursor>
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 e91c28dd..03c7a97 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
@@ -15,12 +15,12 @@
   'use strict';
 
   // Maximum length for patch set descriptions.
-  var PATCH_DESC_MAX_LENGTH = 500;
+  const PATCH_DESC_MAX_LENGTH = 500;
+  const WARN_SHOW_ALL_THRESHOLD = 1000;
+  const COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
+  const MERGE_LIST_PATH = '/MERGE_LIST';
 
-  var COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
-  var MERGE_LIST_PATH = '/MERGE_LIST';
-
-  var FileStatus = {
+  const FileStatus = {
     A: 'Added',
     C: 'Copied',
     D: 'Deleted',
@@ -48,17 +48,18 @@
       },
       keyEventTarget: {
         type: Object,
-        value: function() { return document.body; },
+        value() { return document.body; },
       },
       change: Object,
       diffViewMode: {
         type: String,
         notify: true,
+        observer: '_updateDiffPreferences',
       },
       _files: {
         type: Array,
         observer: '_filesChanged',
-        value: function() { return []; },
+        value() { return []; },
       },
       _loggedIn: {
         type: Boolean,
@@ -66,26 +67,26 @@
       },
       _reviewed: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       _diffAgainst: String,
-      _diffPrefs: Object,
+      diffPrefs: {
+        type: Object,
+        notify: true,
+        observer: '_updateDiffPreferences',
+      },
       _userPrefs: Object,
       _localPrefs: Object,
       _showInlineDiffs: Boolean,
-      _numFilesShown: {
+      numFilesShown: {
         type: Number,
-        value: 75,
+        notify: true,
       },
       _patchChange: {
         type: Object,
         computed: '_calculatePatchChange(_files)',
       },
-      _fileListIncrement: {
-        type: Number,
-        readOnly: true,
-        value: 75,
-      },
+      fileListIncrement: Number,
       _hideChangeTotals: {
         type: Boolean,
         computed: '_shouldHideChangeTotals(_patchChange)',
@@ -96,7 +97,7 @@
       },
       _shownFiles: {
         type: Array,
-        computed: '_computeFilesShown(_numFilesShown, _files.*)',
+        computed: '_computeFilesShown(numFilesShown, _files.*)',
       },
       // Caps the number of files that can be shown and have the 'show diffs' /
       // 'hide diffs' buttons still be functional.
@@ -107,7 +108,7 @@
       },
       _expandedFilePaths: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
     },
 
@@ -139,56 +140,59 @@
       'shift+a': '_handleCapitalAKey',
     },
 
-    reload: function() {
+    reload() {
       if (!this.changeNum || !this.patchRange.patchNum) {
         return Promise.resolve();
       }
       this._collapseAllDiffs();
-      var promises = [];
-      var _this = this;
+      const promises = [];
 
-      promises.push(this._getFiles().then(function(files) {
-        _this._files = files;
+      promises.push(this._getFiles().then(files => {
+        this._files = files;
       }));
-      promises.push(this._getLoggedIn().then(function(loggedIn) {
-        return _this._loggedIn = loggedIn;
-      }).then(function(loggedIn) {
+      promises.push(this._getLoggedIn().then(loggedIn => {
+        return this._loggedIn = loggedIn;
+      }).then(loggedIn => {
         if (!loggedIn) { return; }
 
-        return _this._getReviewedFiles().then(function(reviewed) {
-          _this._reviewed = reviewed;
+        return this._getReviewedFiles().then(reviewed => {
+          this._reviewed = reviewed;
         });
       }));
 
       this._localPrefs = this.$.storage.getPreferences();
-      promises.push(this._getDiffPreferences().then(function(prefs) {
-        this._diffPrefs = prefs;
-      }.bind(this)));
+      promises.push(this._getDiffPreferences().then(prefs => {
+        this.diffPrefs = prefs;
+      }));
 
-      promises.push(this._getPreferences().then(function(prefs) {
+      promises.push(this._getPreferences().then(prefs => {
         this._userPrefs = prefs;
         if (!this.diffViewMode) {
           this.set('diffViewMode', prefs.default_diff_view);
         }
-      }.bind(this)));
+      }));
     },
 
     get diffs() {
       return Polymer.dom(this.root).querySelectorAll('gr-diff');
     },
 
-    _calculatePatchChange: function(files) {
-      var filesNoCommitMsg = files.filter(function(files) {
+    openDiffPrefs() {
+      this.$.diffPreferences.open();
+    },
+
+    _calculatePatchChange(files) {
+      const filesNoCommitMsg = files.filter(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;
-        var total_size = (obj.size && obj.binary) ? obj.size : 0;
-        var size_delta_inserted =
+      return filesNoCommitMsg.reduce((acc, obj) => {
+        const inserted = obj.lines_inserted ? obj.lines_inserted : 0;
+        const deleted = obj.lines_deleted ? obj.lines_deleted : 0;
+        const total_size = (obj.size && obj.binary) ? obj.size : 0;
+        const size_delta_inserted =
             obj.binary && obj.size_delta > 0 ? obj.size_delta : 0;
-        var size_delta_deleted =
+        const size_delta_deleted =
             obj.binary && obj.size_delta < 0 ? obj.size_delta : 0;
 
         return {
@@ -202,40 +206,22 @@
         size_delta_deleted: 0, total_size: 0});
     },
 
-    _getDiffPreferences: function() {
+    _getDiffPreferences() {
       return this.$.restAPI.getDiffPreferences();
     },
 
-    _getPreferences: function() {
+    _getPreferences() {
       return this.$.restAPI.getPreferences();
     },
 
-    _computePatchSets: function(revisionRecord) {
-      var revisions = revisionRecord.base;
-      var patchNums = [];
-      for (var commit in revisions) {
-        if (revisions.hasOwnProperty(commit)) {
-          patchNums.push({
-            num: revisions[commit]._number,
-            desc: revisions[commit].description,
-          });
-        }
-      }
-      return patchNums.sort(function(a, b) { return a.num - b.num; });
-    },
-
-    _computePatchSetDisabled: function(patchNum, currentPatchNum) {
+    _computePatchSetDisabled(patchNum, currentPatchNum) {
       return parseInt(patchNum, 10) >= parseInt(currentPatchNum, 10);
     },
 
-    _handleHiddenChange: function(e) {
-      this._togglePathExpanded(e.model.file.__path);
-    },
-
-    _togglePathExpanded: function(path) {
+    _togglePathExpanded(path) {
       // Is the path in the list of expanded diffs? IF so remove it, otherwise
       // add it to the list.
-      var pathIndex = this._expandedFilePaths.indexOf(path);
+      const pathIndex = this._expandedFilePaths.indexOf(path);
       if (pathIndex === -1) {
         this.push('_expandedFilePaths', path);
       } else {
@@ -243,79 +229,92 @@
       }
     },
 
-    _togglePathExpandedByIndex: function(index) {
+    _togglePathExpandedByIndex(index) {
       this._togglePathExpanded(this._files[index].__path);
     },
 
-    _handlePatchChange: function(e) {
-      var patchRange = Object.assign({}, this.patchRange);
+    _handlePatchChange(e) {
+      const patchRange = Object.assign({}, this.patchRange);
       patchRange.basePatchNum = Polymer.dom(e).rootTarget.value;
       page.show(this.encodeURL('/c/' + this.changeNum + '/' +
           this._patchRangeStr(patchRange), true));
     },
 
-    _forEachDiff: function(fn) {
-      var diffs = this.diffs;
-      for (var i = 0; i < diffs.length; i++) {
+    _updateDiffPreferences() {
+      if (!this.diffs.length) { return; }
+      // Re-render all expanded diffs sequentially.
+      const timerName = 'Update ' + this._expandedFilePaths.length +
+          ' diffs with new prefs';
+      this._renderInOrder(this._expandedFilePaths, this.diffs,
+          this._expandedFilePaths.length)
+          .then(() => {
+            this.$.reporting.timeEnd(timerName);
+            this.$.diffCursor.handleDiffUpdate();
+          });
+    },
+
+    _forEachDiff(fn) {
+      const diffs = this.diffs;
+      for (let i = 0; i < diffs.length; i++) {
         fn(diffs[i]);
       }
     },
 
-    _expandAllDiffs: function(e) {
+    _expandAllDiffs(e) {
       this._showInlineDiffs = true;
 
       // Find the list of paths that are in the file list, but not in the
       // expanded list.
-      var newPaths = [];
-      var path;
-      for (var i = 0; i < this._shownFiles.length; i++) {
+      const newPaths = [];
+      let path;
+      for (let i = 0; i < this._shownFiles.length; i++) {
         path = this._shownFiles[i].__path;
-        if (this._expandedFilePaths.indexOf(path) === -1) {
+        if (!this._expandedFilePaths.includes(path)) {
           newPaths.push(path);
         }
       }
 
-      this.splice.apply(this, ['_expandedFilePaths', 0, 0].concat(newPaths));
+      this.splice(...['_expandedFilePaths', 0, 0].concat(newPaths));
     },
 
-    _collapseAllDiffs: function(e) {
+    _collapseAllDiffs(e) {
       this._showInlineDiffs = false;
       this._expandedFilePaths = [];
       this.$.diffCursor.handleDiffUpdate();
     },
 
-    _computeCommentsString: function(comments, patchNum, path) {
+    _computeCommentsString(comments, patchNum, path) {
       return this._computeCountString(comments, patchNum, path, 'comment');
     },
 
-    _computeDraftsString: function(drafts, patchNum, path) {
+    _computeDraftsString(drafts, patchNum, path) {
       return this._computeCountString(drafts, patchNum, path, 'draft');
     },
 
-    _computeDraftsStringMobile: function(drafts, patchNum, path) {
-      var draftCount = this._computeCountString(drafts, patchNum, path);
+    _computeDraftsStringMobile(drafts, patchNum, path) {
+      const draftCount = this._computeCountString(drafts, patchNum, path);
       return draftCount ? draftCount + 'd' : '';
     },
 
-    _computeCommentsStringMobile: function(comments, patchNum, path) {
-      var commentCount = this._computeCountString(comments, patchNum, path);
+    _computeCommentsStringMobile(comments, patchNum, path) {
+      const commentCount = this._computeCountString(comments, patchNum, path);
       return commentCount ? commentCount + 'c' : '';
     },
 
-    _getCommentsForPath: function(comments, patchNum, path) {
-      return (comments[path] || []).filter(function(c) {
+    getCommentsForPath(comments, patchNum, path) {
+      return (comments[path] || []).filter(c => {
         return parseInt(c.patch_set, 10) === parseInt(patchNum, 10);
       });
     },
 
-    _computeCountString: function(comments, patchNum, path, opt_noun) {
+    _computeCountString(comments, patchNum, path, opt_noun) {
       if (!comments) { return ''; }
 
-      var patchComments = this._getCommentsForPath(comments, patchNum, path);
-      var num = patchComments.length;
+      const patchComments = this.getCommentsForPath(comments, patchNum, path);
+      const num = patchComments.length;
       if (num === 0) { return ''; }
       if (!opt_noun) { return num; }
-      var output = num + ' ' + opt_noun + (num > 1 ? 's' : '');
+      const output = num + ' ' + opt_noun + (num > 1 ? 's' : '');
       return output;
     },
 
@@ -329,15 +328,21 @@
      * @param {string} path
      * @return {string}
      */
-    _computeUnresolvedString: function(comments, drafts, patchNum, path) {
-      comments = this._getCommentsForPath(comments, patchNum, path);
-      drafts = this._getCommentsForPath(drafts, patchNum, path);
+    _computeUnresolvedString(comments, drafts, patchNum, path) {
+      const unresolvedNum = this.computeUnresolvedNum(
+          comments, drafts, patchNum, path);
+      return unresolvedNum === 0 ? '' : '(' + unresolvedNum + ' unresolved)';
+    },
+
+    computeUnresolvedNum(comments, drafts, patchNum, path) {
+      comments = this.getCommentsForPath(comments, patchNum, path);
+      drafts = this.getCommentsForPath(drafts, patchNum, path);
       comments = comments.concat(drafts);
 
       // Create an object where every comment ID is the key of an unresolved
       // comment.
 
-      var idMap = comments.reduce(function(acc, comment) {
+      const idMap = comments.reduce((acc, comment) => {
         if (comment.unresolved) {
           acc[comment.id] = true;
         }
@@ -345,30 +350,25 @@
       }, {});
 
       // Set false for the comments that are marked as parents.
-      comments.forEach(function(comment) {
+      for (const comment of comments) {
         idMap[comment.in_reply_to] = false;
-      });
+      }
 
       // The unresolved comments are the comments that still have true.
-      var unresolvedLeaves = Object.keys(idMap).filter(function(key) {
+      const unresolvedLeaves = Object.keys(idMap).filter(key => {
         return idMap[key];
       });
 
-      return unresolvedLeaves.length === 0 ?
-          '' : '(' + unresolvedLeaves.length + ' unresolved)';
+      return unresolvedLeaves.length;
     },
 
-    _computeReviewed: function(file, _reviewed) {
-      return _reviewed.indexOf(file.__path) !== -1;
+    _computeReviewed(file, _reviewed) {
+      return _reviewed.includes(file.__path);
     },
 
-    _handleReviewedChange: function(e) {
-      this._reviewFile(Polymer.dom(e).rootTarget.getAttribute('data-path'));
-    },
-
-    _reviewFile: function(path) {
-      var index = this._reviewed.indexOf(path);
-      var reviewed = index !== -1;
+    _reviewFile(path) {
+      const index = this._reviewed.indexOf(path);
+      const reviewed = index !== -1;
       if (reviewed) {
         this.splice('_reviewed', index, 1);
       } else {
@@ -378,43 +378,71 @@
       this._saveReviewedState(path, !reviewed);
     },
 
-    _saveReviewedState: function(path, reviewed) {
+    _saveReviewedState(path, reviewed) {
       return this.$.restAPI.saveFileReviewed(this.changeNum,
           this.patchRange.patchNum, path, reviewed);
     },
 
-    _getLoggedIn: function() {
+    _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
     },
 
-    _getReviewedFiles: function() {
+    _getReviewedFiles() {
       return this.$.restAPI.getReviewedFiles(this.changeNum,
           this.patchRange.patchNum);
     },
 
-    _getFiles: function() {
+    _getFiles() {
       return this.$.restAPI.getChangeFilesAsSpeciallySortedArray(
-          this.changeNum, this.patchRange).then(function(files) {
+          this.changeNum, this.patchRange).then(files => {
             // Append UI-specific properties.
-            return files.map(function(file) {
+            return files.map(file => {
               return file;
             });
           });
     },
 
-    _handleFileTap: function(e) {
+    /**
+     * Handle all events from the file list dom-repeat so event handleers don't
+     * have to get registered for potentially very long lists.
+     */
+    _handleFileListTap(e) {
+      // Traverse upwards to find the row element if the target is not the row.
+      let row = e.target;
+      while (!row.classList.contains('row') && row.parentElement) {
+        row = row.parentElement;
+      }
+      const path = row.dataset.path;
+
+      // Handle checkbox mark as reviewed.
+      if (e.target.classList.contains('reviewed')) {
+        return this._reviewFile(path);
+      }
+
       // If the user prefers to expand inline diffs rather than opening the diff
       // view, intercept the click event.
-      if (e.detail.sourceEvent.metaKey || e.detail.sourceEvent.ctrlKey) {
-          return;
+      if (!path || e.detail.sourceEvent.metaKey ||
+          e.detail.sourceEvent.ctrlKey) {
+        return;
       }
-      if (this._userPrefs && this._userPrefs.expand_inline_diffs) {
+
+      if (e.target.dataset.expand ||
+          this._userPrefs && this._userPrefs.expand_inline_diffs) {
         e.preventDefault();
-        this._handleHiddenChange(e);
+        this._togglePathExpanded(path);
+        return;
+      }
+
+      // If we clicked the row but not the link, then simulate a click on the
+      // anchor.
+      if (e.target.classList.contains('path') ||
+          e.target.classList.contains('oldPath')) {
+        const a = row.querySelector('a');
+        if (a) { a.click(); }
       }
     },
 
-    _handleShiftLeftKey: function(e) {
+    _handleShiftLeftKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       if (!this._showInlineDiffs) { return; }
 
@@ -422,7 +450,7 @@
       this.$.diffCursor.moveLeft();
     },
 
-    _handleShiftRightKey: function(e) {
+    _handleShiftRightKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       if (!this._showInlineDiffs) { return; }
 
@@ -430,7 +458,7 @@
       this.$.diffCursor.moveRight();
     },
 
-    _handleIKey: function(e) {
+    _handleIKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e) ||
           this.$.fileCursor.index === -1) { return; }
@@ -439,15 +467,18 @@
       this._togglePathExpandedByIndex(this.$.fileCursor.index);
     },
 
-    _handleCapitalIKey: function(e) {
+    _handleCapitalIKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
       this._toggleInlineDiffs();
     },
 
-    _handleDownKey: function(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    _handleDownKey(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+        return;
+      }
+
       e.preventDefault();
       if (this._showInlineDiffs) {
         this.$.diffCursor.moveDown();
@@ -457,8 +488,10 @@
       }
     },
 
-    _handleUpKey: function(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    _handleUpKey(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+        return;
+      }
 
       e.preventDefault();
       if (this._showInlineDiffs) {
@@ -469,11 +502,11 @@
       }
     },
 
-    _handleCKey: function(e) {
+    _handleCKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
-      var isRangeSelected = this.diffs.some(function(diff) {
+      const isRangeSelected = this.diffs.some(diff => {
         return diff.isRangeSelected();
       }, this);
       if (this._showInlineDiffs && !isRangeSelected) {
@@ -482,21 +515,25 @@
       }
     },
 
-    _handleLeftBracketKey: function(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    _handleLeftBracketKey(e) {
+      // Check for meta key to avoid overriding native chrome shortcut.
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.getKeyboardEvent(e).metaKey) { return; }
 
       e.preventDefault();
       this._openSelectedFile(this._files.length - 1);
     },
 
-    _handleRightBracketKey: function(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    _handleRightBracketKey(e) {
+      // Check for meta key to avoid overriding native chrome shortcut.
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.getKeyboardEvent(e).metaKey) { return; }
 
       e.preventDefault();
       this._openSelectedFile(0);
     },
 
-    _handleEnterKey: function(e) {
+    _handleEnterKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -507,47 +544,54 @@
       e.preventDefault();
       if (this._showInlineDiffs) {
         this._openCursorFile();
+      } else if (this._userPrefs && this._userPrefs.expand_inline_diffs) {
+        if (this.$.fileCursor.index === -1) { return; }
+        this._togglePathExpandedByIndex(this.$.fileCursor.index);
       } else {
         this._openSelectedFile();
       }
     },
 
-    _handleNKey: function(e) {
+    _handleNKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
+          this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
+        return;
+      }
       if (!this._showInlineDiffs) { return; }
 
       e.preventDefault();
-      if (e.shiftKey) {
+      if (this.isModifierPressed(e, 'shiftKey')) {
         this.$.diffCursor.moveToNextCommentThread();
       } else {
         this.$.diffCursor.moveToNextChunk();
       }
     },
 
-    _handlePKey: function(e) {
+    _handlePKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
+          this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
+        return;
+      }
       if (!this._showInlineDiffs) { return; }
 
       e.preventDefault();
-      if (e.shiftKey) {
+      if (this.isModifierPressed(e, 'shiftKey')) {
         this.$.diffCursor.moveToPreviousCommentThread();
       } else {
         this.$.diffCursor.moveToPreviousChunk();
       }
     },
 
-    _handleCapitalAKey: function(e) {
+    _handleCapitalAKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
-      this._forEachDiff(function(diff) {
+      this._forEachDiff(diff => {
         diff.toggleLeftDiff();
       });
     },
 
-    _toggleInlineDiffs: function() {
+    _toggleInlineDiffs() {
       if (this._showInlineDiffs) {
         this._collapseAllDiffs();
       } else {
@@ -555,53 +599,54 @@
       }
     },
 
-    _openCursorFile: function() {
-      var diff = this.$.diffCursor.getTargetDiffElement();
+    _openCursorFile() {
+      const diff = this.$.diffCursor.getTargetDiffElement();
       page.show(this._computeDiffURL(diff.changeNum, diff.patchRange,
           diff.path));
     },
 
-    _openSelectedFile: function(opt_index) {
+    _openSelectedFile(opt_index) {
       if (opt_index != null) {
         this.$.fileCursor.setCursorAtIndex(opt_index);
       }
+      if (!this._files[this.$.fileCursor.index]) { return; }
       page.show(this._computeDiffURL(this.changeNum, this.patchRange,
           this._files[this.$.fileCursor.index].__path));
     },
 
-    _addDraftAtTarget: function() {
-      var diff = this.$.diffCursor.getTargetDiffElement();
-      var target = this.$.diffCursor.getTargetLineElement();
+    _addDraftAtTarget() {
+      const diff = this.$.diffCursor.getTargetDiffElement();
+      const target = this.$.diffCursor.getTargetLineElement();
       if (diff && target) {
         diff.addDraftAtLine(target);
       }
     },
 
-    _shouldHideChangeTotals: function(_patchChange) {
+    _shouldHideChangeTotals(_patchChange) {
       return _patchChange.inserted === 0 && _patchChange.deleted === 0;
     },
 
-    _shouldHideBinaryChangeTotals: function(_patchChange) {
+    _shouldHideBinaryChangeTotals(_patchChange) {
       return _patchChange.size_delta_inserted === 0 &&
           _patchChange.size_delta_deleted === 0;
     },
 
-    _computeFileStatus: function(status) {
+    _computeFileStatus(status) {
       return status || 'M';
     },
 
-    _computeDiffURL: function(changeNum, patchRange, path) {
+    _computeDiffURL(changeNum, patchRange, path) {
       return this.encodeURL(this.getBaseUrl() + '/c/' + changeNum + '/' +
           this._patchRangeStr(patchRange) + '/' + path, true);
     },
 
-    _patchRangeStr: function(patchRange) {
+    _patchRangeStr(patchRange) {
       return patchRange.basePatchNum !== 'PARENT' ?
           patchRange.basePatchNum + '..' + patchRange.patchNum :
           patchRange.patchNum + '';
     },
 
-    _computeFileDisplayName: function(path) {
+    _computeFileDisplayName(path) {
       if (path === COMMIT_MESSAGE_PATH) {
         return 'Commit message';
       } else if (path === MERGE_LIST_PATH) {
@@ -610,65 +655,66 @@
       return path;
     },
 
-    _computeTruncatedFileDisplayName: function(path) {
+    _computeTruncatedFileDisplayName(path) {
       return util.truncatePath(this._computeFileDisplayName(path));
     },
 
-    _formatBytes: function(bytes) {
+    _formatBytes(bytes) {
       if (bytes == 0) return '+/-0 B';
-      var bits = 1024;
-      var decimals = 1;
-      var sizes = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
-      var exponent = Math.floor(Math.log(Math.abs(bytes)) / Math.log(bits));
-      var prepend = bytes > 0 ? '+' : '';
+      const bits = 1024;
+      const decimals = 1;
+      const sizes =
+          ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
+      const exponent = Math.floor(Math.log(Math.abs(bytes)) / Math.log(bits));
+      const prepend = bytes > 0 ? '+' : '';
       return prepend + parseFloat((bytes / Math.pow(bits, exponent))
           .toFixed(decimals)) + ' ' + sizes[exponent];
     },
 
-    _formatPercentage: function(size, delta) {
-      var oldSize = size - delta;
+    _formatPercentage(size, delta) {
+      const oldSize = size - delta;
 
       if (oldSize === 0) { return ''; }
 
-      var percentage = Math.round(Math.abs(delta * 100 / oldSize));
+      const percentage = Math.round(Math.abs(delta * 100 / oldSize));
       return '(' + (delta > 0 ? '+' : '-') + percentage + '%)';
     },
 
-    _computeBinaryClass: function(delta) {
+    _computeBinaryClass(delta) {
       if (delta === 0) { return; }
       return delta >= 0 ? 'added' : 'removed';
     },
 
-    _computeClass: function(baseClass, path) {
-      var classes = [baseClass];
+    _computeClass(baseClass, path) {
+      const classes = [baseClass];
       if (path === COMMIT_MESSAGE_PATH || path === MERGE_LIST_PATH) {
         classes.push('invisible');
       }
       return classes.join(' ');
     },
 
-    _computeExpandInlineClass: function(userPrefs) {
+    _computeExpandInlineClass(userPrefs) {
       return userPrefs.expand_inline_diffs ? 'expandInline' : '';
     },
 
-    _computePathClass: function(path, expandedFilesRecord) {
+    _computePathClass(path, expandedFilesRecord) {
       return this._isFileExpanded(path, expandedFilesRecord) ? 'path expanded' :
           'path';
     },
 
-    _computeShowHideText: function(path, expandedFilesRecord) {
+    _computeShowHideText(path, expandedFilesRecord) {
       return this._isFileExpanded(path, expandedFilesRecord) ? '▼' : '◀';
     },
 
-    _computeFilesShown: function(numFilesShown, files) {
+    _computeFilesShown(numFilesShown, files) {
       return files.base.slice(0, numFilesShown);
     },
 
-    _setReviewedFiles: function(shownFiles, files, reviewedRecord, loggedIn) {
+    _setReviewedFiles(shownFiles, files, reviewedRecord, loggedIn) {
       if (!loggedIn) { return; }
-      var reviewed = reviewedRecord.base;
-      var fileReviewed;
-      for (var i = 0; i < files.length; i++) {
+      const reviewed = reviewedRecord.base;
+      let fileReviewed;
+      for (let i = 0; i < files.length; i++) {
         fileReviewed = this._computeReviewed(files[i], reviewed);
         this._files[i].isReviewed = fileReviewed;
         if (i < shownFiles.length) {
@@ -677,45 +723,56 @@
       }
     },
 
-    _filesChanged: function() {
-      this.async(function() {
-        var diffElements = Polymer.dom(this.root).querySelectorAll('gr-diff');
+    _updateDiffCursor() {
+      const diffElements = Polymer.dom(this.root).querySelectorAll('gr-diff');
 
-        // Overwrite the cursor's list of diffs:
-        this.$.diffCursor.splice.apply(this.$.diffCursor,
-            ['diffs', 0, this.$.diffCursor.diffs.length].concat(diffElements));
-
-        var files = Polymer.dom(this.root).querySelectorAll('.file-row');
-        this.$.fileCursor.stops = files;
-        this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
-      }.bind(this), 1);
+      // Overwrite the cursor's list of diffs:
+      this.$.diffCursor.splice(
+          ...['diffs', 0, this.$.diffCursor.diffs.length].concat(diffElements));
     },
 
-    _incrementNumFilesShown: function() {
-      this._numFilesShown += this._fileListIncrement;
+    _filesChanged() {
+      Polymer.dom.flush();
+      const files = Polymer.dom(this.root).querySelectorAll('.file-row');
+      this.$.fileCursor.stops = files;
+      this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
     },
 
-    _computeFileListButtonHidden: function(numFilesShown, files) {
+    _incrementNumFilesShown() {
+      this.numFilesShown += this.fileListIncrement;
+    },
+
+    _computeFileListButtonHidden(numFilesShown, files) {
       return numFilesShown >= files.length;
     },
 
-    _computeIncrementText: function(numFilesShown, files) {
+    _computeIncrementText(numFilesShown, files) {
       if (!files) { return ''; }
-      var text =
-          Math.min(this._fileListIncrement, files.length - numFilesShown);
+      const text =
+          Math.min(this.fileListIncrement, files.length - numFilesShown);
       return 'Show ' + text + ' more';
     },
 
-    _computeShowAllText: function(files) {
+    _computeShowAllText(files) {
       if (!files) { return ''; }
       return 'Show all ' + files.length + ' files';
     },
 
-    _showAllFiles: function() {
-      this._numFilesShown = this._files.length;
+    _computeWarnShowAll(files) {
+      return files.length > WARN_SHOW_ALL_THRESHOLD;
     },
 
-    _updateSelected: function(patchRange) {
+    _computeShowAllWarning(files) {
+      if (!this._computeWarnShowAll(files)) { return ''; }
+      return 'Warning: showing all ' + files.length +
+          ' files may take several seconds.';
+    },
+
+    _showAllFiles() {
+      this.numFilesShown = this._files.length;
+    },
+
+    _updateSelected(patchRange) {
       this._diffAgainst = patchRange.basePatchNum;
     },
 
@@ -730,7 +787,7 @@
      *
      * @return {String}
      */
-    _getDiffViewMode: function(diffViewMode, userPrefs) {
+    _getDiffViewMode(diffViewMode, userPrefs) {
       if (diffViewMode) {
         return diffViewMode;
       } else if (userPrefs) {
@@ -739,25 +796,30 @@
       return 'SIDE_BY_SIDE';
     },
 
-    _fileListActionsVisible: function(shownFilesRecord,
+    _fileListActionsVisible(shownFilesRecord,
         maxFilesForBulkActions) {
       return shownFilesRecord.base.length <= maxFilesForBulkActions;
     },
 
-    _computePatchSetDescription: function(revisions, patchNum) {
-      var rev = this.getRevisionByPatchNum(revisions, patchNum);
+    _computePatchSetDescription(revisions, patchNum) {
+      const rev = this.getRevisionByPatchNum(revisions, patchNum);
       return (rev && rev.description) ?
           rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
     },
 
-    _computeFileStatusLabel: function(status) {
-      var statusCode = this._computeFileStatus(status);
+    _computeFileStatusLabel(status) {
+      const statusCode = this._computeFileStatus(status);
       return FileStatus.hasOwnProperty(statusCode) ?
           FileStatus[statusCode] : 'Status Unknown';
     },
 
-    _isFileExpanded: function(path, expandedFilesRecord) {
-      return expandedFilesRecord.base.indexOf(path) !== -1;
+    _isFileExpanded(path, expandedFilesRecord) {
+      return expandedFilesRecord.base.includes(path);
+    },
+
+    _onLineSelected(e, detail) {
+      this.$.diffCursor.moveToLineNumber(detail.number, detail.side,
+          detail.path);
     },
 
     /**
@@ -767,25 +829,30 @@
      * one.
      * @param  {splice} record The splice record in the expanded paths list.
      */
-    _expandedPathsChanged: function(record) {
+    _expandedPathsChanged(record) {
       if (!record) { return; }
 
       // Find the paths introduced by the new index splices:
-      var newPaths = record.indexSplices
-          .map(function(splice) {
+      const newPaths = record.indexSplices
+          .map(splice => {
             return splice.object.slice(splice.index,
                 splice.index + splice.addedCount);
           })
-          .reduce(function(acc, paths) { return acc.concat(paths); }, []);
+          .reduce((acc, paths) => { return acc.concat(paths); }, []);
 
-      var timerName = 'Expand ' + newPaths.length + ' diffs';
+      const timerName = 'Expand ' + newPaths.length + ' diffs';
       this.$.reporting.time(timerName);
 
+      // Required so that the newly created diff view is included in this.diffs.
+      Polymer.dom.flush();
+
       this._renderInOrder(newPaths, this.diffs, newPaths.length)
-          .then(function() {
+          .then(() => {
             this.$.reporting.timeEnd(timerName);
             this.$.diffCursor.handleDiffUpdate();
-          }.bind(this));
+          });
+      this._updateDiffCursor();
+      this.$.diffCursor.handleDiffUpdate();
     },
 
     /**
@@ -798,21 +865,21 @@
      *   is used to generate log messages.
      * @return {!Promise}
      */
-    _renderInOrder: function(paths, diffElements, initialCount) {
+    _renderInOrder(paths, diffElements, initialCount) {
       if (!paths.length) {
         console.log('Finished expanding', initialCount, 'diff(s)');
         return Promise.resolve();
       }
       console.log('Expanding diff', 1 + initialCount - paths.length, 'of',
           initialCount, ':', paths[0]);
-      var diffElem = this._findDiffByPath(paths[0], diffElements);
-      var promises = [diffElem.reload()];
+      const diffElem = this._findDiffByPath(paths[0], diffElements);
+      const promises = [diffElem.reload()];
       if (this._isLoggedIn) {
         promises.push(this._reviewFile(paths[0]));
       }
-      return Promise.all(promises).then(function() {
+      return Promise.all(promises).then(() => {
         return this._renderInOrder(paths.slice(1), diffElements, initialCount);
-      }.bind(this));
+      });
     },
 
     /**
@@ -821,8 +888,8 @@
      * @param  {!NodeList<!GrDiffElement>} diffElements
      * @return {!GrDiffElement}
      */
-    _findDiffByPath: function(path, diffElements) {
-      for (var i = 0; i < diffElements.length; i++) {
+    _findDiffByPath(path, diffElements) {
+      for (let i = 0; i < diffElements.length; i++) {
         if (diffElements[i].path === path) {
           return diffElements[i];
         }
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 bd4619f..e022a74 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
@@ -23,6 +23,7 @@
 <script src="../../../bower_components/page/page.js"></script>
 <script src="../../../scripts/util.js"></script>
 
+<link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-file-list.html">
 
@@ -41,36 +42,47 @@
 </test-fixture>
 
 <script>
-  suite('gr-file-list tests', function() {
-    var element;
-    var sandbox;
-    var saveStub;
+  suite('gr-file-list tests', () => {
+    let element;
+    let sandbox;
+    let saveStub;
 
-    setup(function() {
+    setup(() => {
       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({}); },
+        getLoggedIn() { return Promise.resolve(true); },
+        getPreferences() { return Promise.resolve({}); },
+        fetchJSON() { return Promise.resolve({}); },
       });
       stub('gr-date-formatter', {
-        _loadTimeFormat: function() { return Promise.resolve(''); },
+        _loadTimeFormat() { return Promise.resolve(''); },
       });
       stub('gr-diff', {
-        reload: function() { return Promise.resolve(); },
+        reload() { return Promise.resolve(); },
       });
       element = fixture('basic');
+      element.numFilesShown = 200;
       saveStub = sandbox.stub(element, '_saveReviewedState',
-          function() { return Promise.resolve(); });
+          () => { return Promise.resolve(); });
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('get file list', function(done) {
-      var getChangeFilesStub = sandbox.stub(element.$.restAPI, 'getChangeFiles',
-          function() {
+    test('correct number of files are shown', () => {
+      element._files = _.times(500, i => {
+        return {__path: '/file' + i, lines_inserted: 9};
+      });
+      flushAsynchronousOperations();
+      assert.equal(
+          Polymer.dom(element.root).querySelectorAll('.file-row').length,
+          element.numFilesShown);
+    });
+
+    test('get file list', done => {
+      const getChangeFilesStub = sandbox.stub(element.$.restAPI, 'getChangeFiles',
+          () => {
             return Promise.resolve({
               '/COMMIT_MSG': {lines_inserted: 9},
               'tags.html': {lines_deleted: 123},
@@ -78,8 +90,8 @@
             });
           });
 
-      element._getFiles().then(function(files) {
-        var filenames = files.map(function(f) { return f.__path; });
+      element._getFiles().then(files => {
+        const filenames = files.map(f => { return f.__path; });
         assert.deepEqual(filenames, ['/COMMIT_MSG', 'about.txt', 'tags.html']);
         assert.deepEqual(files[0], {
           lines_inserted: 9,
@@ -102,7 +114,7 @@
       });
     });
 
-    test('calculate totals for patch number', function() {
+    test('calculate totals for patch number', () => {
       element._files = [
         {__path: '/COMMIT_MSG', lines_inserted: 9},
         {
@@ -177,7 +189,7 @@
       assert.isFalse(element._hideChangeTotals);
     });
 
-    test('binary only files', function() {
+    test('binary only files', () => {
       element._files = [
         {__path: '/COMMIT_MSG', lines_inserted: 9},
         {__path: 'file_binary', binary: true, size_delta: 10, size: 100},
@@ -194,7 +206,7 @@
       assert.isTrue(element._hideChangeTotals);
     });
 
-    test('binary and regular files', function() {
+    test('binary and regular files', () => {
       element._files = [
         {__path: '/COMMIT_MSG', lines_inserted: 9},
         {__path: 'file_binary', binary: true, size_delta: 10, size: 100},
@@ -213,64 +225,64 @@
       assert.isFalse(element._hideChangeTotals);
     });
 
-    test('_formatBytes function', function() {
-      var table = {
-        64: '+64 B',
-        1023: '+1023 B',
-        1024: '+1 KiB',
-        4096: '+4 KiB',
-        1073741824: '+1 GiB',
+    test('_formatBytes function', () => {
+      const table = {
+        '64': '+64 B',
+        '1023': '+1023 B',
+        '1024': '+1 KiB',
+        '4096': '+4 KiB',
+        '1073741824': '+1 GiB',
         '-64': '-64 B',
         '-1023': '-1023 B',
         '-1024': '-1 KiB',
         '-4096': '-4 KiB',
         '-1073741824': '-1 GiB',
-        0: '+/-0 B',
+        '0': '+/-0 B',
       };
 
-      for (var bytes in table) {
+      for (const bytes in table) {
         if (table.hasOwnProperty(bytes)) {
           assert.equal(element._formatBytes(bytes), table[bytes]);
         }
       }
     });
 
-    test('_formatPercentage function', function() {
-      var table = [
-        { size: 100,
+    test('_formatPercentage function', () => {
+      const table = [
+        {size: 100,
           delta: 100,
           display: '',
         },
-        { size: 195060,
+        {size: 195060,
           delta: 64,
           display: '(+0%)',
         },
-        { size: 195060,
+        {size: 195060,
           delta: -64,
           display: '(-0%)',
         },
-        { size: 394892,
+        {size: 394892,
           delta: -7128,
           display: '(-2%)',
         },
-        { size: 90,
+        {size: 90,
           delta: -10,
           display: '(-10%)',
         },
-        { size: 110,
+        {size: 110,
           delta: 10,
           display: '(+10%)',
         },
       ];
 
-      table.forEach(function(item) {
+      for (const item of table) {
         assert.equal(element._formatPercentage(
             item.size, item.delta), item.display);
-      });
+      }
     });
 
-    suite('keyboard shortcuts', function() {
-      setup(function() {
+    suite('keyboard shortcuts', () => {
+      setup(() => {
         element._files = [
           {__path: '/COMMIT_MSG'},
           {__path: 'file_added_in_rev2.txt'},
@@ -284,12 +296,12 @@
         element.$.fileCursor.setCursorAtIndex(0);
       });
 
-      test('toggle left diff via shortcut', function() {
-        var toggleLeftDiffStub = sandbox.stub();
+      test('toggle left diff via shortcut', () => {
+        const 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() {
+        const diffsStub = sinon.stub(element, 'diffs', {
+          get() {
             return [{toggleLeftDiff: toggleLeftDiffStub}];
           },
         });
@@ -298,27 +310,33 @@
         diffsStub.restore();
       });
 
-      test('keyboard shortcuts', function() {
+      test('keyboard shortcuts', () => {
         flushAsynchronousOperations();
 
-        var items = Polymer.dom(element.root).querySelectorAll('.file-row');
+        const items = Polymer.dom(element.root).querySelectorAll('.file-row');
         element.$.fileCursor.stops = items;
         element.$.fileCursor.setCursorAtIndex(0);
         assert.equal(items.length, 3);
         assert.isTrue(items[0].classList.contains('selected'));
         assert.isFalse(items[1].classList.contains('selected'));
         assert.isFalse(items[2].classList.contains('selected'));
+        // j with a modifier should not move the cursor.
+        MockInteractions.pressAndReleaseKeyOn(element, 74, 'shift', 'j');
+        assert.equal(element.$.fileCursor.index, 0);
         MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
         assert.equal(element.$.fileCursor.index, 1);
         assert.equal(element.selectedIndex, 1);
         MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
 
-        var showStub = sandbox.stub(page, 'show');
+        const showStub = sandbox.stub(page, 'show');
         assert.equal(element.$.fileCursor.index, 2);
         assert.equal(element.selectedIndex, 2);
         MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
         assert(showStub.lastCall.calledWith('/c/42/2/myfile.txt'),
             'Should navigate to /c/42/2/myfile.txt');
+        // k with a modifier should not move the cursor.
+        MockInteractions.pressAndReleaseKeyOn(element, 75, 'shift', 'k');
+        assert.equal(element.$.fileCursor.index, 2);
 
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
         assert.equal(element.$.fileCursor.index, 1);
@@ -334,10 +352,11 @@
         assert.equal(element.selectedIndex, 0);
       });
 
-      test('i key shows/hides selected inline diff', function() {
+      test('i key shows/hides selected inline diff', () => {
         sandbox.stub(element, '_expandedPathsChanged');
         flushAsynchronousOperations();
-        element.$.fileCursor.stops = element.diffs;
+        const files = Polymer.dom(element.root).querySelectorAll('.file-row');
+        element.$.fileCursor.stops = files;
         element.$.fileCursor.setCursorAtIndex(0);
         MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
         flushAsynchronousOperations();
@@ -352,58 +371,88 @@
 
         MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'i');
         flushAsynchronousOperations();
-        for (var index in element.diffs) {
+        for (const index in element.diffs) {
+          if (!element.diffs.hasOwnProperty(index)) { continue; }
           assert.include(element._expandedFilePaths, element.diffs[index].path);
         }
         MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'i');
         flushAsynchronousOperations();
-        for (var index in element.diffs) {
+        for (const index in element.diffs) {
+          if (!element.diffs.hasOwnProperty(index)) { continue; }
           assert.notInclude(element._expandedFilePaths,
               element.diffs[index].path);
         }
       });
 
-      test('_handleEnterKey navigates', function() {
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-        sandbox.stub(element, 'modifierPressed').returns(false);
-        var expandStub = sandbox.stub(element, '_openCursorFile');
-        var navStub = sandbox.stub(element, '_openSelectedFile');
-        var e = new CustomEvent('fake-keyboard-event');
-        sinon.stub(e, 'preventDefault');
-        element._showInlineDiffs = false;
-        element._handleEnterKey(e);
-        assert.isTrue(e.preventDefault.called);
-        assert.isTrue(navStub.called);
-        assert.isFalse(expandStub.called);
-      });
+      suite('_handleEnterKey', () => {
+        let interact;
 
-      test('_handleEnterKey expands', function() {
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-        sandbox.stub(element, 'modifierPressed').returns(false);
-        var expandStub = sandbox.stub(element, '_openCursorFile');
-        var navStub = sandbox.stub(element, '_openSelectedFile');
-        var e = new CustomEvent('fake-keyboard-event');
-        sinon.stub(e, 'preventDefault');
-        element._showInlineDiffs = true;
-        element._handleEnterKey(e);
-        assert.isTrue(e.preventDefault.called);
-        assert.isFalse(navStub.called);
-        assert.isTrue(expandStub.called);
-      });
+        setup(() => {
+          sandbox.stub(element, 'shouldSuppressKeyboardShortcut')
+              .returns(false);
+          sandbox.stub(element, 'modifierPressed').returns(false);
+          const openCursorStub = sandbox.stub(element, '_openCursorFile');
+          const openSelectedStub = sandbox.stub(element, '_openSelectedFile');
+          const expandStub = sandbox.stub(element, '_togglePathExpanded');
 
-      test('_handleEnterKey noop when anchor focused', function() {
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-        sandbox.stub(element, 'modifierPressed').returns(false);
-        var e = new CustomEvent('fake-keyboard-event',
-            {detail: {keyboardEvent: {target: document.createElement('a')}}});
-        sinon.stub(e, 'preventDefault');
-        element._handleEnterKey(e);
-        assert.isFalse(e.preventDefault.called);
+          interact = function(opt_payload) {
+            openCursorStub.reset();
+            openSelectedStub.reset();
+            expandStub.reset();
+
+            const e = new CustomEvent('fake-keyboard-event', opt_payload);
+            sinon.stub(e, 'preventDefault');
+            element._handleEnterKey(e);
+            assert.isTrue(e.preventDefault.called);
+            const result = {};
+            if (openCursorStub.called) {
+              result.opened_cursor = true;
+            }
+            if (openSelectedStub.called) {
+              result.opened_selected = true;
+            }
+            if (expandStub.called) {
+              result.expanded = true;
+            }
+            return result;
+          };
+        });
+
+        test('open from selected file', () => {
+          element._showInlineDiffs = false;
+          assert.deepEqual(interact(), {opened_selected: true});
+        });
+
+        test('open from diff cursor', () => {
+          element._showInlineDiffs = true;
+          assert.deepEqual(interact(), {opened_cursor: true});
+
+          // "Show diffs" mode overrides userPrefs.expand_inline_diffs
+          element._userPrefs = {expand_inline_diffs: true};
+          assert.deepEqual(interact(), {opened_cursor: true});
+        });
+
+        test('expand when user prefers', () => {
+          element._showInlineDiffs = false;
+          assert.deepEqual(interact(), {opened_selected: true});
+          element._userPrefs = {};
+          assert.deepEqual(interact(), {opened_selected: true});
+          element._userPrefs.expand_inline_diffs = true;
+          assert.deepEqual(interact(), {expanded: true});
+        });
+
+        test('noop when anchor focused', () => {
+          const e = new CustomEvent('fake-keyboard-event',
+              {detail: {keyboardEvent: {target: document.createElement('a')}}});
+          sinon.stub(e, 'preventDefault');
+          element._handleEnterKey(e);
+          assert.isFalse(e.preventDefault.called);
+        });
       });
     });
 
-    test('comment filtering', function() {
-      var comments = {
+    test('comment filtering', () => {
+      const comments = {
         '/COMMIT_MSG': [
           {patch_set: 1, message: 'Done', updated: '2017-02-08 16:40:49'},
           {patch_set: 1, message: 'oh hay', updated: '2017-02-09 16:40:49'},
@@ -439,7 +488,7 @@
           },
         ],
       };
-      var drafts = {
+      const drafts = {
         'unresolved.file': [
           {
             patch_set: 2,
@@ -471,13 +520,13 @@
           '1d');
       assert.equal(
           element._computeCountString(comments, '1',
-          'file_added_in_rev2.txt', 'comment'), '');
+              'file_added_in_rev2.txt', 'comment'), '');
       assert.equal(
           element._computeCommentsStringMobile(comments, '1',
-          'file_added_in_rev2.txt'), '');
+              'file_added_in_rev2.txt'), '');
       assert.equal(
           element._computeDraftsStringMobile(comments, '1',
-          'file_added_in_rev2.txt'), '');
+              'file_added_in_rev2.txt'), '');
       assert.equal(
           element._computeCountString(comments, '2', '/COMMIT_MSG', 'comment'),
           '1 comment');
@@ -498,20 +547,24 @@
           '2d');
       assert.equal(
           element._computeCountString(comments, '2',
-          'file_added_in_rev2.txt', 'comment'), '');
+              'file_added_in_rev2.txt', 'comment'), '');
       assert.equal(element._computeCountString(comments, '2',
           'unresolved.file', 'comment'), '3 comments');
       assert.equal(
           element._computeUnresolvedString(comments, [], 2, 'myfile.txt'), '');
       assert.equal(
+          element.computeUnresolvedNum(comments, [], 2, 'myfile.txt'), 0);
+      assert.equal(
           element._computeUnresolvedString(comments, [], 2, 'unresolved.file'),
           '(1 unresolved)');
       assert.equal(
+          element.computeUnresolvedNum(comments, [], 2, 'unresolved.file'), 1);
+      assert.equal(
           element._computeUnresolvedString(comments, drafts, 2,
-          'unresolved.file'), '');
+              'unresolved.file'), '');
     });
 
-    test('computed properties', function() {
+    test('computed properties', () => {
       assert.equal(element._computeFileStatus('A'), 'A');
       assert.equal(element._computeFileStatus(undefined), 'M');
       assert.equal(element._computeFileStatus(null), 'M');
@@ -530,7 +583,7 @@
         {expand_inline_diffs: false}), '');
     });
 
-    test('file review status', function() {
+    test('file review status', () => {
       element._files = [
         {__path: '/COMMIT_MSG'},
         {__path: 'file_added_in_rev2.txt'},
@@ -546,13 +599,13 @@
       element.$.fileCursor.setCursorAtIndex(0);
 
       flushAsynchronousOperations();
-      var fileRows =
+      const fileRows =
           Polymer.dom(element.root).querySelectorAll('.row:not(.header)');
-      var commitMsg = fileRows[0].querySelector(
+      const commitMsg = fileRows[0].querySelector(
           'input.reviewed[type="checkbox"]');
-      var fileAdded = fileRows[1].querySelector(
+      const fileAdded = fileRows[1].querySelector(
           'input.reviewed[type="checkbox"]');
-      var myFile = fileRows[2].querySelector(
+      const myFile = fileRows[2].querySelector(
           'input.reviewed[type="checkbox"]');
 
       assert.isTrue(commitMsg.checked);
@@ -565,15 +618,15 @@
       assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', true));
     });
 
-    test('patch set from revisions', function() {
-      var expected = [
+    test('patch set from revisions', () => {
+      const expected = [
         {num: 1, desc: 'test'},
         {num: 2, desc: 'test'},
         {num: 3, desc: 'test'},
         {num: 4, desc: 'test'},
       ];
-      var patchNums = element._computePatchSets({
-        base: {
+      const patchNums = element.computeAllPatchSets({
+        revisions: {
           rev3: {_number: 3, description: 'test'},
           rev1: {_number: 1, description: 'test'},
           rev4: {_number: 4, description: 'test'},
@@ -581,12 +634,12 @@
         },
       });
       assert.equal(patchNums.length, expected.length);
-      for (var i = 0; i < expected.length; i++) {
+      for (let i = 0; i < expected.length; i++) {
         assert.deepEqual(patchNums[i], expected[i]);
       }
     });
 
-    test('patch range string', function() {
+    test('patch range string', () => {
       assert.equal(
           element._patchRangeStr({basePatchNum: 'PARENT', patchNum: '1'}),
           '1');
@@ -595,23 +648,25 @@
           '1..3');
     });
 
-    test('diff against dropdown', function(done) {
-      var showStub = sandbox.stub(page, 'show');
+    test('diff against dropdown', done => {
+      const showStub = sandbox.stub(page, 'show');
       element.changeNum = '42';
       element.patchRange = {
         basePatchNum: 'PARENT',
         patchNum: '3',
       };
-      element.revisions = {
-        rev1: {_number: 1},
-        rev2: {_number: 2},
-        rev3: {_number: 3},
+      element.change = {
+        revisions: {
+          rev1: {_number: 1},
+          rev2: {_number: 2},
+          rev3: {_number: 3},
+        },
       };
-      flush(function() {
-        var selectEl = element.$.patchChange;
+      flush(() => {
+        const selectEl = element.$.patchChange;
         assert.equal(selectEl.value, 'PARENT');
         assert.isTrue(element.$$('option[value="3"]').hasAttribute('disabled'));
-        selectEl.addEventListener('change', function() {
+        selectEl.addEventListener('change', () => {
           assert.equal(selectEl.value, '2');
           assert(showStub.lastCall.calledWithExactly('/c/42/2..3'),
               'Should navigate to /c/42/2..3');
@@ -623,7 +678,7 @@
       });
     });
 
-    test('checkbox shows/hides diff inline', function() {
+    test('checkbox shows/hides diff inline', () => {
       element._files = [
         {__path: 'myfile.txt'},
       ];
@@ -635,17 +690,20 @@
       element.$.fileCursor.setCursorAtIndex(0);
       sandbox.stub(element, '_expandedPathsChanged');
       flushAsynchronousOperations();
-      var fileRows =
+      const fileRows =
           Polymer.dom(element.root).querySelectorAll('.row:not(.header)');
-      var showHideCheck = fileRows[0].querySelector(
+      // Because the label surrounds the input, the tap event is triggered
+      // there first.
+      const showHideLabel = fileRows[0].querySelector('label.show-hide');
+      const showHideCheck = fileRows[0].querySelector(
           'input.show-hide[type="checkbox"]');
       assert.isNotOk(showHideCheck.checked);
-      MockInteractions.tap(showHideCheck);
+      MockInteractions.tap(showHideLabel);
       assert.isOk(showHideCheck.checked);
       assert.notEqual(element._expandedFilePaths.indexOf('myfile.txt'), -1);
     });
 
-    test('path should be properly escaped', function() {
+    test('path should be properly escaped', () => {
       element._files = [
         {__path: 'foo bar/my+file.txt%'},
       ];
@@ -663,7 +721,7 @@
           '/c/42/2/foo+bar/my%252Bfile.txt%2525');
     });
 
-    test('diff mode correctly toggles the diffs', function() {
+    test('diff mode correctly toggles the diffs', () => {
       element._files = [
         {__path: 'myfile.txt'},
       ];
@@ -672,26 +730,35 @@
         basePatchNum: 'PARENT',
         patchNum: '2',
       };
+      sandbox.spy(element, '_updateDiffPreferences');
       element.$.fileCursor.setCursorAtIndex(0);
       flushAsynchronousOperations();
-      var diffDisplay = element.diffs[0];
+
+      // Tap on a file to generate the diff.
+      const row = Polymer.dom(element.root)
+          .querySelectorAll('.row:not(.header) label.show-hide')[0];
+
+      MockInteractions.tap(row);
+      flushAsynchronousOperations();
+      const diffDisplay = element.diffs[0];
       element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
       assert.equal(element.diffViewMode, 'SIDE_BY_SIDE');
       assert.equal(diffDisplay.viewMode, 'SIDE_BY_SIDE');
       element.set('diffViewMode', 'UNIFIED_DIFF');
       assert.equal(diffDisplay.viewMode, 'UNIFIED_DIFF');
+      assert.isTrue(element._updateDiffPreferences.called);
     });
 
-    test('diff mode selector initializes from preferences', function() {
-      var resolvePrefs;
-      var prefsPromise = new Promise(function(resolve) {
+    test('diff mode selector initializes from preferences', () => {
+      let resolvePrefs;
+      const prefsPromise = new Promise(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;
+      const view = document.createElement('gr-file-list');
+      const select = view.$.modeSelect;
       fixture('blank').appendChild(view);
       flushAsynchronousOperations();
 
@@ -705,8 +772,8 @@
       document.getElementById('blank').restore();
     });
 
-    test('show/hide diffs disabled for large amounts of files', function(done) {
-      var computeSpy = sandbox.spy(element, '_fileListActionsVisible');
+    test('show/hide diffs disabled for large amounts of files', done => {
+      const computeSpy = sandbox.spy(element, '_fileListActionsVisible');
       element._files = [];
       element.changeNum = '42';
       element.patchRange = {
@@ -714,27 +781,27 @@
         patchNum: '2',
       };
       element.$.fileCursor.setCursorAtIndex(0);
-      flush(function() {
+      flush(() => {
         assert.isTrue(computeSpy.lastCall.returnValue);
-        var arr = [];
-        _.times(element._maxFilesForBulkActions + 1, function() {
+        const arr = [];
+        _.times(element._maxFilesForBulkActions + 1, () => {
           arr.push({__path: 'myfile.txt'});
         });
         element._files = arr;
-        element._numFilesShown = arr.length;
+        element.numFilesShown = arr.length;
         assert.isFalse(computeSpy.lastCall.returnValue);
         done();
       });
     });
 
-    test('expanded attribute not set on path when not expanded', function() {
+    test('expanded attribute not set on path when not expanded', () => {
       element._files = [
         {__path: '/COMMIT_MSG'},
       ];
       assert.isNotOk(element.$$('.expanded'));
     });
 
-    test('_getDiffViewMode', function() {
+    test('_getDiffViewMode', () => {
       // No user prefs or diff view mode set.
       assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
       // User prefs but no diff view mode set.
@@ -747,7 +814,7 @@
       assert.equal(element._getDiffViewMode(
           element.diffViewMode, element._userPrefs), 'SIDE_BY_SIDE');
     });
-    test('expand_inline_diffs user preference', function() {
+    test('expand_inline_diffs user preference', () => {
       element._files = [
         {__path: '/COMMIT_MSG'},
       ];
@@ -758,30 +825,30 @@
       };
       sandbox.stub(element, '_expandedPathsChanged');
       flushAsynchronousOperations();
-      var commitMsgFile = Polymer.dom(element.root)
+      const commitMsgFile = Polymer.dom(element.root)
           .querySelectorAll('.row:not(.header) a')[0];
 
       // Remove href attribute so the app doesn't route to a diff view
       commitMsgFile.removeAttribute('href');
-      var hiddenChangeSpy = sandbox.spy(element, '_handleHiddenChange');
+      const togglePathSpy = sandbox.spy(element, '_togglePathExpanded');
 
       MockInteractions.tap(commitMsgFile);
       flushAsynchronousOperations();
-      assert(hiddenChangeSpy.notCalled, 'file is opened as diff view');
+      assert(togglePathSpy.notCalled, 'file is opened as diff view');
       assert.isNotOk(element.$$('.expanded'));
 
       element._userPrefs = {expand_inline_diffs: true};
       flushAsynchronousOperations();
       MockInteractions.tap(commitMsgFile);
       flushAsynchronousOperations();
-      assert(hiddenChangeSpy.calledOnce, 'file is expanded');
+      assert(togglePathSpy.calledOnce, 'file is expanded');
       assert.isOk(element.$$('.expanded'));
     });
 
-    test('_togglePathExpanded', function() {
-      var path = 'path/to/my/file.txt';
+    test('_togglePathExpanded', () => {
+      const path = 'path/to/my/file.txt';
       element.files = [{__path: path}];
-      var renderStub = sandbox.stub(element, '_renderInOrder')
+      const renderStub = sandbox.stub(element, '_renderInOrder')
           .returns(Promise.resolve());
 
       assert.equal(element._expandedFilePaths.length, 0);
@@ -797,111 +864,363 @@
       assert.notInclude(element._expandedFilePaths, path);
     });
 
-    test('_expandedPathsChanged', function(done) {
+    test('_expandedPathsChanged', done => {
       sandbox.stub(element, '_reviewFile');
-      var path = 'path/to/my/file.txt';
-      var diffs = [{
-        path: path,
-        reload: function() {
+      const path = 'path/to/my/file.txt';
+      const diffs = [{
+        path,
+        reload() {
           done();
         },
       }];
-      var diffsStub = sinon.stub(element, 'diffs', {
-        get: function() { return diffs; },
+      sinon.stub(element, 'diffs', {
+        get() { return diffs; },
       });
       element.push('_expandedFilePaths', path);
     });
 
-    suite('_handleFileTap', function() {
+    suite('_handleFileListTap', () => {
       function testForModifier(modifier) {
-        var e = {preventDefault: function() {}};
+        const e = {preventDefault() {}};
         e.detail = {sourceEvent: {}};
+        e.target = {
+          dataset: {path: '/test'},
+          classList: element.classList,
+        };
+
         e.detail.sourceEvent[modifier] = true;
 
-        var hiddenChangeStub = sandbox.stub(element, '_handleHiddenChange');
-        element._userPrefs = { expand_inline_diffs: true };
+        const togglePathStub = sandbox.stub(element, '_togglePathExpanded');
+        element._userPrefs = {expand_inline_diffs: true};
 
-        element._handleFileTap(e);
-        assert.isFalse(hiddenChangeStub.called);
+        element._handleFileListTap(e);
+        assert.isFalse(togglePathStub.called);
 
         e.detail.sourceEvent[modifier] = false;
-        element._handleFileTap(e);
-        assert.equal(hiddenChangeStub.callCount, 1);
+        element._handleFileListTap(e);
+        assert.equal(togglePathStub.callCount, 1);
 
-        element._userPrefs = { expand_inline_diffs: false };
-        element._handleFileTap(e);
-        assert.equal(hiddenChangeStub.callCount, 1);
+        element._userPrefs = {expand_inline_diffs: false};
+        element._handleFileListTap(e);
+        assert.equal(togglePathStub.callCount, 1);
       }
 
-      test('_handleFileTap meta', function() {
+      test('_handleFileListTap meta', () => {
         testForModifier('metaKey');
       });
 
-      test('_handleFileTap ctrl', function() {
+      test('_handleFileListTap ctrl', () => {
         testForModifier('ctrlKey');
       });
     });
 
-    test('_renderInOrder', function(done) {
-      var reviewStub = sandbox.stub(element, '_reviewFile');
-      var callCount = 0;
-      var diffs = [{
+    test('_renderInOrder', done => {
+      const reviewStub = sandbox.stub(element, '_reviewFile');
+      let callCount = 0;
+      const diffs = [{
         path: 'p0',
-        reload: function() {
+        reload() {
           assert.equal(callCount++, 2);
           return Promise.resolve();
         },
       }, {
         path: 'p1',
-        reload: function() {
+        reload() {
           assert.equal(callCount++, 1);
           return Promise.resolve();
         },
       }, {
         path: 'p2',
-        reload: function() {
+        reload() {
           assert.equal(callCount++, 0);
           return Promise.resolve();
         },
       }];
       element._renderInOrder(['p2', 'p1', 'p0'], diffs, 3)
-        .then(function() {
-          assert.isFalse(reviewStub.called);
-          done();
-        });
+          .then(() => {
+            assert.isFalse(reviewStub.called);
+            done();
+          });
     });
 
-    test('_renderInOrder logged in', function(done) {
+    test('_renderInOrder logged in', done => {
       element._isLoggedIn = true;
-      var reviewStub = sandbox.stub(element, '_reviewFile');
-      var callCount = 0;
-      var diffs = [{
+      const reviewStub = sandbox.stub(element, '_reviewFile');
+      let callCount = 0;
+      const diffs = [{
         path: 'p0',
-        reload: function() {
+        reload() {
           assert.equal(reviewStub.callCount, 2);
           assert.equal(callCount++, 2);
           return Promise.resolve();
         },
       }, {
         path: 'p1',
-        reload: function() {
+        reload() {
           assert.equal(reviewStub.callCount, 1);
           assert.equal(callCount++, 1);
           return Promise.resolve();
         },
       }, {
         path: 'p2',
-        reload: function() {
+        reload() {
           assert.equal(reviewStub.callCount, 0);
           assert.equal(callCount++, 0);
           return Promise.resolve();
         },
       }];
       element._renderInOrder(['p2', 'p1', 'p0'], diffs, 3)
-        .then(function() {
-          assert.equal(reviewStub.callCount, 3);
-          done();
-        });
+          .then(() => {
+            assert.equal(reviewStub.callCount, 3);
+            done();
+          });
+    });
+  });
+
+  suite('gr-file-list inline diff tests', () => {
+    let element;
+    let sandbox;
+
+    const setupDiff = function(diff) {
+      const mock = document.createElement('mock-diff-response');
+      diff._diff = mock.diffResponse;
+      diff._comments = {
+        left: [],
+        right: [],
+      };
+      diff.prefs = {
+        context: 10,
+        tab_size: 8,
+        font_size: 12,
+        line_length: 100,
+        cursor_blink_rate: 0,
+        line_wrapping: false,
+        intraline_difference: true,
+        show_line_endings: true,
+        show_tabs: true,
+        show_whitespace_errors: true,
+        syntax_highlighting: true,
+        auto_hide_diff_table_header: true,
+        theme: 'DEFAULT',
+        ignore_whitespace: 'IGNORE_NONE',
+      };
+      diff._renderDiffTable();
+    };
+
+    const renderAndGetNewDiffs = function(index) {
+      const diffs =
+          Polymer.dom(element.root).querySelectorAll('gr-diff');
+
+      for (let i = index; i < diffs.length; i++) {
+        setupDiff(diffs[i]);
+      }
+
+      element._updateDiffCursor();
+      element.$.diffCursor.handleDiffUpdate();
+      return diffs;
+    };
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(true); },
+        getPreferences() { return Promise.resolve({}); },
+      });
+      stub('gr-date-formatter', {
+        _loadTimeFormat() { return Promise.resolve(''); },
+      });
+      stub('gr-diff', {
+        reload() { return Promise.resolve(); },
+      });
+      element = fixture('basic');
+      element.numFilesShown = 75;
+      element.selectedIndex = 0;
+      element._files = [
+        {__path: '/COMMIT_MSG', lines_inserted: 9},
+        {
+          __path: 'file_added_in_rev2.txt',
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+        {
+          __path: 'myfile.txt',
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+      ];
+      element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
+      element._loggedIn = true;
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      sandbox.stub(window, 'fetch', () => {
+        return Promise.resolve();
+      });
+      flushAsynchronousOperations();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('cursor with individually opened files', () => {
+      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
+      flushAsynchronousOperations();
+      let diffs = renderAndGetNewDiffs(0);
+      const diffStops = diffs[0].getCursorStops();
+
+      // 1 diff should be rendered.
+      assert.equal(diffs.length, 1);
+
+      // No line number is selected.
+      assert.isFalse(diffStops[10].classList.contains('target-row'));
+
+      // Tapping content on a line selects the line number.
+      MockInteractions.tap(Polymer.dom(
+          diffStops[10]).querySelectorAll('.contentText')[0]);
+      flushAsynchronousOperations();
+      assert.isTrue(diffStops[10].classList.contains('target-row'));
+
+      // Keyboard shortcuts are still moving the file cursor, not the diff
+      // cursor.
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      flushAsynchronousOperations();
+      assert.isTrue(diffStops[10].classList.contains('target-row'));
+      assert.isFalse(diffStops[11].classList.contains('target-row'));
+
+      // The file cusor is now at 1.
+      assert.equal(element.$.fileCursor.index, 1);
+      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
+      flushAsynchronousOperations();
+
+      diffs = renderAndGetNewDiffs(1);
+      // Two diffs should be rendered.
+      assert.equal(diffs.length, 2);
+      const diffStopsFirst = diffs[0].getCursorStops();
+      const diffStopsSecond = diffs[1].getCursorStops();
+
+      // The line on the first diff is stil selected
+      assert.isTrue(diffStopsFirst[10].classList.contains('target-row'));
+      assert.isFalse(diffStopsSecond[10].classList.contains('target-row'));
+    });
+
+    test('cursor with toggle all files', () => {
+      MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'i');
+      flushAsynchronousOperations();
+
+      const diffs = renderAndGetNewDiffs(0);
+      const diffStops = diffs[0].getCursorStops();
+
+      // 1 diff should be rendered.
+      assert.equal(diffs.length, 3);
+
+      // No line number is selected.
+      assert.isFalse(diffStops[10].classList.contains('target-row'));
+
+      // Tapping content on a line selects the line number.
+      MockInteractions.tap(Polymer.dom(
+          diffStops[10]).querySelectorAll('.contentText')[0]);
+      flushAsynchronousOperations();
+      assert.isTrue(diffStops[10].classList.contains('target-row'));
+
+      // Keyboard shortcuts are still moving the file cursor, not the diff
+      // cursor.
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      flushAsynchronousOperations();
+      assert.isFalse(diffStops[10].classList.contains('target-row'));
+      assert.isTrue(diffStops[11].classList.contains('target-row'));
+
+      // The file cusor is still at 0.
+      assert.equal(element.$.fileCursor.index, 0);
+    });
+
+    suite('n key presses', () => {
+      let nKeySpy;
+      let nextCommentStub;
+      let nextChunkStub;
+      let fileRows;
+      setup(() => {
+        nKeySpy = sandbox.spy(element, '_handleNKey');
+        nextCommentStub = sandbox.stub(element.$.diffCursor,
+            'moveToNextCommentThread');
+        nextChunkStub = sandbox.stub(element.$.diffCursor,
+            'moveToNextChunk');
+        fileRows =
+            Polymer.dom(element.root).querySelectorAll('.row:not(.header)');
+      });
+      test('n key with all files expanded and no shift key', () => {
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'i');
+        flushAsynchronousOperations();
+
+        // Handle N key should return before calling diff cursor functions.
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+        assert.isTrue(nKeySpy.called);
+        assert.isFalse(nextCommentStub.called);
+
+        // This is also called in diffCursor.moveToFirstChunk.
+        assert.equal(nextChunkStub.callCount, 1);
+        assert.isFalse(!!element._showInlineDiffs);
+      });
+
+      test('n key with all files expanded and shift key', () => {
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'i');
+        flushAsynchronousOperations();
+
+        MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
+        assert.isTrue(nKeySpy.called);
+        assert.isFalse(nextCommentStub.called);
+
+        // This is also called in diffCursor.moveToFirstChunk.
+        assert.equal(nextChunkStub.callCount, 1);
+        assert.isFalse(!!element._showInlineDiffs);
+      });
+
+      test('n key without all files expanded and shift key', () => {
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, 'shift', 'i');
+        flushAsynchronousOperations();
+
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+        assert.isTrue(nKeySpy.called);
+        assert.isFalse(nextCommentStub.called);
+
+        // This is also called in diffCursor.moveToFirstChunk.
+        assert.equal(nextChunkStub.callCount, 2);
+        assert.isTrue(element._showInlineDiffs);
+      });
+
+      test('n key without all files expanded and no shift key', () => {
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, 'shift', 'i');
+        flushAsynchronousOperations();
+
+        MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
+        assert.isTrue(nKeySpy.called);
+        assert.isTrue(nextCommentStub.called);
+
+        // This is also called in diffCursor.moveToFirstChunk.
+        assert.equal(nextChunkStub.callCount, 1);
+        assert.isTrue(element._showInlineDiffs);
+      });
+    });
+
+    test('_openSelectedFile behavior', () => {
+      const _files = element._files;
+      element.set('_files', []);
+      const showStub = sandbox.stub(page, 'show');
+      // Noop when there are no files.
+      element._openSelectedFile();
+      assert.isFalse(showStub.called);
+
+      element.set('_files', _files);
+      flushAsynchronousOperations();
+       // Navigates when a file is selected.
+      element._openSelectedFile();
+      assert.isTrue(showStub.called);
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html
new file mode 100644
index 0000000..7e481e7
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html
@@ -0,0 +1,116 @@
+<!--
+Copyright (C) 2017 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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-selector/iron-selector.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-label-scores">
+  <template>
+    <style>
+      .labelContainer {
+        margin-bottom: .5em;
+      }
+      .labelContainer:last-child {
+        margin-bottom: 0;
+      }
+      .labelName {
+        display: inline-block;
+        margin-right: .5em;
+        min-width: 7em;
+        text-align: right;
+        white-space: nowrap;
+        width: 25%;
+      }
+      .labelMessage {
+        color: #666;
+      }
+      .mergedMessage {
+        font-style: italic;
+        text-align: center;
+        width: 100%;
+      }
+      .placeholder::before {
+        content: ' ';
+      }
+      iron-selector > gr-button:first-of-type {
+        border-bottom-left-radius: 2px;
+        border-top-left-radius: 2px;
+      }
+      iron-selector > gr-button:last-of-type {
+        border-bottom-right-radius: 2px;
+        border-top-right-radius: 2px;
+      }
+      iron-selector > gr-button.iron-selected {
+        background-color: #ddd;
+      }
+      gr-button {
+        min-width: 40px;
+      }
+      .placeholder {
+        display: inline-block;
+        width: 40px;
+      }
+      @media only screen and (max-width: 25em) {
+        :host {
+          text-align: center;
+        }
+        .labelName {
+          margin: 0;
+          text-align: center;
+          width: 100%;
+        }
+      }
+    </style>
+    <template is="dom-repeat" items="[[_labels]]" as="label">
+      <div class="labelContainer">
+        <span class="labelName">[[label.name]]</span>
+        <span id="spaces[[index]]"></span>
+        <template is="dom-repeat"
+            items="[[_computeBlankItems(permittedLabels, label.name, 'start')]]"
+            as="value">
+          <span class="placeholder" data-label$="[[label.name]]"></span>
+        </template>
+        <iron-selector data-label$="[[label.name]]"
+            selected="[[_computeIndexOfLabelValue(change.labels, permittedLabels, label)]]"
+            hidden$="[[!_computeAnyPermittedLabelValues(permittedLabels, label.name)]]">
+          <template is="dom-repeat"
+              items="[[_computePermittedLabelValues(permittedLabels, label.name)]]"
+              as="value">
+            <gr-button has-tooltip data-value$="[[value]]"
+              title$="[[_computeLabelValueTitle(change.labels, label.name, value)]]">
+            [[value]]</gr-button>
+          </template>
+        </iron-selector>
+        <template is="dom-repeat"
+            items="[[_computeBlankItems(permittedLabels, label.name, 'end')]]"
+            as="value">
+          <span class="placeholder" data-label$="[[label.name]]"></span>
+        </template>
+        <span class="labelMessage"
+            hidden$="[[_computeAnyPermittedLabelValues(permittedLabels, label.name)]]">
+          You don't have permission to edit this label.
+        </span>
+      </div>
+    </template>
+    <div class="mergedMessage"
+        hidden$="[[!_changeIsMerged(change.status)]]">
+      Because this change has been merged, votes may not be decreased.
+    </div>
+  </template>
+  <script src="gr-label-scores.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js
new file mode 100644
index 0000000..f029723
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js
@@ -0,0 +1,141 @@
+// Copyright (C) 2017 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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-label-scores',
+    properties: {
+      _labels: {
+        type: Array,
+        computed: '_computeLabels(change.labels.*, account)',
+      },
+      permittedLabels: {
+        type: Object,
+        observer: '_computeColumns',
+      },
+      change: Object,
+      _labelValues: Object,
+    },
+
+    getLabelValues() {
+      const labels = {};
+      for (const label in this.permittedLabels) {
+        if (!this.permittedLabels.hasOwnProperty(label)) { continue; }
+
+        const selectorEl = this.$$(`iron-selector[data-label="${label}"]`);
+        // The user may have not voted on this label.
+        if (!selectorEl || !selectorEl.selectedItem) { continue; }
+
+        let selectedVal = selectorEl.selectedItem.getAttribute('data-value');
+        selectedVal = parseInt(selectedVal, 10);
+
+        // Only send the selection if the user changed it.
+        let prevVal = this._getVoteForAccount(this.change.labels, label,
+            this.account);
+        if (prevVal !== null) {
+          prevVal = parseInt(prevVal, 10);
+        }
+        if (selectedVal !== prevVal) {
+          labels[label] = selectedVal;
+        }
+      }
+      return labels;
+    },
+
+    _getVoteForAccount(labels, labelName, account) {
+      const votes = labels[labelName];
+      if (votes.all && votes.all.length > 0) {
+        for (let i = 0; i < votes.all.length; i++) {
+          if (votes.all[i]._account_id == account._account_id) {
+            return votes.all[i].value;
+          }
+        }
+      }
+      return null;
+    },
+
+    _computeLabels(labelRecord) {
+      const labelsObj = labelRecord.base;
+      if (!labelsObj) { return []; }
+      return Object.keys(labelsObj).sort().map(key => {
+        return {
+          name: key,
+          value: this._getVoteForAccount(labelsObj, key, this.account),
+        };
+      });
+    },
+
+    _computeColumns(permittedLabels) {
+      const labels = Object.keys(permittedLabels);
+      const values = {};
+      for (const label of labels) {
+        for (const value of permittedLabels[label]) {
+          values[parseInt(value, 10)] = true;
+        }
+      }
+
+      const orderedValues = Object.keys(values).sort((a, b) => {
+        return a - b;
+      });
+
+      for (let i = 0; i < orderedValues.length; i++) {
+        values[orderedValues[i]] = i;
+      }
+      this._labelValues = values;
+    },
+
+    _computeIndexOfLabelValue(labels, permittedLabels, label) {
+      if (!labels[label.name]) { return null; }
+      const labelValue = label.value;
+      const len = permittedLabels[label.name] != null ?
+          permittedLabels[label.name].length : 0;
+      for (let i = 0; i < len; i++) {
+        const val = parseInt(permittedLabels[label.name][i], 10);
+        if (val == labelValue) {
+          return i;
+        }
+      }
+      return null;
+    },
+
+    _computePermittedLabelValues(permittedLabels, label) {
+      return permittedLabels[label];
+    },
+
+    _computeBlankItems(permittedLabels, label, side) {
+      if (!permittedLabels[label]) { return []; }
+      const startPosition = this._labelValues[parseInt(
+          permittedLabels[label][0])];
+      if (side === 'start') {
+        return new Array(startPosition);
+      }
+      const endPosition = this._labelValues[parseInt(
+          permittedLabels[label][permittedLabels[label].length - 1])];
+      return new Array(Object.keys(this._labelValues).length - endPosition - 1);
+    },
+
+    _computeAnyPermittedLabelValues(permittedLabels, label) {
+      return permittedLabels.hasOwnProperty(label);
+    },
+
+    _changeIsMerged(changeStatus) {
+      return changeStatus === 'MERGED';
+    },
+
+    _computeLabelValueTitle(labels, label, value) {
+      return labels[label] && labels[label].values[value];
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html
new file mode 100644
index 0000000..accdb35
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html
@@ -0,0 +1,282 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-label-scores</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-label-scores.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-label-scores></gr-label-scores>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-label-scores tests', () => {
+    let element;
+    let sandbox;
+
+    setup(done => {
+      sandbox = sinon.sandbox.create();
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(false); },
+      });
+      element = fixture('basic');
+      element.change = {
+        _number: '123',
+        labels: {
+          'Code-Review': {
+            values: {
+              '0': 'No score',
+              '+1': 'good',
+              '+2': 'excellent',
+              '-1': 'bad',
+              '-2': 'terrible',
+            },
+            default_value: 0,
+            value: 1,
+            all: [{
+              _account_id: 123,
+              value: '+1',
+            }],
+          },
+          'Verified': {
+            values: {
+              '0': 'No score',
+              '+1': 'good',
+              '+2': 'excellent',
+              '-1': 'bad',
+              '-2': 'terrible',
+            },
+            default_value: 0,
+            value: 1,
+            all: [{
+              _account_id: 123,
+              value: '+1',
+            }],
+          },
+        },
+      };
+
+      element.account = {
+        _account_id: 123,
+      };
+
+      element.permittedLabels = {
+        'Code-Review': [
+          '-2',
+          '-1',
+          ' 0',
+          '+1',
+          '+2',
+        ],
+        'Verified': [
+          '-1',
+          ' 0',
+          '+1',
+        ],
+      };
+      flush(done);
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('label picker', () => {
+      for (const label in element.permittedLabels) {
+        if (element.permittedLabels.hasOwnProperty(label)) {
+          assert.ok(element.$$('iron-selector[data-label="' + label + '"]'),
+              label);
+        }
+      }
+      element.draft = 'I wholeheartedly disapprove';
+      MockInteractions.tap(element.$$(
+          'iron-selector[data-label="Code-Review"] > ' +
+          'gr-button[data-value="-1"]'));
+      MockInteractions.tap(element.$$(
+          'iron-selector[data-label="Verified"] > ' +
+          'gr-button[data-value="-1"]'));
+      flushAsynchronousOperations();
+      assert.deepEqual(element.getLabelValues(), {
+        'Code-Review': -1,
+        'Verified': -1,
+      });
+      assert.equal(element.$$('iron-selector[data-label="Code-Review"]')
+          .selected, 1);
+      assert.equal(element.$$(
+          'iron-selector[data-label="Verified"] .iron-selected')
+          .textContent.trim(), '-1');
+    });
+
+    test('correct item is selected', () => {
+      assert.equal(element.$$('iron-selector[data-label="Code-Review"]')
+          .selected, 3);
+      assert.equal(
+          element.$$('iron-selector[data-label="Code-Review"] .iron-selected')
+              .textContent.trim(), '+1');
+
+      // +1 is in the third position (-1, 0, +1).
+      assert.equal(element.$$('iron-selector[data-label="Verified"]')
+          .selected, 2);
+      assert.equal(element.$$(
+          'iron-selector[data-label="Verified"] .iron-selected')
+          .textContent.trim(), '+1');
+    });
+
+    test('do not display tooltips on touch devices', () => {
+      const verifiedBtn = element.$$(
+          'iron-selector[data-label="Verified"] > gr-button[data-value="-1"]');
+
+      // On touch devices, tooltips should not be shown.
+      verifiedBtn._isTouchDevice = true;
+      verifiedBtn._handleShowTooltip();
+      assert.isNotOk(verifiedBtn._tooltip);
+      verifiedBtn._handleHideTooltip();
+      assert.isNotOk(verifiedBtn._tooltip);
+
+      // On other devices, tooltips should be shown.
+      verifiedBtn._isTouchDevice = false;
+      verifiedBtn._handleShowTooltip();
+      assert.isOk(verifiedBtn._tooltip);
+      verifiedBtn._handleHideTooltip();
+      assert.isNotOk(verifiedBtn._tooltip);
+    });
+
+    test('_getVoteForAccount', () => {
+      const labelName = 'Code-Review';
+      assert.equal(element._getVoteForAccount(element.change.labels, labelName,
+          element.account), '+1');
+    });
+
+    test('_computeColumns', () => {
+      element._computeColumns(element.permittedLabels);
+      assert.deepEqual(element._labelValues, {
+        '-2': 0,
+        '-1': 1,
+        '0': 2,
+        '1': 3,
+        '2': 4,
+      });
+    });
+
+    test('_computeIndexOfLabelValue', () => {
+      assert.equal(element._computeIndexOfLabelValue(element.change.labels,
+          element.permittedLabels,
+          element._labels[0]), 3);
+    });
+
+    test('_computeBlankItems', () => {
+      element._labelValues = {
+        '-2': 0,
+        '-1': 1,
+        '0': 2,
+        '1': 3,
+        '2': 4,
+      };
+
+      assert.equal(element._computeBlankItems(element.permittedLabels,
+          'Code-Review').length, 0);
+
+      assert.deepEqual(
+          element._computeBlankItems(element.permittedLabels,
+              'Verified').length, 1);
+    });
+
+    test('changes in label score are reflected in the DOM', () => {
+      element.change = {
+        _number: '123',
+        labels: {
+          'Code-Review': {
+            values: {
+              '0': 'No score',
+              '+1': 'good',
+              '+2': 'excellent',
+              '-1': 'bad',
+              '-2': 'terrible',
+            },
+            default_value: 0,
+          },
+          'Verified': {
+            values: {
+              '0': 'No score',
+              '+1': 'good',
+              '+2': 'excellent',
+              '-1': 'bad',
+              '-2': 'terrible',
+            },
+            default_value: 0,
+          },
+        },
+      };
+      flushAsynchronousOperations();
+      const selector = element.$$('iron-selector[data-label="Verified"]');
+      element.set(['change', 'labels', 'Verified', 'all'],
+         [{_account_id: 123, value: 1}]);
+      flushAsynchronousOperations();
+      assert.equal(selector.selected, 2); // Index 2, value 1
+    });
+
+    test('without permitted labels', () => {
+      element.permittedLabels = {
+        Verified: [
+          '-1',
+          ' 0',
+          '+1',
+        ],
+      };
+      flushAsynchronousOperations();
+      assert.isOk(element.$$('iron-selector[data-label="Verified"]'));
+      assert.isFalse(element.$$('iron-selector[data-label="Verified"]').hidden);
+      assert.isOk(element.$$('iron-selector[data-label="Code-Review"]'));
+      assert.isTrue(
+          element.$$('iron-selector[data-label="Code-Review"]').hidden);
+    });
+
+    test('asymetrical labels', () => {
+      element.permittedLabels = {
+        'Code-Review': [
+          '-2',
+          '-1',
+          ' 0',
+          '+1',
+          '+2',
+        ],
+        'Verified': [
+          ' 0',
+          '+1',
+        ],
+      };
+      flushAsynchronousOperations();
+      assert.equal(element.$$('iron-selector[data-label="Verified"]')
+          .items.length, 2);
+      assert.equal(Polymer.dom(element.root).
+          querySelectorAll('.placeholder[data-label="Verified"]').length, 3);
+      assert.equal(element.$$('iron-selector[data-label="Code-Review"]')
+          .items.length, 5);
+      assert.equal(Polymer.dom(element.root).
+          querySelectorAll('.placeholder[data-label="Code-Review"]').length, 0);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.html b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
index 831914e..6cd7fad 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
@@ -75,14 +75,14 @@
         font-weight: bold;
       }
       .message {
-        max-width: 80ch;
+        --gr-formatted-text-prose-max-width: 80ch;
       }
       .collapsed .message {
         max-width: none;
         overflow: hidden;
         text-overflow: ellipsis;
       }
-      .collapsed .name,
+      .collapsed .author,
       .collapsed .content,
       .collapsed .message,
       .collapsed .updateCategory,
@@ -108,11 +108,11 @@
       .collapsed .date {
         position: static;
       }
-      .collapsed .name {
+      .collapsed .author {
         color: var(--default-text-color);
         margin-right: .4em;
       }
-      .expanded .name {
+      .expanded .author {
         cursor: pointer;
       }
       .date {
@@ -128,7 +128,13 @@
     <div class$="[[_computeClass(_expanded, showAvatar)]]">
       <gr-avatar account="[[author]]" image-size="100"></gr-avatar>
       <div class="contentContainer">
-        <div class="name" on-tap="_handleNameTap">[[author.name]]</div>
+        <div class="author" on-tap="_handleAuthorTap">
+          <span hidden$="[[!showOnBehalfOf]]">
+            <span class="name">[[message.real_author.name]]</span>
+            on behalf of
+          </span>
+          <span class="name">[[author.name]]</span>
+        </div>
         <template is="dom-if" if="[[message.message]]">
           <div class="content">
             <div class="message hideOnOpen">[[message.message]]</div>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.js b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
index e782943..919ef6b 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
@@ -30,7 +30,7 @@
      */
 
     listeners: {
-      'tap': '_handleTap',
+      tap: '_handleTap',
     },
 
     properties: {
@@ -62,6 +62,10 @@
         type: Boolean,
         computed: '_computeShowAvatar(author, config)',
       },
+      showOnBehalfOf: {
+        type: Boolean,
+        computed: '_computeShowOnBehalfOf(message)',
+      },
       showReplyButton: {
         type: Boolean,
         computed: '_computeShowReplyButton(message, _loggedIn)',
@@ -82,16 +86,16 @@
       '_updateExpandedClass(message.expanded)',
     ],
 
-    ready: function() {
-      this.$.restAPI.getConfig().then(function(config) {
+    ready() {
+      this.$.restAPI.getConfig().then(config => {
         this.config = config;
-      }.bind(this));
-      this.$.restAPI.getLoggedIn().then(function(loggedIn) {
+      });
+      this.$.restAPI.getLoggedIn().then(loggedIn => {
         this._loggedIn = loggedIn;
-      }.bind(this));
+      });
     },
 
-    _updateExpandedClass: function(expanded) {
+    _updateExpandedClass(expanded) {
       if (expanded) {
         this.classList.add('expanded');
       } else {
@@ -99,19 +103,25 @@
       }
     },
 
-    _computeAuthor: function(message) {
+    _computeAuthor(message) {
       return message.author || message.updated_by;
     },
 
-    _computeShowAvatar: function(author, config) {
+    _computeShowAvatar(author, config) {
       return !!(author && config && config.plugin && config.plugin.has_avatars);
     },
 
-    _computeShowReplyButton: function(message, loggedIn) {
+    _computeShowOnBehalfOf(message) {
+      const author = message.author || message.updated_by;
+      return !!(author && message.real_author &&
+          author._account_id != message.real_author._account_id);
+    },
+
+    _computeShowReplyButton(message, loggedIn) {
       return !!message.message && loggedIn;
     },
 
-    _computeExpanded: function(expanded) {
+    _computeExpanded(expanded) {
       return expanded;
     },
 
@@ -120,54 +130,54 @@
      * should be true or not, then _expanded is set to true if there are
      * inline comments (otherwise false).
      */
-    _commentsChanged: function(value) {
+    _commentsChanged(value) {
       if (this.message && this.message.expanded === undefined) {
         this.set('message.expanded', Object.keys(value || {}).length > 0);
       }
     },
 
-    _handleTap: function(e) {
+    _handleTap(e) {
       if (this.message.expanded) { return; }
       e.stopPropagation();
       this.set('message.expanded', true);
     },
 
-    _handleNameTap: function(e) {
+    _handleAuthorTap(e) {
       if (!this.message.expanded) { return; }
       e.stopPropagation();
       this.set('message.expanded', false);
     },
 
-    _computeIsAutomated: function(message) {
+    _computeIsAutomated(message) {
       return !!(message.reviewer ||
-          (message.tag && message.tag.indexOf('autogenerated') === 0));
+          (message.tag && message.tag.startsWith('autogenerated')));
     },
 
-    _computeIsHidden: function(hideAutomated, isAutomated) {
+    _computeIsHidden(hideAutomated, isAutomated) {
       return hideAutomated && isAutomated;
     },
 
-    _computeIsReviewerUpdate: function(event) {
+    _computeIsReviewerUpdate(event) {
       return event.type === 'REVIEWER_UPDATE';
     },
 
-    _computeClass: function(expanded, showAvatar) {
-      var classes = [];
+    _computeClass(expanded, showAvatar) {
+      const classes = [];
       classes.push(expanded ? 'expanded' : 'collapsed');
       classes.push(showAvatar ? 'showAvatar' : 'hideAvatar');
       return classes.join(' ');
     },
 
-    _computeMessageHash: function(message) {
+    _computeMessageHash(message) {
       return '#message-' + message.id;
     },
 
-    _handleLinkTap: function(e) {
+    _handleLinkTap(e) {
       e.preventDefault();
 
       this.fire('scroll-to', {message: this.message}, {bubbles: false});
 
-      var hash = this._computeMessageHash(this.message);
+      const hash = this._computeMessageHash(this.message);
       // Don't add the hash to the window history if it's already there.
       // Otherwise you mess up expected back button behavior.
       if (window.location.hash == hash) { return; }
@@ -176,7 +186,7 @@
       page.show(window.location.pathname + hash, null, false);
     },
 
-    _handleReplyTap: function(e) {
+    _handleReplyTap(e) {
       e.preventDefault();
       this.fire('reply', {message: this.message});
     },
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
index 89c7173..5f8b312 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
@@ -33,30 +33,30 @@
 </test-fixture>
 
 <script>
-  suite('gr-message tests', function() {
-    var element;
+  suite('gr-message tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getLoggedIn: function() { return Promise.resolve(false); },
+        getLoggedIn() { return Promise.resolve(false); },
       });
       element = fixture('basic');
     });
 
-    test('reply event', function(done) {
+    test('reply event', done => {
       element.message = {
-        'id': '47c43261_55aa2c41',
-        'author': {
-          '_account_id': 1115495,
-          'name': 'Andrew Bonventre',
-          'email': 'andybons@chromium.org',
+        id: '47c43261_55aa2c41',
+        author: {
+          _account_id: 1115495,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org',
         },
-        'date': '2016-01-12 20:24:49.448000000',
-        'message': 'Uploaded patch set 1.',
-        '_revision_number': 1
+        date: '2016-01-12 20:24:49.448000000',
+        message: 'Uploaded patch set 1.',
+        _revision_number: 1,
       };
 
-      element.addEventListener('reply', function(e) {
+      element.addEventListener('reply', e => {
         assert.deepEqual(e.detail.message, element.message);
         done();
       });
@@ -64,38 +64,38 @@
       MockInteractions.tap(element.$$('.replyContainer gr-button'));
     });
 
-    test('reviewer update', function() {
-      var author = {
+    test('reviewer update', () => {
+      const author = {
         _account_id: 1115495,
         name: 'Andrew Bonventre',
         email: 'andybons@chromium.org',
       };
-      var reviewer = {
+      const reviewer = {
         _account_id: 123456,
         name: 'Foo Bar',
         email: 'barbar@chromium.org',
       };
       element.message = {
         id: 0xDEADBEEF,
-        author: author,
-        reviewer: reviewer,
+        author,
+        reviewer,
         date: '2016-01-12 20:24:49.448000000',
         type: 'REVIEWER_UPDATE',
         updates: [
           {
             message: 'Added to CC:',
             reviewers: [reviewer],
-          }
+          },
         ],
       };
       flushAsynchronousOperations();
-      var content = element.$$('.contentContainer');
+      const content = element.$$('.contentContainer');
       assert.isOk(content);
       assert.strictEqual(element.$$('gr-account-chip').account, reviewer);
-      assert.equal(author.name, element.$$('.name').textContent);
+      assert.equal(author.name, element.$$('.author > .name').textContent);
     });
 
-    test('autogenerated prefix hiding', function() {
+    test('autogenerated prefix hiding', () => {
       element.message = {
         tag: 'autogenerated:gerrit:test',
         updated: '2016-01-12 20:24:49.448000000',
@@ -109,7 +109,7 @@
       assert.isTrue(element.hidden);
     });
 
-    test('reviewer message treated as autogenerated', function() {
+    test('reviewer message treated as autogenerated', () => {
       element.message = {
         tag: 'autogenerated:gerrit:test',
         updated: '2016-01-12 20:24:49.448000000',
@@ -124,7 +124,7 @@
       assert.isTrue(element.hidden);
     });
 
-    test('tag that is not autogenerated prefix does not hide', function() {
+    test('tag that is not autogenerated prefix does not hide', () => {
       element.message = {
         tag: 'something',
         updated: '2016-01-12 20:24:49.448000000',
@@ -138,12 +138,30 @@
       assert.isFalse(element.hidden);
     });
 
-    test('reply button hidden unless logged in', function() {
-      var message = {
-        'message': 'Uploaded patch set 1.',
+    test('reply button hidden unless logged in', () => {
+      const message = {
+        message: 'Uploaded patch set 1.',
       };
       assert.isFalse(element._computeShowReplyButton(message, false));
       assert.isTrue(element._computeShowReplyButton(message, true));
     });
+
+    test('_computeShowOnBehalfOf', () => {
+      const message = {
+        message: '...',
+      };
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      message.author = {_account_id: 1115495};
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      message.real_author = {_account_id: 1115495};
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      message.real_author._account_id = 123456;
+      assert.isOk(element._computeShowOnBehalfOf(message));
+      message.updated_by = message.author;
+      delete message.author;
+      assert.isOk(element._computeShowOnBehalfOf(message));
+      delete message.updated_by;
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
index 14361f4..4449131 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
@@ -77,11 +77,14 @@
       <gr-button id="oldMessagesBtn" link on-tap="_handleShowAllTap">
           [[_computeNumMessagesText(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]
       </gr-button>
-      /
-      <gr-button id="incrementMessagesBtn" link
-          on-tap="_handleIncrementShownMessages">
-        [[_computeIncrementText(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]
-      </gr-button>
+      <span
+          hidden$="[[_computeIncrementHidden(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]">
+        /
+        <gr-button id="incrementMessagesBtn" link
+            on-tap="_handleIncrementShownMessages">
+          [[_computeIncrementText(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]
+        </gr-button>
+      </span>
     </span>
     <template
         is="dom-repeat"
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
index 0d58d96..ebdb4dc 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
@@ -14,10 +14,10 @@
 (function() {
   'use strict';
 
-  var MAX_INITIAL_SHOWN_MESSAGES = 20;
-  var MESSAGES_INCREMENT = 5;
+  const MAX_INITIAL_SHOWN_MESSAGES = 20;
+  const MESSAGES_INCREMENT = 5;
 
-  var ReportingEvent = {
+  const ReportingEvent = {
     SHOW_ALL: 'show-all-messages',
     SHOW_MORE: 'show-more-messages',
   };
@@ -29,11 +29,11 @@
       changeNum: Number,
       messages: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       reviewerUpdates: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       comments: Object,
       projectConfig: Object,
@@ -64,34 +64,35 @@
        */
       _visibleMessages: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
     },
 
-    scrollToMessage: function(messageID) {
-      var el = this.$$('[data-message-id="' + messageID + '"]');
+    scrollToMessage(messageID) {
+      let el = this.$$('[data-message-id="' + messageID + '"]');
       // If the message is hidden, expand the hidden messages back to that
       // point.
       if (!el) {
-        for (var index = 0; index < this._processedMessages.length; index++) {
+        let index;
+        for (index = 0; index < this._processedMessages.length; index++) {
           if (this._processedMessages[index].id === messageID) {
             break;
           }
         }
         if (index === this._processedMessages.length) { return; }
 
-        var newMessages = this._processedMessages.slice(index,
+        const newMessages = this._processedMessages.slice(index,
             -this._visibleMessages.length);
         // Add newMessages to the beginning of _visibleMessages.
-        this.splice.apply(this, ['_visibleMessages', 0, 0].concat(newMessages));
+        this.splice(...['_visibleMessages', 0, 0].concat(newMessages));
         // Allow the dom-repeat to stamp.
         Polymer.dom.flush();
         el = this.$$('[data-message-id="' + messageID + '"]');
       }
 
       el.set('message.expanded', true);
-      var top = el.offsetTop;
-      for (var offsetParent = el.offsetParent;
+      let top = el.offsetTop;
+      for (let offsetParent = el.offsetParent;
            offsetParent;
            offsetParent = offsetParent.offsetParent) {
         top += offsetParent.offsetTop;
@@ -100,20 +101,20 @@
       this._highlightEl(el);
     },
 
-    _isAutomated: function(message) {
+    _isAutomated(message) {
       return !!(message.reviewer ||
-          (message.tag && message.tag.indexOf('autogenerated') === 0));
+          (message.tag && message.tag.startsWith('autogenerated')));
     },
 
-    _computeItems: function(messages, reviewerUpdates) {
+    _computeItems(messages, reviewerUpdates) {
       messages = messages || [];
       reviewerUpdates = reviewerUpdates || [];
-      var mi = 0;
-      var ri = 0;
-      var result = [];
-      var mDate;
-      var rDate;
-      for (var i = 0; i < messages.length; i++) {
+      let mi = 0;
+      let ri = 0;
+      let result = [];
+      let mDate;
+      let rDate;
+      for (let i = 0; i < messages.length; i++) {
         messages[i]._index = i;
       }
 
@@ -139,8 +140,8 @@
       return result;
     },
 
-    _expandedChanged: function(exp) {
-      for (var i = 0; i < this._processedMessages.length; i++) {
+    _expandedChanged(exp) {
+      for (let i = 0; i < this._processedMessages.length; i++) {
         this._processedMessages[i].expanded = exp;
         if (i < this._visibleMessages.length) {
           this.set(['_visibleMessages', i, 'expanded'], exp);
@@ -148,11 +149,11 @@
       }
     },
 
-    _highlightEl: function(el) {
-      var highlightedEls =
+    _highlightEl(el) {
+      const highlightedEls =
           Polymer.dom(this.root).querySelectorAll('.highlighted');
-      for (var i = 0; i < highlightedEls.length; i++) {
-        highlightedEls[i].classList.remove('highlighted');
+      for (const highlighedEl of highlightedEls) {
+        highlighedEl.classList.remove('highlighted');
       }
       function handleAnimationEnd() {
         el.removeEventListener('animationend', handleAnimationEnd);
@@ -165,39 +166,40 @@
     /**
      * @param {boolean} expand
      */
-    handleExpandCollapse: function(expand) {
+    handleExpandCollapse(expand) {
       this._expanded = expand;
     },
 
-    _handleExpandCollapseTap: function(e) {
+    _handleExpandCollapseTap(e) {
       e.preventDefault();
       this.handleExpandCollapse(!this._expanded);
     },
 
-    _handleAutomatedMessageToggleTap: function(e) {
+    _handleAutomatedMessageToggleTap(e) {
       e.preventDefault();
 
       this._hideAutomated = !this._hideAutomated;
     },
 
-    _handleScrollTo: function(e) {
+    _handleScrollTo(e) {
       this.scrollToMessage(e.detail.message.id);
     },
 
-    _hasAutomatedMessages: function(messages) {
-      for (var i = 0; messages && i < messages.length; i++) {
-        if (this._isAutomated(messages[i])) {
+    _hasAutomatedMessages(messages) {
+      if (!messages) { return false; }
+      for (const message of messages) {
+        if (this._isAutomated(message)) {
           return true;
         }
       }
       return false;
     },
 
-    _computeExpandCollapseMessage: function(expanded) {
+    _computeExpandCollapseMessage(expanded) {
       return expanded ? 'Collapse all' : 'Expand all';
     },
 
-    _computeAutomatedToggleText: function(hideAutomated) {
+    _computeAutomatedToggleText(hideAutomated) {
       return hideAutomated ? 'Show all messages' : 'Show comments only';
     },
 
@@ -209,18 +211,18 @@
      * @param {!Object} message
      * @return {!Object} Hash of arrays of comments, filename as key.
      */
-    _computeCommentsForMessage: function(comments, message) {
+    _computeCommentsForMessage(comments, message) {
       if (message._index === undefined || !comments || !this.messages) {
         return [];
       }
-      var messages = this.messages || [];
-      var index = message._index;
-      var authorId = message.author && message.author._account_id;
-      var mDate = util.parseDate(message.date).getTime();
+      const messages = this.messages || [];
+      const index = message._index;
+      const authorId = message.author && message.author._account_id;
+      const mDate = util.parseDate(message.date).getTime();
       // NB: Messages array has oldest messages first.
-      var nextMDate;
+      let nextMDate;
       if (index > 0) {
-        for (var i = index - 1; i >= 0; i--) {
+        for (let i = index - 1; i >= 0; i--) {
           if (messages[i] && messages[i].author &&
               messages[i].author._account_id === authorId) {
             nextMDate = util.parseDate(messages[i].date).getTime();
@@ -228,15 +230,16 @@
           }
         }
       }
-      var msgComments = {};
-      for (var file in comments) {
-        var fileComments = comments[file];
-        for (var i = 0; i < fileComments.length; i++) {
+      const msgComments = {};
+      for (const file in comments) {
+        if (!comments.hasOwnProperty(file)) { continue; }
+        const fileComments = comments[file];
+        for (let i = 0; i < fileComments.length; i++) {
           if (fileComments[i].author &&
               fileComments[i].author._account_id !== authorId) {
             continue;
           }
-          var cDate = util.parseDate(fileComments[i].updated).getTime();
+          const cDate = util.parseDate(fileComments[i].updated).getTime();
           if (cDate <= mDate) {
             if (nextMDate && cDate <= nextMDate) {
               continue;
@@ -255,12 +258,12 @@
      * remaining in the list and the number of messages needed to display five
      * more visible messages in the list.
      */
-    _getDelta: function(visibleMessages, messages, hideAutomated) {
-      var delta = MESSAGES_INCREMENT;
-      var msgsRemaining = messages.length - visibleMessages.length;
+    _getDelta(visibleMessages, messages, hideAutomated) {
+      let delta = MESSAGES_INCREMENT;
+      const msgsRemaining = messages.length - visibleMessages.length;
       if (hideAutomated) {
-        var counter = 0;
-        var i;
+        let counter = 0;
+        let i;
         for (i = msgsRemaining; i > 0 && counter < MESSAGES_INCREMENT; i--) {
           if (!this._isAutomated(messages[i - 1])) { counter++; }
         }
@@ -273,7 +276,7 @@
      * Gets the number of messages that would be visible, but do not currently
      * exist in _visibleMessages.
      */
-    _numRemaining: function(visibleMessages, messages, hideAutomated) {
+    _numRemaining(visibleMessages, messages, hideAutomated) {
       if (hideAutomated) {
         return this._getHumanMessages(messages).length -
             this._getHumanMessages(visibleMessages).length;
@@ -281,20 +284,20 @@
       return messages.length - visibleMessages.length;
     },
 
-    _computeIncrementText: function(visibleMessages, messages, hideAutomated) {
-      var delta = this._getDelta(visibleMessages, messages, hideAutomated);
+    _computeIncrementText(visibleMessages, messages, hideAutomated) {
+      let delta = this._getDelta(visibleMessages, messages, hideAutomated);
       delta = Math.min(
           this._numRemaining(visibleMessages, messages, hideAutomated), delta);
       return 'Show ' + Math.min(MESSAGES_INCREMENT, delta) + ' more';
     },
 
-    _getHumanMessages: function(messages) {
-      return messages.filter(function(msg) {
+    _getHumanMessages(messages) {
+      return messages.filter(msg => {
         return !this._isAutomated(msg);
-      }.bind(this));
+      });
     },
 
-    _computeShowHideTextHidden: function(visibleMessages, messages,
+    _computeShowHideTextHidden(visibleMessages, messages,
         hideAutomated) {
       if (hideAutomated) {
         messages = this._getHumanMessages(messages);
@@ -303,29 +306,37 @@
       return visibleMessages.length >= messages.length;
     },
 
-    _handleShowAllTap: function() {
+    _handleShowAllTap() {
       this._visibleMessages = this._processedMessages;
       this.$.reporting.reportInteraction(ReportingEvent.SHOW_ALL);
     },
 
-    _handleIncrementShownMessages: function() {
-      var delta = this._getDelta(this._visibleMessages, this._processedMessages,
-          this._hideAutomated);
-      var len = this._visibleMessages.length;
-      var newMessages = this._processedMessages.slice(-(len + delta), -len);
+    _handleIncrementShownMessages() {
+      const delta = this._getDelta(this._visibleMessages,
+          this._processedMessages, this._hideAutomated);
+      const len = this._visibleMessages.length;
+      const newMessages = this._processedMessages.slice(-(len + delta), -len);
       // Add newMessages to the beginning of _visibleMessages
-      this.splice.apply(this, ['_visibleMessages', 0, 0].concat(newMessages));
+      this.splice(...['_visibleMessages', 0, 0].concat(newMessages));
       this.$.reporting.reportInteraction(ReportingEvent.SHOW_MORE);
     },
 
-    _processedMessagesChanged: function(messages) {
+    _processedMessagesChanged(messages) {
       this._visibleMessages = messages.slice(-MAX_INITIAL_SHOWN_MESSAGES);
     },
 
-    _computeNumMessagesText: function(visibleMessages, messages,
+    _computeNumMessagesText(visibleMessages, messages,
         hideAutomated) {
-      var total = this._numRemaining(visibleMessages, messages, hideAutomated);
+      const total =
+          this._numRemaining(visibleMessages, messages, hideAutomated);
       return total === 1 ? 'Show 1 message' : 'Show all ' + total + ' messages';
     },
+
+    _computeIncrementHidden(visibleMessages, messages,
+        hideAutomated) {
+      const total =
+          this._numRemaining(visibleMessages, messages, hideAutomated);
+      return total <= this._getDelta(visibleMessages, messages, hideAutomated);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
index cdca365..f6f2764 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
@@ -34,9 +34,9 @@
 
 <script>
 
-  var randomMessage = function(opt_params) {
-    var params = opt_params || {};
-    var author1 = {
+  const randomMessage = function(opt_params) {
+    const params = opt_params || {};
+    const author1 = {
       _account_id: 1115495,
       name: 'Andrew Bonventre',
       email: 'andybons@chromium.org',
@@ -50,24 +50,24 @@
     };
   };
 
-  var randomAutomated = function(opt_params) {
+  const randomAutomated = function(opt_params) {
     return Object.assign({tag: 'autogenerated:gerrit:replace'},
         randomMessage(opt_params));
   };
 
-  suite('gr-messages-list tests', function() {
-    var element;
-    var messages;
-    var sandbox;
+  suite('gr-messages-list tests', () => {
+    let element;
+    let messages;
+    let sandbox;
 
-    var getMessages = function() {
+    const getMessages = function() {
       return Polymer.dom(element.root).querySelectorAll('gr-message');
     };
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
-        getLoggedIn: function() { return Promise.resolve(false); },
+        getConfig() { return Promise.resolve({}); },
+        getLoggedIn() { return Promise.resolve(false); },
       });
       sandbox = sinon.sandbox.create();
       element = fixture('basic');
@@ -76,24 +76,24 @@
       flushAsynchronousOperations();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('show some old messages', function() {
+    test('show some old messages', () => {
       assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
       element.messages = _.times(26, randomMessage);
       flushAsynchronousOperations();
 
       assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
       assert.equal(getMessages().length, 20);
-      assert.equal(element.$.incrementMessagesBtn.innerText,
+      assert.equal(element.$.incrementMessagesBtn.innerText.trim(),
           'Show 5 more');
       MockInteractions.tap(element.$.incrementMessagesBtn);
       flushAsynchronousOperations();
 
       assert.equal(getMessages().length, 25);
-      assert.equal(element.$.incrementMessagesBtn.innerText,
+      assert.equal(element.$.incrementMessagesBtn.innerText.trim(),
           'Show 1 more');
       MockInteractions.tap(element.$.incrementMessagesBtn);
       flushAsynchronousOperations();
@@ -102,7 +102,7 @@
       assert.equal(getMessages().length, 26);
     });
 
-    test('show all old messages', function() {
+    test('show all old messages', () => {
       assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
       element.messages = _.times(26, randomMessage);
       flushAsynchronousOperations();
@@ -117,7 +117,7 @@
       assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
     });
 
-    test('message count respects automated', function() {
+    test('message count respects automated', () => {
       element.messages = _.times(10, randomAutomated)
           .concat(_.times(11, randomMessage));
       flushAsynchronousOperations();
@@ -130,7 +130,7 @@
       assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
     });
 
-    test('message count still respects non-automated on toggle', function() {
+    test('message count still respects non-automated on toggle', () => {
       element.messages = _.times(10, randomMessage)
           .concat(_.times(11, randomAutomated));
       flushAsynchronousOperations();
@@ -144,7 +144,7 @@
       assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
     });
 
-    test('show all messages respects expand', function() {
+    test('show all messages respects expand', () => {
       element.messages = _.times(10, randomAutomated)
           .concat(_.times(11, randomMessage));
       flushAsynchronousOperations();
@@ -152,10 +152,10 @@
       MockInteractions.tap(element.$$('#collapse-messages')); // Expand all.
       flushAsynchronousOperations();
 
-      var messages = getMessages();
+      let messages = getMessages();
       assert.equal(messages.length, 20);
-      for (var i = 0; i < messages.length; i++) {
-        assert.isTrue(messages[i]._expanded);
+      for (const message of messages) {
+        assert.isTrue(message._expanded);
       }
 
       MockInteractions.tap(element.$.oldMessagesBtn);
@@ -163,12 +163,12 @@
 
       messages = getMessages();
       assert.equal(messages.length, 21);
-      for (var i = 0; i < messages.length; i++) {
-        assert.isTrue(messages[i]._expanded);
+      for (const message of messages) {
+        assert.isTrue(message._expanded);
       }
     });
 
-    test('show all messages respects collapse', function() {
+    test('show all messages respects collapse', () => {
       element.messages = _.times(10, randomAutomated)
           .concat(_.times(11, randomMessage));
       flushAsynchronousOperations();
@@ -177,10 +177,10 @@
       MockInteractions.tap(element.$$('#collapse-messages')); // Collapse all.
       flushAsynchronousOperations();
 
-      var messages = getMessages();
+      let messages = getMessages();
       assert.equal(messages.length, 20);
-      for (var i = 0; i < messages.length; i++) {
-        assert.isFalse(messages[i]._expanded);
+      for (const message of messages) {
+        assert.isFalse(message._expanded);
       }
 
       MockInteractions.tap(element.$.oldMessagesBtn);
@@ -188,37 +188,37 @@
 
       messages = getMessages();
       assert.equal(messages.length, 21);
-      for (var i = 0; i < messages.length; i++) {
-        assert.isFalse(messages[i]._expanded);
+      for (const message of messages) {
+        assert.isFalse(message._expanded);
       }
     });
 
-    test('expand/collapse all', function() {
-      var allMessageEls = getMessages();
-      for (var i = 0; i < allMessageEls.length; i++) {
-        allMessageEls[i]._expanded = false;
+    test('expand/collapse all', () => {
+      let allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        message._expanded = false;
       }
       MockInteractions.tap(allMessageEls[1]);
       assert.isTrue(allMessageEls[1]._expanded);
 
       MockInteractions.tap(element.$$('#collapse-messages'));
       allMessageEls = getMessages();
-      for (var i = 0; i < allMessageEls.length; i++) {
-        assert.isTrue(allMessageEls[i]._expanded);
+      for (const message of allMessageEls) {
+        assert.isTrue(message._expanded);
       }
 
       MockInteractions.tap(element.$$('#collapse-messages'));
       allMessageEls = getMessages();
-      for (var i = 0; i < allMessageEls.length; i++) {
-        assert.isFalse(allMessageEls[i]._expanded);
+      for (const message of allMessageEls) {
+        assert.isFalse(message._expanded);
       }
     });
 
-    test('expand/collapse from external keypress', function() {
+    test('expand/collapse from external keypress', () => {
       MockInteractions.tap(element.$$('#collapse-messages'));
-      var allMessageEls = getMessages();
-      for (var i = 0; i < allMessageEls.length; i++) {
-        assert.isTrue(allMessageEls[i]._expanded);
+      let allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        assert.isTrue(message._expanded);
       }
 
       // Expand/collapse all text also changes.
@@ -227,36 +227,35 @@
 
       MockInteractions.tap(element.$$('#collapse-messages'));
       allMessageEls = getMessages();
-      for (var i = 0; i < allMessageEls.length; i++) {
-        assert.isFalse(allMessageEls[i]._expanded);
+      for (const message of allMessageEls) {
+        assert.isFalse(message._expanded);
       }
       // Expand/collapse all text also changes.
       assert.equal(element.$$('#collapse-messages').textContent.trim(),
           'Expand all');
     });
 
-    test('hide messages does not appear when no automated messages',
-        function() {
+    test('hide messages does not appear when no automated messages', () => {
       assert.isOk(element.$$('#automatedMessageToggleContainer[hidden]'));
     });
 
-    test('scroll to message', function() {
-      var allMessageEls = getMessages();
-      for (var i = 0; i < allMessageEls.length; i++) {
-        allMessageEls[i].set('message.expanded', false);
+    test('scroll to message', () => {
+      const allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        message.set('message.expanded', false);
       }
 
-      var scrollToStub = sandbox.stub(window, 'scrollTo');
-      var highlightStub = sandbox.stub(element, '_highlightEl');
+      const scrollToStub = sandbox.stub(window, 'scrollTo');
+      const highlightStub = sandbox.stub(element, '_highlightEl');
 
       element.scrollToMessage('invalid');
 
-      for (var i = 0; i < allMessageEls.length; i++) {
-        assert.isFalse(allMessageEls[i]._expanded,
-            'expected gr-message ' + i + ' to not be expanded');
+      for (const message of allMessageEls) {
+        assert.isFalse(message._expanded,
+            'expected gr-message to not be expanded');
       }
 
-      var messageID = messages[1].id;
+      const messageID = messages[1].id;
       element.scrollToMessage(messageID);
       assert.isTrue(
           element.$$('[data-message-id="' + messageID + '"]')._expanded);
@@ -265,15 +264,15 @@
       assert.isTrue(highlightStub.calledOnce);
     });
 
-    test('scroll to message offscreen', function() {
-      var scrollToStub = sandbox.stub(window, 'scrollTo');
-      var highlightStub = sandbox.stub(element, '_highlightEl');
+    test('scroll to message offscreen', () => {
+      const scrollToStub = sandbox.stub(window, 'scrollTo');
+      const highlightStub = sandbox.stub(element, '_highlightEl');
       element.messages = _.times(25, randomMessage);
       flushAsynchronousOperations();
       assert.isFalse(scrollToStub.called);
       assert.isFalse(highlightStub.called);
 
-      var messageID = element.messages[1].id;
+      const messageID = element.messages[1].id;
       element.scrollToMessage(messageID);
       assert.isTrue(scrollToStub.calledOnce);
       assert.isTrue(highlightStub.calledOnce);
@@ -282,13 +281,13 @@
           element.$$('[data-message-id="' + messageID + '"]')._expanded);
     });
 
-    test('messages', function() {
-      var author = {
+    test('messages', () => {
+      const author = {
         _account_id: 42,
         name: 'Marvin the Paranoid Android',
         email: 'marvin@sirius.org',
       };
-      var comments = {
+      const comments = {
         file1: [
           {
             message: 'message text',
@@ -309,7 +308,7 @@
             line: 42,
             id: '450a935e_0f1c05db',
             patch_set: 2,
-            author: author,
+            author,
           },
           {
             message: 'message text',
@@ -318,7 +317,7 @@
             line: 62,
             id: '6505d749_10ed44b2',
             patch_set: 2,
-            author: author,
+            author,
           },
         ],
         file2: [
@@ -329,18 +328,18 @@
             line: 132,
             id: '450a935e_4f260d25',
             patch_set: 2,
-            author: author,
+            author,
           },
         ],
       };
-      var messages = [].concat(
+      const messages = [].concat(
           randomMessage(),
           {
             _index: 5,
             _revision_number: 4,
             message: 'Uploaded patch set 4.',
             date: '2016-09-28 13:36:33.000000000',
-            author: author,
+            author,
             id: '8c19ccc949c6d482b061be6a28e10782abf0e7af',
           },
           {
@@ -348,18 +347,18 @@
             _revision_number: 4,
             message: 'Patch Set 4:\n\n(6 comments)',
             date: '2016-09-28 13:36:33.000000000',
-            author: author,
+            author,
             id: 'e7bfdbc842f6b6d8064bc68e0f52b673f40c0ca5',
           }
       );
       element.comments = comments;
       element.messages = messages;
-      var isAuthor = function(author, message) {
+      const isAuthor = function(author, message) {
         return message.author._account_id === author._account_id;
       };
-      var isMarvin = isAuthor.bind(null, author);
+      const isMarvin = isAuthor.bind(null, author);
       flushAsynchronousOperations();
-      var messageElements = getMessages();
+      const messageElements = getMessages();
       assert.equal(messageElements.length, messages.length);
       assert.deepEqual(messageElements[1].message, messages[1]);
       assert.deepEqual(messageElements[2].message, messages[2]);
@@ -370,8 +369,8 @@
       assert.deepEqual(messageElements[2].comments, {});
     });
 
-    test('messages without author do not throw', function() {
-      var comments = {
+    test('messages without author do not throw', () => {
+      const comments = {
         file1: [
           {
             message: 'message text',
@@ -386,7 +385,7 @@
             },
           },
         ]};
-      var messages = [{
+      const messages = [{
         _index: 5,
         _revision_number: 4,
         message: 'Uploaded patch set 4.',
@@ -396,31 +395,43 @@
       element.messages = messages;
       element.comments = comments;
       flushAsynchronousOperations();
-      var messageEls = getMessages();
+      const messageEls = getMessages();
       assert.equal(messageEls.length, 1);
       assert.equal(messageEls[0].message.message, messages[0].message);
     });
+
+    test('hide increment text if increment >= total remaining', () => {
+      // Test with stubbed return values, as _numRemaining and _getDelta have
+      // their own tests.
+      sandbox.stub(element, '_getDelta').returns(5);
+      const remainingStub = sandbox.stub(element, '_numRemaining').returns(6);
+      assert.isFalse(element._computeIncrementHidden(null, null, null));
+      remainingStub.restore();
+
+      sandbox.stub(element, '_numRemaining').returns(4);
+      assert.isTrue(element._computeIncrementHidden(null, null, null));
+    });
   });
 
-  suite('gr-messages-list automate tests', function() {
-    var element;
-    var messages;
+  suite('gr-messages-list automate tests', () => {
+    let element;
+    let messages;
 
-    var getMessages = function() {
+    const getMessages = function() {
       return Polymer.dom(element.root).querySelectorAll('gr-message');
     };
-    var getHiddenMessages = function() {
+    const getHiddenMessages = function() {
       return Polymer.dom(element.root).querySelectorAll('gr-message[hidden]');
     };
 
-    var randomMessageReviewer = {
+    const randomMessageReviewer = {
       reviewer: {},
     };
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
-        getLoggedIn: function() { return Promise.resolve(false); },
+        getConfig() { return Promise.resolve({}); },
+        getLoggedIn() { return Promise.resolve(false); },
       });
       element = fixture('basic');
       messages = _.times(2, randomAutomated);
@@ -429,19 +440,19 @@
       flushAsynchronousOperations();
     });
 
-    test('hide autogenerated button is not hidden', function() {
+    test('hide autogenerated button is not hidden', () => {
       assert.isNotOk(element.$$('#automatedMessageToggle[hidden]'));
     });
 
-    test('autogenerated messages are not hidden initially', function() {
-      var allHiddenMessageEls = getHiddenMessages();
+    test('autogenerated messages are not hidden initially', () => {
+      const allHiddenMessageEls = getHiddenMessages();
 
-      //There are no hidden messages.
+      // There are no hidden messages.
       assert.isFalse(!!allHiddenMessageEls.length);
     });
 
-    test('autogenerated messages hidden after hide button tap', function() {
-      var allHiddenMessageEls = getHiddenMessages();
+    test('autogenerated messages hidden after hide button tap', () => {
+      let allHiddenMessageEls = getHiddenMessages();
 
       element._hideAutomated = false;
       MockInteractions.tap(element.$.automatedMessageToggle);
@@ -453,19 +464,19 @@
       assert.equal(allHiddenMessageEls.length, allMessageEls.length);
     });
 
-    test('autogenerated messages not hidden after show button tap', function() {
-      var allHiddenMessageEls = getHiddenMessages();
+    test('autogenerated messages not hidden after show button tap', () => {
+      let allHiddenMessageEls = getHiddenMessages();
 
       element._hideAutomated = true;
       MockInteractions.tap(element.$.automatedMessageToggle);
       allHiddenMessageEls = getHiddenMessages();
 
-      //Autogenerated messages are now hidden.
+      // Autogenerated messages are now hidden.
       assert.isFalse(!!allHiddenMessageEls.length);
     });
 
-    test('_getDelta', function() {
-      var messages = [randomMessage()];
+    test('_getDelta', () => {
+      let messages = [randomMessage()];
       assert.equal(element._getDelta([], messages, false), 1);
       assert.equal(element._getDelta([], messages, true), 1);
 
@@ -478,18 +489,18 @@
           .concat(_.times(2, randomAutomated))
           .concat(_.times(3, randomMessage));
 
-      var dummyArr = _.times(2, randomMessage);
+      const dummyArr = _.times(2, randomMessage);
       assert.equal(element._getDelta(dummyArr, messages, false), 5);
       assert.equal(element._getDelta(dummyArr, messages, true), 7);
     });
 
-    test('_getHumanMessages', function() {
+    test('_getHumanMessages', () => {
       assert.equal(
           element._getHumanMessages(_.times(5, randomAutomated)).length, 0);
       assert.equal(
           element._getHumanMessages(_.times(5, randomMessage)).length, 5);
 
-      var messages = _.shuffle(_.times(5, randomMessage)
+      let messages = _.shuffle(_.times(5, randomMessage)
           .concat(_.times(5, randomAutomated)));
       messages = element._getHumanMessages(messages);
       assert.equal(messages.length, 5);
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
index 8eacd48..363400e 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
@@ -82,7 +82,7 @@
       .mobile {
         display: none;
       }
-       @media screen and (max-width: 50em) {
+       @media screen and (max-width: 60em) {
         .mobile {
           display: block;
         }
@@ -94,8 +94,7 @@
         }
       }
     </style>
-    <div hidden$="[[!loading]]">Loading...</div>
-    <div hidden$="[[loading]]">
+    <div>
       <hr class="mobile">
       <section class="relatedChanges" hidden$="[[!_relatedResponse.changes.length]]" hidden>
         <h4>Relation chain</h4>
@@ -105,7 +104,8 @@
             as="related">
           <div class$="rightIndent [[_computeChangeContainerClass(change, related)]]">
             <a href$="[[_computeChangeURL(related._change_number, related._revision_number)]]"
-                class$="[[_computeLinkClass(related)]]">
+                class$="[[_computeLinkClass(related)]]"
+                title$="[[related.commit.subject]]">
               [[related.commit.subject]]
             </a>
             <span class$="[[_computeChangeStatusClass(related)]]">
@@ -119,7 +119,8 @@
         <template is="dom-repeat" items="[[_submittedTogether]]" as="change">
           <div>
             <a href$="[[_computeChangeURL(change._number)]]"
-                class$="[[_computeLinkClass(change)]]">
+                class$="[[_computeLinkClass(change)]]"
+                title$="[[change.project]]: [[change.branch]]: [[change.subject]]">
               [[change.project]]: [[change.branch]]: [[change.subject]]
             </a>
           </div>
@@ -130,7 +131,8 @@
         <template is="dom-repeat" items="[[_sameTopic]]" as="change">
           <div>
             <a href$="[[_computeChangeURL(change._number)]]"
-                class$="[[_computeLinkClass(change)]]">
+                class$="[[_computeLinkClass(change)]]"
+                title$="[[change.project]]: [[change.branch]]: [[change.subject]]">
               [[change.project]]: [[change.branch]]: [[change.subject]]
             </a>
           </div>
@@ -141,7 +143,8 @@
         <template is="dom-repeat" items="[[_conflicts]]" as="change">
           <div>
             <a href$="[[_computeChangeURL(change._number)]]"
-                class$="[[_computeLinkClass(change)]]">
+                class$="[[_computeLinkClass(change)]]"
+                title$="[[change.subject]]">
               [[change.subject]]
             </a>
           </div>
@@ -152,14 +155,16 @@
         <template is="dom-repeat" items="[[_cherryPicks]]" as="change">
           <div>
             <a href$="[[_computeChangeURL(change._number)]]"
-                class$="[[_computeLinkClass(change)]]">
+                class$="[[_computeLinkClass(change)]]"
+                title$="[[change.branch]]: [[change.subject]]">
               [[change.branch]]: [[change.subject]]
             </a>
           </div>
         </template>
       </section>
     </div>
+    <div hidden$="[[!loading]]">Loading...</div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-related-changes-list.js"></script>
-</dom-module>
+</dom-module>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
index 55a0bce..fe35c7f 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
@@ -39,11 +39,26 @@
         computed: '_computeConnectedRevisions(change, patchNum, ' +
             '_relatedResponse.changes)',
       },
-      _relatedResponse: Object,
-      _submittedTogether: Array,
-      _conflicts: Array,
-      _cherryPicks: Array,
-      _sameTopic: Array,
+      _relatedResponse: {
+        type: Object,
+        value() { return {changes: []}; },
+      },
+      _submittedTogether: {
+        type: Array,
+        value() { return []; },
+      },
+      _conflicts: {
+        type: Array,
+        value() { return []; },
+      },
+      _cherryPicks: {
+        type: Array,
+        value() { return []; },
+      },
+      _sameTopic: {
+        type: Array,
+        value() { return []; },
+      },
     },
 
     behaviors: [
@@ -56,52 +71,52 @@
           '_conflicts, _cherryPicks, _sameTopic)',
     ],
 
-    clear: function() {
+    clear() {
       this.loading = true;
+      this.hidden = true;
     },
 
-    reload: function() {
+    reload() {
       if (!this.change || !this.patchNum) {
         return Promise.resolve();
       }
       this.loading = true;
-      var promises = [
-        this._getRelatedChanges().then(function(response) {
+      const promises = [
+        this._getRelatedChanges().then(response => {
           this._relatedResponse = response;
 
           this.hasParent = this._calculateHasParent(this.change.change_id,
-            response.changes);
-
-        }.bind(this)),
-        this._getSubmittedTogether().then(function(response) {
+              response.changes);
+        }),
+        this._getSubmittedTogether().then(response => {
           this._submittedTogether = response;
-        }.bind(this)),
-        this._getCherryPicks().then(function(response) {
+        }),
+        this._getCherryPicks().then(response => {
           this._cherryPicks = response;
-        }.bind(this)),
+        }),
       ];
 
       // Get conflicts if change is open and is mergeable.
       if (this.changeIsOpen(this.change.status) && this.change.mergeable) {
-        promises.push(this._getConflicts().then(function(response) {
+        promises.push(this._getConflicts().then(response => {
           this._conflicts = response;
-        }.bind(this)));
+        }));
       }
 
-      promises.push(this._getServerConfig().then(function(config) {
+      promises.push(this._getServerConfig().then(config => {
         if (this.change.topic && !config.change.submit_whole_topic) {
-          return this._getChangesWithSameTopic().then(function(response) {
+          return this._getChangesWithSameTopic().then(response => {
             this._sameTopic = response;
-          }.bind(this));
+          });
         } else {
           this._sameTopic = [];
         }
         return this._sameTopic;
-      }.bind(this)));
+      }));
 
-      return Promise.all(promises).then(function() {
+      return Promise.all(promises).then(() => {
         this.loading = false;
-      }.bind(this));
+      });
     },
 
     /**
@@ -112,62 +127,62 @@
      * @param  {Array} relatedChanges
      * @return {Boolean}
      */
-    _calculateHasParent: function(currentChangeId, relatedChanges) {
+    _calculateHasParent(currentChangeId, relatedChanges) {
       return relatedChanges.length > 0 &&
           relatedChanges[relatedChanges.length - 1].change_id !==
           currentChangeId;
     },
 
-    _getRelatedChanges: function() {
+    _getRelatedChanges() {
       return this.$.restAPI.getRelatedChanges(this.change._number,
           this.patchNum);
     },
 
-    _getSubmittedTogether: function() {
+    _getSubmittedTogether() {
       return this.$.restAPI.getChangesSubmittedTogether(this.change._number);
     },
 
-    _getServerConfig: function() {
+    _getServerConfig() {
       return this.$.restAPI.getConfig();
     },
 
-    _getConflicts: function() {
+    _getConflicts() {
       return this.$.restAPI.getChangeConflicts(this.change._number);
     },
 
-    _getCherryPicks: function() {
+    _getCherryPicks() {
       return this.$.restAPI.getChangeCherryPicks(this.change.project,
           this.change.change_id, this.change._number);
     },
 
-    _getChangesWithSameTopic: function() {
+    _getChangesWithSameTopic() {
       return this.$.restAPI.getChangesWithSameTopic(this.change.topic);
     },
 
-    _computeChangeURL: function(changeNum, patchNum) {
-      var urlStr = this.getBaseUrl() + '/c/' + changeNum;
+    _computeChangeURL(changeNum, patchNum) {
+      let urlStr = this.getBaseUrl() + '/c/' + changeNum;
       if (patchNum != null) {
         urlStr += '/' + patchNum;
       }
       return urlStr;
     },
 
-    _computeChangeContainerClass: function(currentChange, relatedChange) {
-      var classes = ['changeContainer'];
+    _computeChangeContainerClass(currentChange, relatedChange) {
+      const classes = ['changeContainer'];
       if (relatedChange.change_id === currentChange.change_id) {
         classes.push('thisChange');
       }
       return classes.join(' ');
     },
 
-    _computeLinkClass: function(change) {
+    _computeLinkClass(change) {
       if (change.status == this.ChangeStatus.ABANDONED) {
         return 'strikethrough';
       }
     },
 
-    _computeChangeStatusClass: function(change) {
-      var classes = ['status'];
+    _computeChangeStatusClass(change) {
+      const classes = ['status'];
       if (change._revision_number != change._current_revision_number) {
         classes.push('notCurrent');
       } else if (this._isIndirectAncestor(change)) {
@@ -180,7 +195,7 @@
       return classes.join(' ');
     },
 
-    _computeChangeStatus: function(change) {
+    _computeChangeStatus(change) {
       switch (change.status) {
         case this.ChangeStatus.MERGED:
           return 'Merged';
@@ -199,41 +214,42 @@
       return '';
     },
 
-    _resultsChanged: function(related, submittedTogether, conflicts,
+    _resultsChanged(related, submittedTogether, conflicts,
         cherryPicks, sameTopic) {
-      var results = [
+      const results = [
         related,
         submittedTogether,
         conflicts,
         cherryPicks,
-        sameTopic
+        sameTopic,
       ];
-      for (var i = 0; i < results.length; i++) {
+      for (let i = 0; i < results.length; i++) {
         if (results[i].length > 0) {
           this.hidden = false;
+          this.fire('update', null, {bubbles: false});
           return;
         }
       }
       this.hidden = true;
     },
 
-    _isIndirectAncestor: function(change) {
-      return this._connectedRevisions.indexOf(change.commit.commit) == -1;
+    _isIndirectAncestor(change) {
+      return !this._connectedRevisions.includes(change.commit.commit);
     },
 
-    _computeConnectedRevisions: function(change, patchNum, relatedChanges) {
-      var connected = [];
-      var changeRevision;
-      for (var rev in change.revisions) {
+    _computeConnectedRevisions(change, patchNum, relatedChanges) {
+      const connected = [];
+      let changeRevision;
+      for (const rev in change.revisions) {
         if (change.revisions[rev]._number == patchNum) {
           changeRevision = rev;
         }
       }
-      var commits = relatedChanges.map(function(c) { return c.commit; });
-      var pos = commits.length - 1;
+      const commits = relatedChanges.map(c => { return c.commit; });
+      let pos = commits.length - 1;
 
       while (pos >= 0) {
-        var commit = commits[pos].commit;
+        const commit = commits[pos].commit;
         connected.push(commit);
         if (commit == changeRevision) {
           break;
@@ -241,8 +257,8 @@
         pos--;
       }
       while (pos >= 0) {
-        for (var i = 0; i < commits[pos].parents.length; i++) {
-          if (connected.indexOf(commits[pos].parents[i].commit) != -1) {
+        for (let i = 0; i < commits[pos].parents.length; i++) {
+          if (connected.includes(commits[pos].parents[i].commit)) {
             connected.push(commits[pos].commit);
             break;
           }
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
index 78c4cf4..4a25405 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
@@ -33,21 +33,21 @@
 </test-fixture>
 
 <script>
-  suite('gr-related-changes-list tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-related-changes-list tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('connected revisions', function() {
-      var change = {
+    test('connected revisions', () => {
+      const change = {
         revisions: {
           'e3c6d60783bfdec9ebae7dcfec4662360433449e': {
             _number: 1,
@@ -69,18 +69,18 @@
           },
           '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6': {
             _number: 4,
-          }
-        }
+          },
+        },
       };
-      var patchNum = 7;
-      var relatedChanges = [
+      let patchNum = 7;
+      let relatedChanges = [
         {
           commit: {
             commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
             parents: [
               {
-                commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd'
-              }
+                commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+              },
             ],
           },
         },
@@ -89,8 +89,8 @@
             commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
             parents: [
               {
-                commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb'
-              }
+                commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+              },
             ],
           },
         },
@@ -99,8 +99,8 @@
             commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
             parents: [
               {
-                commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae'
-              }
+                commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
+              },
             ],
           },
         },
@@ -109,8 +109,8 @@
             commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
             parents: [
               {
-                commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907'
-              }
+                commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+              },
             ],
           },
         },
@@ -119,8 +119,8 @@
             commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
             parents: [
               {
-                commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce'
-              }
+                commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce',
+              },
             ],
           },
         },
@@ -129,14 +129,14 @@
             commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce',
             parents: [
               {
-                commit: '455ed9cd27a16bf6991f04dcc57ef575dc4d5e75'
-              }
+                commit: '455ed9cd27a16bf6991f04dcc57ef575dc4d5e75',
+              },
             ],
           },
-        }
+        },
       ];
 
-      var connectedChanges =
+      let connectedChanges =
           element._computeConnectedRevisions(change, patchNum, relatedChanges);
       assert.deepEqual(connectedChanges, [
         '613bc4f81741a559c6667ac08d71dcc3348f73ce',
@@ -155,8 +155,8 @@
             commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
             parents: [
               {
-                commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd'
-              }
+                commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+              },
             ],
           },
         },
@@ -165,8 +165,8 @@
             commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
             parents: [
               {
-                commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb'
-              }
+                commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+              },
             ],
           },
         },
@@ -175,8 +175,8 @@
             commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
             parents: [
               {
-                commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae'
-              }
+                commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
+              },
             ],
           },
         },
@@ -185,8 +185,8 @@
             commit: 'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
             parents: [
               {
-                commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6'
-              }
+                commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+              },
             ],
           },
         },
@@ -195,8 +195,8 @@
             commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
             parents: [
               {
-                commit: 'af815dac54318826b7f1fa468acc76349ffc588e'
-              }
+                commit: 'af815dac54318826b7f1fa468acc76349ffc588e',
+              },
             ],
           },
         },
@@ -205,11 +205,11 @@
             commit: 'af815dac54318826b7f1fa468acc76349ffc588e',
             parents: [
               {
-                commit: '58f76e406e24cb8b0f5d64c7f5ac1e8616d0a22c'
-              }
+                commit: '58f76e406e24cb8b0f5d64c7f5ac1e8616d0a22c',
+              },
             ],
           },
-        }
+        },
       ];
 
       connectedChanges =
@@ -222,9 +222,9 @@
       ]);
     });
 
-    test('_computeChangeContainerClass', function() {
-      var change1 = {change_id: 123};
-      var change2 = {change_id: 456};
+    test('_computeChangeContainerClass', () => {
+      const change1 = {change_id: 123};
+      const change2 = {change_id: 456};
 
       assert.notEqual(element._computeChangeContainerClass(
           change1, change1).indexOf('thisChange'), -1);
@@ -232,26 +232,25 @@
           change1, change2).indexOf('thisChange'), -1);
     });
 
-    suite('get conflicts tests', function() {
-      var element;
-      var conflictsStub;
+    suite('get conflicts tests', () => {
+      let element;
+      let conflictsStub;
 
-      setup(function() {
+      setup(() => {
         element = fixture('basic');
 
-        sandbox.stub(element, '_getRelatedChanges',
-            function() {
-              return Promise.resolve({changes: []});
-            });
+        sandbox.stub(element, '_getRelatedChanges', () => {
+          return Promise.resolve({changes: []});
+        });
         sandbox.stub(element, '_getSubmittedTogether',
-            function() { return Promise.resolve(); });
+            () => { return Promise.resolve(); });
         sandbox.stub(element, '_getCherryPicks',
-            function() { return Promise.resolve(); });
+            () => { return Promise.resolve(); });
         conflictsStub = sandbox.stub(element, '_getConflicts',
-            function() { return Promise.resolve(); });
+            () => { return Promise.resolve(['test data']); });
       });
 
-      test('request conflicts if open and mergeable', function() {
+      test('request conflicts if open and mergeable', () => {
         element.patchNum = 7;
         element.change = {
           change_id: 123,
@@ -262,7 +261,7 @@
         assert.isTrue(conflictsStub.called);
       });
 
-      test('does not request conflicts if closed and mergeable', function() {
+      test('does not request conflicts if closed and mergeable', () => {
         element.patchNum = 7;
         element.change = {
           change_id: 123,
@@ -273,7 +272,7 @@
         assert.isFalse(conflictsStub.called);
       });
 
-      test('does not request conflicts if open and not mergeable', function() {
+      test('does not request conflicts if open and not mergeable', () => {
         element.patchNum = 7;
         element.change = {
           change_id: 123,
@@ -284,8 +283,7 @@
         assert.isFalse(conflictsStub.called);
       });
 
-      test('does not request conflicts if closed and not mergeable',
-          function() {
+      test('doesnt request conflicts if closed and not mergeable', () => {
         element.patchNum = 7;
         element.change = {
           change_id: 123,
@@ -297,9 +295,9 @@
       });
     });
 
-    test('_calculateHasParent', function() {
-      var changeId = 123;
-      var relatedChanges = [];
+    test('_calculateHasParent', () => {
+      const changeId = 123;
+      const relatedChanges = [];
 
       assert.equal(element._calculateHasParent(changeId, relatedChanges),
           false);
@@ -311,7 +309,27 @@
       relatedChanges.push({change_id: 234});
       assert.equal(element._calculateHasParent(changeId, relatedChanges),
           true);
+    });
 
+    test('clear hides', () => {
+      element.loading = false;
+      element.hidden = false;
+      element.clear();
+      assert.isTrue(element.loading);
+      assert.isTrue(element.hidden);
+    });
+
+    test('update fires', () => {
+      const updateHandler = sandbox.stub();
+      element.addEventListener('update', updateHandler);
+
+      element._resultsChanged([], [], [], [], []);
+      assert.isTrue(element.hidden);
+      assert.isFalse(updateHandler.called);
+
+      element._resultsChanged([], [], [], [], ['test']);
+      assert.isFalse(element.hidden);
+      assert.isTrue(updateHandler.called);
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
new file mode 100644
index 0000000..b4a6171
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
@@ -0,0 +1,119 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-reply-dialog</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-reply-dialog.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-reply-dialog></gr-reply-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-reply-dialog tests', () => {
+    let element;
+    let changeNum;
+    let patchNum;
+
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+
+      changeNum = 42;
+      patchNum = 1;
+
+      stub('gr-rest-api-interface', {
+        getConfig() { return Promise.resolve({}); },
+        getAccount() { return Promise.resolve({}); },
+      });
+
+      element = fixture('basic');
+      element.change = {
+        _number: changeNum,
+        labels: {
+          'Verified': {
+            values: {
+              '-1': 'Fails',
+              ' 0': 'No score',
+              '+1': 'Verified',
+            },
+            default_value: 0,
+          },
+          'Code-Review': {
+            values: {
+              '-2': 'Do not submit',
+              '-1': 'I would prefer that you didn\'t submit this',
+              ' 0': 'No score',
+              '+1': 'Looks good to me, but someone else must approve',
+              '+2': 'Looks good to me, approved',
+            },
+            default_value: 0,
+          },
+        },
+      };
+      element.patchNum = patchNum;
+      element.permittedLabels = {
+        'Code-Review': [
+          '-1',
+          ' 0',
+          '+1',
+        ],
+        'Verified': [
+          '-1',
+          ' 0',
+          '+1',
+        ],
+      };
+      element.serverConfig = {note_db_enabled: true};
+
+      sandbox.stub(element, 'fetchIsLatestKnown', () => Promise.resolve(true));
+
+      // Allow the elements created by dom-repeat to be stamped.
+      flushAsynchronousOperations();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('send blocked when invalid email is supplied to ccs', () => {
+      const sendStub = sandbox.stub(element, 'send').returns(Promise.resolve());
+      // Stub the below function to avoid side effects from the send promise
+      // resolving.
+      sandbox.stub(element, '_purgeReviewersPendingRemove');
+
+      element.$$('#ccs').$.entry.setText('test');
+      MockInteractions.tap(element.$$('gr-button.send'));
+      assert.isFalse(sendStub.called);
+      flushAsynchronousOperations();
+
+      element.$$('#ccs').$.entry.setText('test@test.test');
+      MockInteractions.tap(element.$$('gr-button.send'));
+      assert.isTrue(sendStub.called);
+    });
+  });
+</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 b1f95e6..1ee43fa 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
@@ -14,10 +14,12 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../bower_components/iron-selector/iron-selector.html">
 <link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
@@ -26,6 +28,7 @@
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-storage/gr-storage.html">
 <link rel="import" href="../gr-account-list/gr-account-list.html">
+<link rel="import" href="../gr-label-scores/gr-label-scores.html">
 
 <dom-module id="gr-reply-dialog">
   <template>
@@ -51,8 +54,7 @@
         width: 100%;
       }
       .peopleContainer,
-      .labelsContainer,
-      .actionsContainer {
+      .labelsContainer {
         flex-shrink: 0;
       }
       .peopleContainer {
@@ -71,6 +73,8 @@
         display: flex;
         flex-wrap: wrap;
         flex: 1;
+        max-height: 12em;
+        overflow-y: auto;
       }
       #reviewerConfirmationOverlay {
         padding: 1em;
@@ -105,38 +109,6 @@
         border: none;
         width: 100%;
       }
-      .labelContainer:not(:first-of-type) {
-        margin-top: .5em;
-      }
-      .labelName {
-        display: inline-block;
-        margin-right: .5em;
-        min-width: 7em;
-        text-align: right;
-        white-space: nowrap;
-        width: 25%;
-      }
-      .labelMessage {
-        color: #666;
-      }
-      iron-selector {
-        display: inline-flex;
-      }
-      iron-selector > gr-button {
-        margin-right: .25em;
-        min-width: 3.5em;
-      }
-      iron-selector > gr-button:first-of-type {
-        border-top-left-radius: 2px;
-        border-bottom-left-radius: 2px;
-      }
-      iron-selector > gr-button:last-of-type {
-        border-top-right-radius: 2px;
-        border-bottom-right-radius: 2px;
-      }
-      iron-selector > gr-button.iron-selected {
-        background-color: #ddd;
-      }
       .draftsContainer {
         flex: 1;
         overflow-y: auto;
@@ -144,14 +116,24 @@
       .draftsContainer h3 {
         margin-top: .25em;
       }
-      .actionsContainer {
-        display: flex;
-        justify-content: space-between;
-      }
       .action:link,
       .action:visited {
         color: #00e;
       }
+      #checkingStatusLabel,
+      #notLatestLabel {
+        margin-left: 1em;
+      }
+      #checkingStatusLabel {
+        color: #444;
+        font-style: italic;
+      }
+      #notLatestLabel {
+        color: red;
+      }
+      #cancelButton {
+        float:right;
+      }
       @media screen and (max-width: 50em) {
         :host {
           max-height: none;
@@ -191,6 +173,7 @@
                 change="[[change]]"
                 filter="[[filterReviewerSuggestion]]"
                 pending-confirmation="{{_ccPendingConfirmation}}"
+                allow-any-input
                 placeholder="Add CC...">
             </gr-account-list>
           </div>
@@ -223,7 +206,7 @@
             id="textarea"
             class="message"
             autocomplete="on"
-            placeholder="Say something nice..."
+            placeholder=[[_messagePlaceholder]]
             disabled="{{disabled}}"
             rows="4"
             max-rows="15"
@@ -242,36 +225,50 @@
             config="[[projectConfig.commentlinks]]"></gr-formatted-text>
       </section>
       <section class="labelsContainer">
-        <template is="dom-repeat" items="[[_labels]]" as="label">
-          <div class="labelContainer">
-            <span class="labelName">[[label.name]]</span>
-            <iron-selector data-label$="[[label.name]]"
-                selected="[[_computeIndexOfLabelValue(change.labels, permittedLabels, label)]]"
-                hidden$="[[!_computeAnyPermittedLabelValues(permittedLabels, label.name)]]">
-              <template is="dom-repeat"
-                  items="[[_computePermittedLabelValues(permittedLabels, label.name)]]"
-                  as="value">
-                <gr-button has-tooltip data-value$="[[value]]"
-                    title$="[[_computeLabelValueTitle(change.labels, label.name, value)]]">[[value]]</gr-button>
-              </template>
-            </iron-selector>
-            <span class="labelMessage"
-                hidden$="[[_computeAnyPermittedLabelValues(permittedLabels, label.name)]]">
-              You don't have permission to edit this label.
-            </span>
-          </div>
-        </template>
+        <gr-label-scores
+            id="labelScores"
+            account="[[_account]]"
+            change="[[change]]"
+            permitted-labels=[[permittedLabels]]></gr-label-scores>
       </section>
       <section class="draftsContainer" hidden$="[[_computeHideDraftList(diffDrafts)]]">
-        <h3>[[_computeDraftsTitle(diffDrafts)]]</h3>
+        <div class="includeComments">
+          <input type="checkbox" id="includeComments"
+              checked="{{_includeComments::change}}">
+          <label for="includeComments">Publish [[_computeDraftsTitle(diffDrafts)]]</label>
+        </div>
         <gr-comment-list
+            id="commentList"
             comments="[[diffDrafts]]"
             change-num="[[change._number]]"
             project-config="[[projectConfig]]"
-            patch-num="[[patchNum]]"></gr-comment-list>
+            patch-num="[[patchNum]]"
+            hidden$="[[!_includeComments]]"></gr-comment-list>
       </section>
-      <section class="actionsContainer">
-        <gr-button primary class="action send" on-tap="_sendTapHandler">Send</gr-button>
+      <section>
+        <gr-button
+            primary
+            disabled="[[!_isState(knownLatestState, 'latest')]]"
+            class="action send"
+            on-tap="_sendTapHandler">[[_sendButtonLabel]]</gr-button>
+        </gr-button>
+        <template is="dom-if" if="[[canBeStarted]]">
+          <gr-button
+              disabled="[[!_isState(knownLatestState, 'latest')]]"
+              class="action save"
+              on-tap="_saveTapHandler">Save</gr-button>
+        </template>
+        <span
+            id="checkingStatusLabel"
+            hidden$="[[!_isState(knownLatestState, 'checking')]]">
+          Checking whether patch [[patchNum]] is latest...
+        </span>
+        <span
+            id="notLatestLabel"
+            hidden$="[[!_isState(knownLatestState, 'not-latest')]]">
+          Patch [[patchNum]] is not latest.
+          <gr-button link on-tap="_reload">Reload</gr-button>
+        </span>
         <gr-button
             id="cancelButton"
             class="action cancel"
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 4c35f38..8218b43 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
@@ -14,20 +14,26 @@
 (function() {
   'use strict';
 
-  var STORAGE_DEBOUNCE_INTERVAL_MS = 400;
+  const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
-  var FocusTarget = {
+  const FocusTarget = {
     ANY: 'any',
     BODY: 'body',
     CCS: 'cc',
     REVIEWERS: 'reviewers',
   };
 
-  var ReviewerTypes = {
+  const ReviewerTypes = {
     REVIEWER: 'REVIEWER',
     CC: 'CC',
   };
 
+  const LatestPatchState = {
+    LATEST: 'latest',
+    CHECKING: 'checking',
+    NOT_LATEST: 'not-latest',
+  };
+
   Polymer({
     is: 'gr-reply-dialog',
 
@@ -50,9 +56,19 @@
      * @event autogrow
      */
 
+    /**
+     * Fires to show an alert when a send is attempted on the non-latest patch.
+     *
+     * @event show-alert
+     */
+
     properties: {
       change: Object,
       patchNum: String,
+      canBeStarted: {
+        type: Boolean,
+        value: false,
+      },
       disabled: {
         type: Boolean,
         value: false,
@@ -70,13 +86,18 @@
       diffDrafts: Object,
       filterReviewerSuggestion: {
         type: Function,
-        value: function() {
+        value() {
           return this._filterReviewerSuggestion.bind(this);
         },
       },
       permittedLabels: Object,
       serverConfig: Object,
       projectConfig: Object,
+      knownLatestState: String,
+      underReview: {
+        type: Boolean,
+        value: true,
+      },
 
       _account: Object,
       _ccs: Array,
@@ -84,12 +105,16 @@
         type: Object,
         observer: '_reviewerPendingConfirmationUpdated',
       },
-      _labels: {
-        type: Array,
-        computed: '_computeLabels(change.labels.*, _account)',
+      _messagePlaceholder: {
+        type: String,
+        computed: '_computeMessagePlaceholder(canBeStarted)',
       },
       _owner: Object,
       _pendingConfirmationDetails: Object,
+      _includeComments: {
+        type: Boolean,
+        value: true,
+      },
       _reviewers: Array,
       _reviewerPendingConfirmation: {
         type: Object,
@@ -107,80 +132,107 @@
           REVIEWER: [],
         },
       },
+      _sendButtonLabel: {
+        type: String,
+        computed: '_computeSendButtonLabel(canBeStarted)',
+      },
+      _ccsEnabled: {
+        type: Boolean,
+        computed: '_computeCCsEnabled(serverConfig)',
+      },
     },
 
-    FocusTarget: FocusTarget,
+    FocusTarget,
 
     behaviors: [
+      Gerrit.BaseUrlBehavior,
+      Gerrit.KeyboardShortcutBehavior,
+      Gerrit.PatchSetBehavior,
       Gerrit.RESTClientBehavior,
     ],
 
+    keyBindings: {
+      esc: '_handleEscKey',
+    },
+
     observers: [
       '_changeUpdated(change.reviewers.*, change.owner, serverConfig)',
       '_ccsChanged(_ccs.splices)',
       '_reviewersChanged(_reviewers.splices)',
     ],
 
-    attached: function() {
-      this._getAccount().then(function(account) {
+    attached() {
+      this._getAccount().then(account => {
         this._account = account || {};
-      }.bind(this));
+      });
     },
 
-    ready: function() {
+    ready() {
       this.$.jsAPI.addElement(this.$.jsAPI.Element.REPLY_DIALOG, this);
     },
 
-    open: function(opt_focusTarget) {
+    open(opt_focusTarget) {
+      this.knownLatestState = LatestPatchState.CHECKING;
+      this.fetchIsLatestKnown(this.change, this.$.restAPI)
+          .then(isUpToDate => {
+            this.knownLatestState = isUpToDate ?
+                LatestPatchState.LATEST : LatestPatchState.NOT_LATEST;
+          });
+
       this._focusOn(opt_focusTarget);
       if (!this.draft || !this.draft.length) {
         this.draft = this._loadStoredDraft();
       }
     },
 
-    focus: function() {
+    focus() {
       this._focusOn(FocusTarget.ANY);
     },
 
-    getFocusStops: function() {
+    getFocusStops() {
       return {
         start: this.$.reviewers.focusStart,
         end: this.$.cancelButton,
       };
     },
 
-    setLabelValue: function(label, value) {
-      var selectorEl = this.$$('iron-selector[data-label="' + label + '"]');
+    setLabelValue(label, value) {
+      const selectorEl =
+          this.$.labelScores.$$('iron-selector[data-label="' + label + '"]');
       // The selector may not be present if it’s not at the latest patch set.
       if (!selectorEl) { return; }
-      var item = selectorEl.$$('gr-button[data-value="' + value + '"]');
+      const item = selectorEl.$$('gr-button[data-value="' + value + '"]');
       if (!item) { return; }
       selectorEl.selectIndex(selectorEl.indexOf(item));
     },
 
-    _ccsChanged: function(splices) {
+    _handleEscKey(e) {
+      this.cancel();
+    },
+
+    _ccsChanged(splices) {
       if (splices && splices.indexSplices) {
         this._processReviewerChange(splices.indexSplices, ReviewerTypes.CC);
       }
     },
 
-    _reviewersChanged: function(splices) {
+    _reviewersChanged(splices) {
       if (splices && splices.indexSplices) {
         this._processReviewerChange(splices.indexSplices,
             ReviewerTypes.REVIEWER);
       }
     },
 
-    _processReviewerChange: function(indexSplices, type) {
-      indexSplices.forEach(function(splice) {
-        splice.removed.forEach(function(account) {
+    _processReviewerChange(indexSplices, type) {
+      for (const splice of indexSplices) {
+        for (const account of splice.removed) {
           if (!this._reviewersPendingRemove[type]) {
             console.err('Invalid type ' + type + ' for reviewer.');
             return;
           }
           this._reviewersPendingRemove[type].push(account);
-        }.bind(this));
-      }.bind(this));
+        }
+      }
     },
 
     /**
@@ -191,15 +243,14 @@
      * @param {Object} opt_accountIdsTransferred map of account IDs that must
      *     not be removed, because they have been readded in another state.
      */
-    _purgeReviewersPendingRemove: function(isCancel,
-        opt_accountIdsTransferred) {
-      var reviewerArr;
-      var keep = opt_accountIdsTransferred || {};
-      for (var type in this._reviewersPendingRemove) {
+    _purgeReviewersPendingRemove(isCancel, opt_accountIdsTransferred) {
+      let reviewerArr;
+      const keep = opt_accountIdsTransferred || {};
+      for (const type in this._reviewersPendingRemove) {
         if (this._reviewersPendingRemove.hasOwnProperty(type)) {
           if (!isCancel) {
             reviewerArr = this._reviewersPendingRemove[type];
-            for (var i = 0; i < reviewerArr.length; i++) {
+            for (let i = 0; i < reviewerArr.length; i++) {
               if (!keep[reviewerArr[i]._account_id]) {
                 this._removeAccount(reviewerArr[i], type);
               }
@@ -217,118 +268,107 @@
      * @param {Object} account
      * @param {ReviewerTypes} type
      */
-    _removeAccount: function(account, type) {
+    _removeAccount(account, type) {
       if (account._pendingAdd) { return; }
 
       return this.$.restAPI.removeChangeReviewer(this.change._number,
-          account._account_id).then(function(response) {
-        if (!response.ok) { return response; }
+          account._account_id).then(response => {
+            if (!response.ok) { return response; }
 
-        var reviewers = this.change.reviewers[type] || [];
-        for (var i = 0; i < reviewers.length; i++) {
-          if (reviewers[i]._account_id == account._account_id) {
-            this.splice(['change', 'reviewers', type], i, 1);
-            break;
-          }
-        }
-      }.bind(this));
+            const reviewers = this.change.reviewers[type] || [];
+            for (let i = 0; i < reviewers.length; i++) {
+              if (reviewers[i]._account_id == account._account_id) {
+                this.splice(['change', 'reviewers', type], i, 1);
+                break;
+              }
+            }
+          });
     },
 
-    _mapReviewer: function(reviewer) {
-      var reviewerId;
-      var confirmed;
+    _mapReviewer(reviewer) {
+      let reviewerId;
+      let confirmed;
       if (reviewer.account) {
-        reviewerId = reviewer.account._account_id;
+        reviewerId = reviewer.account._account_id || reviewer.account.email;
       } else if (reviewer.group) {
         reviewerId = reviewer.group.id;
         confirmed = reviewer.group.confirmed;
       }
-      return {reviewer: reviewerId, confirmed: confirmed};
+      return {reviewer: reviewerId, confirmed};
     },
 
-    send: function() {
-      var obj = {
-        drafts: 'PUBLISH_ALL_REVISIONS',
-        labels: {},
+    send(includeComments) {
+      if (this.knownLatestState === 'not-latest') {
+        this.fire('show-alert',
+            {message: 'Cannot reply to non-latest patch.'});
+        return;
+      }
+
+      const labels = this.$.labelScores.getLabelValues();
+
+      const obj = {
+        drafts: includeComments ? 'PUBLISH_ALL_REVISIONS' : 'KEEP',
+        labels,
       };
 
-      for (var label in this.permittedLabels) {
-        if (!this.permittedLabels.hasOwnProperty(label)) { continue; }
-
-        var selectorEl = this.$$('iron-selector[data-label="' + label + '"]');
-
-        // The user may have not voted on this label.
-        if (!selectorEl || !selectorEl.selectedItem) { continue; }
-
-        var selectedVal = selectorEl.selectedItem.getAttribute('data-value');
-        selectedVal = parseInt(selectedVal, 10);
-
-        // Only send the selection if the user changed it.
-        var prevVal = this._getVoteForAccount(this.change.labels, label,
-            this._account);
-        if (prevVal !== null) {
-          prevVal = parseInt(prevVal, 10);
-        }
-        if (selectedVal !== prevVal) {
-          obj.labels[label] = selectedVal;
-        }
-      }
       if (this.draft != null) {
         obj.message = this.draft;
       }
 
-      var accountAdditions = {};
-      obj.reviewers = this.$.reviewers.additions().map(function(reviewer) {
+      const accountAdditions = {};
+      obj.reviewers = this.$.reviewers.additions().map(reviewer => {
         if (reviewer.account) {
           accountAdditions[reviewer.account._account_id] = true;
         }
         return this._mapReviewer(reviewer);
-      }.bind(this));
-      if (this.serverConfig.note_db_enabled) {
-        this.$$('#ccs').additions().forEach(function(reviewer) {
+      });
+      const ccsEl = this.$$('#ccs');
+      if (ccsEl) {
+        for (let reviewer of ccsEl.additions()) {
           if (reviewer.account) {
             accountAdditions[reviewer.account._account_id] = true;
           }
           reviewer = this._mapReviewer(reviewer);
           reviewer.state = 'CC';
           obj.reviewers.push(reviewer);
-        }.bind(this));
+        }
       }
 
       this.disabled = true;
 
-      var errFn = this._handle400Error.bind(this);
-      return this._saveReview(obj, errFn).then(function(response) {
+      const errFn = this._handle400Error.bind(this);
+      return this._saveReview(obj, errFn).then(response => {
         if (!response || !response.ok) {
           return response;
         }
         this.disabled = false;
         this.draft = '';
+        this._includeComments = true;
         this.fire('send', null, {bubbles: false});
         return accountAdditions;
-      }.bind(this)).catch(function(err) {
+      }).catch(err => {
         this.disabled = false;
         throw err;
-      }.bind(this));
+      });
     },
 
-    _focusOn: function(section) {
+    _focusOn(section) {
       if (section === FocusTarget.ANY) {
         section = this._chooseFocusTarget();
       }
       if (section === FocusTarget.BODY) {
-        var textarea = this.$.textarea;
+        const textarea = this.$.textarea;
         textarea.async(textarea.textarea.focus.bind(textarea.textarea));
       } else if (section === FocusTarget.REVIEWERS) {
-        var reviewerEntry = this.$.reviewers.focusStart;
+        const reviewerEntry = this.$.reviewers.focusStart;
         reviewerEntry.async(reviewerEntry.focus);
       } else if (section === FocusTarget.CCS) {
-        var ccEntry = this.$$('#ccs').focusStart;
+        const ccEntry = this.$$('#ccs').focusStart;
         ccEntry.async(ccEntry.focus);
       }
     },
 
-    _chooseFocusTarget: function() {
+    _chooseFocusTarget() {
       // If we are the owner and the reviewers field is empty, focus on that.
       if (this._account && this.change && this.change.owner &&
           this._account._account_id === this.change.owner._account_id &&
@@ -340,7 +380,7 @@
       return FocusTarget.BODY;
     },
 
-    _handle400Error: function(response) {
+    _handle400Error(response) {
       // A call to _saveReview could fail with a server error if erroneous
       // reviewers were requested. This is signalled with a 400 Bad Request
       // status. The default gr-rest-api-interface error handling would
@@ -356,126 +396,87 @@
 
       if (response.status !== 400) {
         // This is all restAPI does when there is no custom error handling.
-        this.fire('server-error', {response: response});
+        this.fire('server-error', {response});
         return response;
       }
 
       // Process the response body, format a better error message, and fire
       // an event for gr-event-manager to display.
-      var jsonPromise = this.$.restAPI.getResponseObject(response);
-      return jsonPromise.then(function(result) {
-        var errors = [];
-        ['reviewers', 'ccs'].forEach(function(state) {
-          for (var input in result[state]) {
-            var reviewer = result[state][input];
-            if (!!reviewer.error) {
+      const jsonPromise = this.$.restAPI.getResponseObject(response);
+      return jsonPromise.then(result => {
+        const errors = [];
+        for (const state of ['reviewers', 'ccs']) {
+          if (!result.hasOwnProperty(state)) { continue; }
+          for (const reviewer of Object.values(result[state])) {
+            if (reviewer.error) {
               errors.push(reviewer.error);
             }
           }
-        });
+        }
         response = {
           ok: false,
           status: response.status,
-          text: function() { return Promise.resolve(errors.join(', ')); },
+          text() { return Promise.resolve(errors.join(', ')); },
         };
-        this.fire('server-error', {response: response});
-      }.bind(this));
+        this.fire('server-error', {response});
+      });
     },
 
-    _computeHideDraftList: function(drafts) {
+    _computeHideDraftList(drafts) {
       return Object.keys(drafts || {}).length == 0;
     },
 
-    _computeDraftsTitle: function(drafts) {
-      var total = 0;
-      for (var file in drafts) {
-        total += drafts[file].length;
+    _computeDraftsTitle(drafts) {
+      let total = 0;
+      for (const file in drafts) {
+        if (drafts.hasOwnProperty(file)) {
+          total += drafts[file].length;
+        }
       }
       if (total == 0) { return ''; }
       if (total == 1) { return '1 Draft'; }
       if (total > 1) { return total + ' Drafts'; }
     },
 
-    _computeLabelValueTitle: function(labels, label, value) {
-      return labels[label] && labels[label].values[value];
+    _computeMessagePlaceholder(canBeStarted) {
+      return canBeStarted ?
+        'Add a note for your reviewers...' :
+        'Say something nice...';
     },
 
-    _computeLabels: function(labelRecord) {
-      var labelsObj = labelRecord.base;
-      if (!labelsObj) { return []; }
-      return Object.keys(labelsObj).sort().map(function(key) {
-        return {
-          name: key,
-          value: this._getVoteForAccount(labelsObj, key, this._account),
-        };
-      }.bind(this));
-    },
-
-    _getVoteForAccount: function(labels, labelName, account) {
-      var votes = labels[labelName];
-      if (votes.all && votes.all.length > 0) {
-        for (var i = 0; i < votes.all.length; i++) {
-          if (votes.all[i]._account_id == account._account_id) {
-            return votes.all[i].value;
-          }
-        }
-      }
-      return null;
-    },
-
-    _computeIndexOfLabelValue: function(labels, permittedLabels, label) {
-      if (!labels[label.name]) { return null; }
-      var labelValue = label.value;
-      var len = permittedLabels[label.name] != null ?
-          permittedLabels[label.name].length : 0;
-      for (var i = 0; i < len; i++) {
-        var val = parseInt(permittedLabels[label.name][i], 10);
-        if (val == labelValue) {
-          return i;
-        }
-      }
-      return null;
-    },
-
-    _computePermittedLabelValues: function(permittedLabels, label) {
-      return permittedLabels[label];
-    },
-
-    _computeAnyPermittedLabelValues: function(permittedLabels, label) {
-      return permittedLabels.hasOwnProperty(label);
-    },
-
-    _changeUpdated: function(changeRecord, owner, serverConfig) {
+    _changeUpdated(changeRecord, owner, serverConfig) {
       this._rebuildReviewerArrays(changeRecord.base, owner, serverConfig);
     },
 
-    _rebuildReviewerArrays: function(change, owner, serverConfig) {
+    _rebuildReviewerArrays(change, owner, serverConfig) {
       this._owner = owner;
 
-      var reviewers = [];
-      var ccs = [];
+      let reviewers = [];
+      const ccs = [];
 
-      for (var key in change) {
-        if (key !== 'REVIEWER' && key !== 'CC') {
-          console.warn('unexpected reviewer state:', key);
-          continue;
+      for (const key in change) {
+        if (change.hasOwnProperty(key)) {
+          if (key !== 'REVIEWER' && key !== 'CC') {
+            console.warn('unexpected reviewer state:', key);
+            continue;
+          }
+          for (const entry of change[key]) {
+            if (entry._account_id === owner._account_id) {
+              continue;
+            }
+            switch (key) {
+              case 'REVIEWER':
+                reviewers.push(entry);
+                break;
+              case 'CC':
+                ccs.push(entry);
+                break;
+            }
+          }
         }
-        change[key].forEach(function(entry) {
-          if (entry._account_id === owner._account_id) {
-            return;
-          }
-          switch (key) {
-            case 'REVIEWER':
-              reviewers.push(entry);
-              break;
-            case 'CC':
-              ccs.push(entry);
-              break;
-          }
-        });
       }
 
-      if (serverConfig.note_db_enabled) {
+      if (this._ccsEnabled) {
         this._ccs = ccs;
       } else {
         this._ccs = [];
@@ -484,12 +485,12 @@
       this._reviewers = reviewers;
     },
 
-    _accountOrGroupKey: function(entry) {
+    _accountOrGroupKey(entry) {
       return entry.id || entry._account_id;
     },
 
-    _filterReviewerSuggestion: function(suggestion) {
-      var entry;
+    _filterReviewerSuggestion(suggestion) {
+      let entry;
       if (suggestion.account) {
         entry = suggestion.account;
       } else if (suggestion.group) {
@@ -502,40 +503,74 @@
         return false;
       }
 
-      var key = this._accountOrGroupKey(entry);
-      var finder = function(entry) {
-        return this._accountOrGroupKey(entry) === key;
-      }.bind(this);
+      const key = this._accountOrGroupKey(entry);
+      const finder = entry => this._accountOrGroupKey(entry) === key;
 
       return this._reviewers.find(finder) === undefined &&
           this._ccs.find(finder) === undefined;
     },
 
-    _getAccount: function() {
+    _getAccount() {
       return this.$.restAPI.getAccount();
     },
 
-    _cancelTapHandler: function(e) {
+    _cancelTapHandler(e) {
       e.preventDefault();
+      this.cancel();
+    },
+
+    cancel() {
       this.fire('cancel', null, {bubbles: false});
       this._purgeReviewersPendingRemove(true);
       this._rebuildReviewerArrays(this.change.reviewers, this._owner,
           this.serverConfig);
     },
 
-    _sendTapHandler: function(e) {
+    _saveTapHandler(e) {
       e.preventDefault();
-      this.send().then(function(keep) {
-        this._purgeReviewersPendingRemove(false, keep);
-      }.bind(this));
+      if (this._ccsEnabled && !this.$$('#ccs').submitEntryText()) {
+        // Do not proceed with the save if there is an invalid email entry in
+        // the text field of the CC entry.
+        return;
+      }
+      this.send(this._includeComments).then(keepReviewers => {
+        this._purgeReviewersPendingRemove(false, keepReviewers);
+      });
     },
 
-    _saveReview: function(review, opt_errFn) {
+    _sendTapHandler(e) {
+      e.preventDefault();
+      if (this._ccsEnabled && !this.$$('#ccs').submitEntryText()) {
+        // Do not proceed with the send if there is an invalid email entry in
+        // the text field of the CC entry.
+        return;
+      }
+      if (this.canBeStarted) {
+        this._startReview().then(() => {
+          return this.send(this._includeComments);
+        }).then(keepReviewers => {
+          this._purgeReviewersPendingRemove(false, keepReviewers);
+        });
+        return;
+      }
+      this.send(this._includeComments).then(keepReviewers => {
+        this._purgeReviewersPendingRemove(false, keepReviewers);
+      });
+    },
+
+    _saveReview(review, opt_errFn) {
       return this.$.restAPI.saveChangeReview(this.change._number, this.patchNum,
           review, opt_errFn);
     },
 
-    _reviewerPendingConfirmationUpdated: function(reviewer) {
+    _startReview() {
+      if (!this.canBeStarted) {
+        return Promise.resolve();
+      }
+      return this.$.restAPI.startReview(this.change._number);
+    },
+
+    _reviewerPendingConfirmationUpdated(reviewer) {
       if (reviewer === null) {
         this.$.reviewerConfirmationOverlay.close();
       } else {
@@ -545,7 +580,7 @@
       }
     },
 
-    _confirmPendingReviewer: function() {
+    _confirmPendingReviewer() {
       if (this._ccPendingConfirmation) {
         this.$$('#ccs').confirmGroup(this._ccPendingConfirmation.group);
         this._focusOn(FocusTarget.CCS);
@@ -555,16 +590,16 @@
       }
     },
 
-    _cancelPendingReviewer: function() {
+    _cancelPendingReviewer() {
       this._ccPendingConfirmation = null;
       this._reviewerPendingConfirmation = null;
 
-      var target =
+      const target =
           this._ccPendingConfirmation ? FocusTarget.CCS : FocusTarget.REVIEWERS;
       this._focusOn(target);
     },
 
-    _getStorageLocation: function() {
+    _getStorageLocation() {
       // Tests trigger this method without setting change.
       if (!this.change) { return {}; }
       return {
@@ -574,13 +609,13 @@
       };
     },
 
-    _loadStoredDraft: function() {
-      var draft = this.$.storage.getDraftComment(this._getStorageLocation());
+    _loadStoredDraft() {
+      const draft = this.$.storage.getDraftComment(this._getStorageLocation());
       return draft ? draft.message : '';
     },
 
-    _draftChanged: function(newDraft, oldDraft) {
-      this.debounce('store', function() {
+    _draftChanged(newDraft, oldDraft) {
+      this.debounce('store', () => {
         if (!newDraft.length && oldDraft) {
           // If the draft has been modified to be empty, then erase the storage
           // entry.
@@ -592,11 +627,28 @@
       }, STORAGE_DEBOUNCE_INTERVAL_MS);
     },
 
-    _handleHeightChanged: function(e) {
+    _handleHeightChanged(e) {
       // If the textarea resizes, we need to re-fit the overlay.
-      this.debounce('autogrow', function() {
+      this.debounce('autogrow', () => {
         this.fire('autogrow');
       });
     },
+
+    _isState(knownLatestState, value) {
+      return knownLatestState === value;
+    },
+
+    _reload() {
+      // Load the current change without any patch range.
+      location.href = this.getBaseUrl() + '/c/' + this.change._number;
+    },
+
+    _computeSendButtonLabel(canBeStarted) {
+      return canBeStarted ? 'Start review' : 'Send';
+    },
+
+    _computeCCsEnabled(serverConfig) {
+      return serverConfig && serverConfig.note_db_enabled;
+    },
   });
 })();
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 5aa5848..ec2833a 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
@@ -33,36 +33,36 @@
 </test-fixture>
 
 <script>
-  suite('gr-reply-dialog tests', function() {
-    var element;
-    var changeNum;
-    var patchNum;
+  suite('gr-reply-dialog tests', () => {
+    let element;
+    let changeNum;
+    let patchNum;
 
-    var sandbox;
-    var getDraftCommentStub;
-    var setDraftCommentStub;
-    var eraseDraftCommentStub;
+    let sandbox;
+    let getDraftCommentStub;
+    let setDraftCommentStub;
+    let eraseDraftCommentStub;
 
-    var lastId = 0;
-    var makeAccount = function() { return {_account_id: lastId++}; };
-    var makeGroup = function() { return {id: lastId++}; };
+    let lastId = 0;
+    const makeAccount = function() { return {_account_id: lastId++}; };
+    const makeGroup = function() { return {id: lastId++}; };
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
 
       changeNum = 42;
       patchNum = 1;
 
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
-        getAccount: function() { return Promise.resolve({}); },
+        getConfig() { return Promise.resolve({}); },
+        getAccount() { return Promise.resolve({}); },
       });
 
       element = fixture('basic');
       element.change = {
         _number: changeNum,
         labels: {
-          Verified: {
+          'Verified': {
             values: {
               '-1': 'Fails',
               ' 0': 'No score',
@@ -89,7 +89,7 @@
           ' 0',
           '+1',
         ],
-        Verified: [
+        'Verified': [
           '-1',
           ' 0',
           '+1',
@@ -102,85 +102,126 @@
       eraseDraftCommentStub = sandbox.stub(element.$.storage,
           'eraseDraftComment');
 
+      sandbox.stub(element, 'fetchIsLatestKnown',
+          () => { return Promise.resolve(true); });
+
       // Allow the elements created by dom-repeat to be stamped.
       flushAsynchronousOperations();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('changes in label score are reflected in the DOM', function() {
-      element._account = {_account_id: 1};
-      element.set(['change', 'labels', 'Verified', 'all'],
-          [{_account_id: 1, value: -1}]);
-      flushAsynchronousOperations();
-      var selector = element.$$('iron-selector[data-label="Verified"]');
-      assert.equal(selector.selected, 0); // Index 0, value -1
-      element.set(['change', 'labels', 'Verified', 'all'],
-         [{_account_id: 1, value: 1}]);
-      flushAsynchronousOperations();
-      assert.equal(selector.selected, 2); // Index 2, value 1
-    });
-
-    test('cancel event', function(done) {
-      element.addEventListener('cancel', function() { done(); });
-      MockInteractions.tap(element.$$('.cancel'));
-    });
-
-    test('label picker', function(done) {
-      element.revisions = {};
-      element.patchNum = '';
-
+    test('default to publishing drafts with reply', done => {
       // Async tick is needed because iron-selector content is distributed and
       // distributed content requires an observer to be set up.
       // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
-      flush(function() {
-        flush(function() {
-          for (var label in element.permittedLabels) {
-            assert.ok(element.$$('iron-selector[data-label="' + label + '"]'),
-                label);
-          }
+      flush(() => {
+        flush(() => {
           element.draft = 'I wholeheartedly disapprove';
-          MockInteractions.tap(element.$$(
-              'iron-selector[data-label="Code-Review"] > ' +
-              'gr-button[data-value="-1"]'));
-          MockInteractions.tap(element.$$(
-              'iron-selector[data-label="Verified"] > ' +
-              'gr-button[data-value="-1"]'));
 
-          var saveReviewStub = sinon.stub(element, '_saveReview',
-              function(review) {
+          sandbox.stub(element, '_saveReview', review => {
             assert.deepEqual(review, {
               drafts: 'PUBLISH_ALL_REVISIONS',
-              labels: {
-                'Code-Review': -1,
-                'Verified': -1,
-              },
+              labels: {},
               message: 'I wholeheartedly disapprove',
               reviewers: [],
             });
-            return Promise.resolve({ok: true});
-          });
-
-          element.addEventListener('send', function() {
-            assert.isFalse(element.disabled,
-                'Element should be enabled when done sending reply.');
-            assert.equal(element.draft.length, 0);
-            saveReviewStub.restore();
+            assert.isFalse(element.$.commentList.hidden);
             done();
+            return Promise.resolve({ok: true});
           });
 
           // This is needed on non-Blink engines most likely due to the ways in
           // which the dom-repeat elements are stamped.
-          flush(function() {
+          flush(() => {
             MockInteractions.tap(element.$$('.send'));
-            assert.isTrue(element.disabled);
           });
         });
       });
     });
 
+    test('keep drafts with reply', done => {
+      MockInteractions.tap(element.$$('#includeComments'));
+      assert.equal(element._includeComments, false);
+
+      // Async tick is needed because iron-selector content is distributed and
+      // distributed content requires an observer to be set up.
+      // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
+      flush(() => {
+        flush(() => {
+          element.draft = 'I wholeheartedly disapprove';
+
+          sandbox.stub(element, '_saveReview', review => {
+            assert.deepEqual(review, {
+              drafts: 'KEEP',
+              labels: {},
+              message: 'I wholeheartedly disapprove',
+              reviewers: [],
+            });
+            assert.isTrue(element.$.commentList.hidden);
+            done();
+            return Promise.resolve({ok: true});
+          });
+
+          // This is needed on non-Blink engines most likely due to the ways in
+          // which the dom-repeat elements are stamped.
+          flush(() => {
+            MockInteractions.tap(element.$$('.send'));
+          });
+        });
+      });
+    });
+
+    test('label picker', done => {
+      element.draft = 'I wholeheartedly disapprove';
+      sandbox.stub(element, '_saveReview', review => {
+        assert.deepEqual(review, {
+          drafts: 'PUBLISH_ALL_REVISIONS',
+          labels: {
+            'Code-Review': -1,
+            'Verified': -1,
+          },
+          message: 'I wholeheartedly disapprove',
+          reviewers: [],
+        });
+        return Promise.resolve({ok: true});
+      });
+
+      sandbox.stub(element.$.labelScores, 'getLabelValues', () => {
+        return {
+          'Code-Review': -1,
+          'Verified': -1,
+        };
+      });
+
+      element.addEventListener('send', () => {
+        assert.isFalse(element.disabled,
+            'Element should be enabled when done sending reply.');
+        assert.equal(element.draft.length, 0);
+        done();
+      });
+
+      // This is needed on non-Blink engines most likely due to the ways in
+      // which the dom-repeat elements are stamped.
+      flush(() => {
+        MockInteractions.tap(element.$$('.send'));
+        assert.isTrue(element.disabled);
+      });
+    });
+
+    test('setlabelValue', () => {
+      element._account = {_account_id: 1};
+      flushAsynchronousOperations();
+      const label = 'Verified';
+      const value = '+1';
+      element.setLabelValue(label, value);
+      flushAsynchronousOperations();
+      const labels = element.$.labelScores.getLabelValues();
+      assert.deepEqual(labels, {Verified: 1});
+    });
+
     function getActiveElement() {
       return Polymer.IronOverlayManager.deepActiveElement;
     }
@@ -191,7 +232,7 @@
     }
 
     function overlayObserver(mode) {
-      return new Promise(function(resolve) {
+      return new Promise(resolve => {
         function listener() {
           element.removeEventListener('iron-overlay-' + mode, listener);
           resolve();
@@ -201,9 +242,9 @@
     }
 
     function testConfirmationDialog(done, cc) {
-      var yesButton =
+      const yesButton =
           element.$$('.reviewerConfirmationButtons gr-button:first-child');
-      var noButton =
+      const noButton =
           element.$$('.reviewerConfirmationButtons gr-button:last-child');
 
       element.serverConfig = {note_db_enabled: true};
@@ -213,19 +254,19 @@
       assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
 
       // Cause the confirmation dialog to display.
-      var observer = overlayObserver('opened');
-      var group = {
+      let observer = overlayObserver('opened');
+      const group = {
         id: 'id',
         name: 'name',
       };
       if (cc) {
         element._ccPendingConfirmation = {
-          group: group,
+          group,
           count: 10,
         };
       } else {
         element._reviewerPendingConfirmation = {
-          group: group,
+          group,
           count: 10,
         };
       }
@@ -241,16 +282,16 @@
             element._pendingConfirmationDetails);
       }
 
-      observer.then(function() {
+      observer.then(() => {
         assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
         observer = overlayObserver('closed');
-        var expected = 'Group name has 10 members';
+        const expected = 'Group name has 10 members';
         assert.notEqual(
             element.$.reviewerConfirmationOverlay.innerText.indexOf(expected),
             -1);
         MockInteractions.tap(noButton); // close the overlay
         return observer;
-      }).then(function() {
+      }).then(() => {
         assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
 
         // We should be focused on account entry input.
@@ -264,24 +305,24 @@
         observer = overlayObserver('opened');
         if (cc) {
           element._ccPendingConfirmation = {
-            group: group,
+            group,
             count: 10,
           };
         } else {
           element._reviewerPendingConfirmation = {
-            group: group,
+            group,
             count: 10,
           };
         }
         return observer;
-      }).then(function() {
+      }).then(() => {
         assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
         observer = overlayObserver('closed');
         MockInteractions.tap(yesButton); // Confirm the group.
         return observer;
-      }).then(function() {
+      }).then(() => {
         assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
-        var additions = cc ?
+        const additions = cc ?
             element.$$('#ccs').additions() :
             element.$.reviewers.additions();
         assert.deepEqual(
@@ -301,41 +342,41 @@
         // We should be focused on account entry input.
         assert.equal(getActiveElement().id, 'input');
       }).then(done);
-    };
+    }
 
-    test('cc confirmation', function(done) {
+    test('cc confirmation', done => {
       testConfirmationDialog(done, true);
     });
 
-    test('reviewer confirmation', function(done) {
+    test('reviewer confirmation', done => {
       testConfirmationDialog(done, false);
     });
 
-    test('_getStorageLocation', function() {
-      var actual = element._getStorageLocation();
+    test('_getStorageLocation', () => {
+      const actual = element._getStorageLocation();
       assert.equal(actual.changeNum, changeNum);
       assert.equal(actual.patchNum, patchNum);
       assert.equal(actual.path, '@change');
     });
 
-    test('gets draft from storage on open', function() {
-      var storedDraft = 'hello world';
+    test('gets draft from storage on open', () => {
+      const storedDraft = 'hello world';
       getDraftCommentStub.returns({message: storedDraft});
       element.open();
       assert.isTrue(getDraftCommentStub.called);
       assert.equal(element.draft, storedDraft);
     });
 
-    test('blank if no stored draft', function() {
+    test('blank if no stored draft', () => {
       getDraftCommentStub.returns(null);
       element.open();
       assert.isTrue(getDraftCommentStub.called);
       assert.equal(element.draft, '');
     });
 
-    test('updates stored draft on edits', function() {
-      var firstEdit = 'hello';
-      var location = element._getStorageLocation();
+    test('updates stored draft on edits', () => {
+      const firstEdit = 'hello';
+      const location = element._getStorageLocation();
 
       element.draft = firstEdit;
       element.flushDebouncer('store');
@@ -348,32 +389,33 @@
       assert.isTrue(eraseDraftCommentStub.calledWith(location));
     });
 
-    test('400 converts to human-readable server-error', function(done) {
-      sandbox.stub(window, 'fetch', function() {
-        var text = '....{"reviewers":{"id1":{"error":"first error"}},' +
+    test('400 converts to human-readable server-error', done => {
+      sandbox.stub(window, 'fetch', () => {
+        const text = '....{"reviewers":{"id1":{"error":"first error"}},' +
           '"ccs":{"id2":{"error":"second error"}}}';
         return Promise.resolve({
           ok: false,
           status: 400,
-          text: function() { return Promise.resolve(text); },
+          text() { return Promise.resolve(text); },
         });
       });
 
-      element.addEventListener('server-error', function(event) {
+      element.addEventListener('server-error', event => {
         if (event.target !== element) {
           return;
         }
-        event.detail.response.text().then(function(body) {
+        event.detail.response.text().then(body => {
           assert.equal(body, 'first error, second error');
+          done();
         });
       });
 
       // Async tick is needed because iron-selector content is distributed and
       // distributed content requires an observer to be set up.
-      flush(function() { element.send().then(done); });
+      flush(() => { element.send(); });
     });
 
-    test('ccs are displayed if NoteDb is enabled', function() {
+    test('ccs are displayed if NoteDb is enabled', () => {
       function hasCc() {
         flushAsynchronousOperations();
         return !!element.$$('#ccs');
@@ -386,12 +428,12 @@
       assert.isTrue(hasCc());
     });
 
-    test('filterReviewerSuggestion', function() {
-      var owner = makeAccount();
-      var reviewer1 = makeAccount();
-      var reviewer2 = makeGroup();
-      var cc1 = makeAccount();
-      var cc2 = makeGroup();
+    test('filterReviewerSuggestion', () => {
+      const owner = makeAccount();
+      const reviewer1 = makeAccount();
+      const reviewer2 = makeGroup();
+      const cc1 = makeAccount();
+      const cc2 = makeGroup();
 
       element._owner = owner;
       element._reviewers = [reviewer1, reviewer2];
@@ -413,7 +455,7 @@
       assert.isFalse(element._filterReviewerSuggestion({group: cc2}));
     });
 
-    test('_chooseFocusTarget', function() {
+    test('_chooseFocusTarget', () => {
       element._account = null;
       assert.strictEqual(
           element._chooseFocusTarget(), element.FocusTarget.BODY);
@@ -440,39 +482,38 @@
           element._chooseFocusTarget(), element.FocusTarget.BODY);
     });
 
-    test('only send labels that have changed', function(done) {
-      flush(function() {
-        var saveReviewStub = sinon.stub(element, '_saveReview',
-            function(review) {
+    test('only send labels that have changed', done => {
+      flush(() => {
+        sandbox.stub(element, '_saveReview', review => {
           assert.deepEqual(review.labels, {Verified: -1});
           return Promise.resolve({ok: true});
         });
 
-        element.addEventListener('send', function() {
-          saveReviewStub.restore();
+        element.addEventListener('send', () => {
           done();
         });
         // Without wrapping this test in flush(), the below two calls to
         // MockInteractions.tap() cause a race in some situations in shadow DOM.
         // The send button can be tapped before the others, causing the test to
         // fail.
-        MockInteractions.tap(element.$$(
+
+        MockInteractions.tap(element.$$('gr-label-scores').$$(
             'iron-selector[data-label="Verified"] > ' +
             'gr-button[data-value="-1"]'));
         MockInteractions.tap(element.$$('.send'));
       });
     });
 
-    test('do not display tooltips on touch devices', function() {
+    test('do not display tooltips on touch devices', () => {
       element._account = {_account_id: 1};
       element.set(['change', 'labels', 'Verified', 'all'],
           [{_account_id: 1, value: -1}]);
       element.labels = {
-        Verified: {
+        'Verified': {
           values: {
             '-1': 'Fails',
             ' 0': 'No score',
-            '+1': 'Verified'
+            '+1': 'Verified',
           },
           default_value: 0,
         },
@@ -482,7 +523,7 @@
             '-1': 'I would prefer that you didn\'t submit this',
             ' 0': 'No score',
             '+1': 'Looks good to me, but someone else must approve',
-            '+2': 'Looks good to me, approved'
+            '+2': 'Looks good to me, approved',
           },
           default_value: 0,
         },
@@ -490,7 +531,7 @@
 
       flushAsynchronousOperations();
 
-      var verifiedBtn = element.$$(
+      const verifiedBtn = element.$$('gr-label-scores').$$(
           'iron-selector[data-label="Verified"] > ' +
           'gr-button[data-value="-1"]');
 
@@ -509,8 +550,8 @@
       assert.isNotOk(verifiedBtn._tooltip);
     });
 
-    test('_processReviewerChange', function() {
-      var mockIndexSplices = function(toRemove) {
+    test('_processReviewerChange', () => {
+      const mockIndexSplices = function(toRemove) {
         return [{
           removed: [toRemove],
         }];
@@ -521,16 +562,16 @@
       assert.equal(element._reviewersPendingRemove.REVIEWER.length, 1);
     });
 
-    test('_purgeReviewersPendingRemove', function() {
-      var removeStub = sandbox.stub(element, '_removeAccount');
-      var mock = function() {
+    test('_purgeReviewersPendingRemove', () => {
+      const removeStub = sandbox.stub(element, '_removeAccount');
+      const mock = function() {
         element._reviewersPendingRemove = {
           test: [makeAccount()],
           test2: [makeAccount(), makeAccount()],
         };
       };
-      var checkObjEmpty = function(obj) {
-        for (var prop in obj) {
+      const checkObjEmpty = function(obj) {
+        for (const prop in obj) {
           if (obj.hasOwnProperty(prop) && obj[prop].length) { return false; }
         }
         return true;
@@ -546,48 +587,47 @@
       assert.isTrue(checkObjEmpty(element._reviewersPendingRemove));
     });
 
-    test('_removeAccount', function(done) {
+    test('_removeAccount', done => {
       sandbox.stub(element.$.restAPI, 'removeChangeReviewer')
           .returns(Promise.resolve({ok: true}));
-      var arr = [makeAccount(), makeAccount()];
+      const arr = [makeAccount(), makeAccount()];
       element.change.reviewers = {
         REVIEWER: arr.slice(),
       };
 
-      element._removeAccount(arr[1], 'REVIEWER').then(function() {
+      element._removeAccount(arr[1], 'REVIEWER').then(() => {
         assert.equal(element.change.reviewers.REVIEWER.length, 1);
         assert.deepEqual(element.change.reviewers.REVIEWER, arr.slice(0, 1));
         done();
       });
     });
 
-    test('migrate reviewers between states', function(done) {
+    test('migrate reviewers between states', done => {
       element.serverConfig = {note_db_enabled: true};
       element._reviewersPendingRemove = {
         CC: [],
         REVIEWER: [],
       };
       flushAsynchronousOperations();
-      var reviewers = element.$.reviewers;
-      var ccs = element.$$('#ccs');
-      var reviewer1 = makeAccount();
-      var reviewer2 = makeAccount();
-      var cc1 = makeAccount();
-      var cc2 = makeAccount();
+      const reviewers = element.$.reviewers;
+      const ccs = element.$$('#ccs');
+      const reviewer1 = makeAccount();
+      const reviewer2 = makeAccount();
+      const cc1 = makeAccount();
+      const cc2 = makeAccount();
+      const cc3 = makeAccount();
       element._reviewers = [reviewer1, reviewer2];
-      element._ccs = [cc1, cc2];
+      element._ccs = [cc1, cc2, cc3];
 
-      var mutations = [];
+      const mutations = [];
 
-      var saveReviewStub = sandbox.stub(element, '_saveReview',
-          function(review) {
-        mutations.push.apply(mutations, review.reviewers);
+      sandbox.stub(element, '_saveReview', review => {
+        mutations.push(...review.reviewers);
         return Promise.resolve({ok: true});
       });
 
-      var removeAccountStub = sandbox.stub(element, '_removeAccount',
-          function(account, type) {
-        mutations.push({state: 'REMOVED', account: account});
+      sandbox.stub(element, '_removeAccount', (account, type) => {
+        mutations.push({state: 'REMOVED', account});
         return Promise.resolve();
       });
 
@@ -595,33 +635,123 @@
       reviewers.fire('remove', {account: reviewer1});
       ccs.$.entry.fire('add', {value: {account: reviewer1}});
       ccs.fire('remove', {account: cc1});
+      ccs.fire('remove', {account: cc3});
       reviewers.$.entry.fire('add', {value: {account: cc1}});
 
       // Add to other field without removing from former field.
       // (Currently not possible in UI, but this is a good consistency check).
       reviewers.$.entry.fire('add', {value: {account: cc2}});
       ccs.$.entry.fire('add', {value: {account: reviewer2}});
-      var mapReviewer = function(reviewer, opt_state) {
-        var result = {reviewer: reviewer._account_id, confirmed: undefined};
+      const mapReviewer = function(reviewer, opt_state) {
+        const result = {reviewer: reviewer._account_id, confirmed: undefined};
         if (opt_state) {
           result.state = opt_state;
         }
         return result;
       };
 
-      // Send and purge and verify moves without deletions.
+      // Send and purge and verify moves, delete cc3.
       element.send()
-          .then(element._purgeReviewersPendingRemove.bind(element))
-          .then(function() {
-        assert.deepEqual(
-            mutations, [
-                mapReviewer(cc1),
-                mapReviewer(cc2),
-                mapReviewer(reviewer1, 'CC'),
-                mapReviewer(reviewer2, 'CC'),
-            ]);
-        done();
+          .then(keepReviewers =>
+              element._purgeReviewersPendingRemove(false, keepReviewers))
+          .then(() => {
+            assert.deepEqual(
+                mutations, [
+                  mapReviewer(cc1),
+                  mapReviewer(cc2),
+                  mapReviewer(reviewer1, 'CC'),
+                  mapReviewer(reviewer2, 'CC'),
+                  {account: cc3, state: 'REMOVED'},
+                ]);
+            done();
+          });
+    });
+
+    test('emits cancel on esc key', () => {
+      const cancelHandler = sandbox.spy();
+      element.addEventListener('cancel', cancelHandler);
+      MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'esc');
+      flushAsynchronousOperations();
+
+      assert.isTrue(cancelHandler.called);
+    });
+
+    test('_computeMessagePlaceholder', () => {
+      assert.equal(
+          element._computeMessagePlaceholder(false),
+          'Say something nice...');
+      assert.equal(
+          element._computeMessagePlaceholder(true),
+          'Add a note for your reviewers...');
+    });
+
+    test('_computeSendButtonLabel', () => {
+      assert.equal(
+          element._computeSendButtonLabel(false),
+          'Send');
+      assert.equal(
+          element._computeSendButtonLabel(true),
+          'Start review');
+    });
+
+    test('_handle400Error reviewrs and CCs', done => {
+      const error1 = 'error 1';
+      const error2 = 'error 2';
+      const error3 = 'error 3';
+      const response = {
+        status: 400,
+        text() {
+          return Promise.resolve(')]}\'' + JSON.stringify({
+            reviewers: {
+              username1: {
+                input: 'user 1',
+                error: error1,
+              },
+              username2: {
+                input: 'user 2',
+                error: error2,
+              },
+            },
+            ccs: {
+              username3: {
+                input: 'user 3',
+                error: error3,
+              },
+            },
+          }));
+        },
+      };
+      element.addEventListener('server-error', e => {
+        e.detail.response.text().then(text => {
+          assert.equal(text, [error1, error2, error3].join(', '));
+          done();
+        });
       });
+      element._handle400Error(response);
+    });
+
+    test('_handle400Error CCs only', done => {
+      const error1 = 'error 1';
+      const response = {
+        status: 400,
+        text() {
+          return Promise.resolve(')]}\'' + JSON.stringify({
+            ccs: {
+              username1: {
+                input: 'user 1',
+                error: error1,
+              },
+            },
+          }));
+        },
+      };
+      element.addEventListener('server-error', e => {
+        e.detail.response.text().then(text => {
+          assert.equal(text, error1);
+          done();
+        });
+      });
+      element._handle400Error(response);
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
index 435b7de..c77c783 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
@@ -33,6 +33,9 @@
       .autocompleteContainer {
         position: relative;
       }
+      .hiddenReviewers {
+        margin-top: .3em;
+      }
       .inputContainer {
         display: flex;
         margin-top: .25em;
@@ -56,13 +59,17 @@
         }
       }
     </style>
-    <template is="dom-repeat" items="[[_reviewers]]" as="reviewer">
+    <template is="dom-repeat" items="[[_displayedReviewers]]" as="reviewer">
       <gr-account-chip class="reviewer" account="[[reviewer]]"
           on-remove="_handleRemove"
-          data-account-id$="[[reviewer._account_id]]"
           removable="[[_computeCanRemoveReviewer(reviewer, mutable)]]">
       </gr-account-chip>
     </template>
+    <gr-button
+        class="hiddenReviewers"
+        link
+        hidden$="[[!_hiddenReviewerCount]]"
+        on-tap="_handleViewAll">and [[_hiddenReviewerCount]] more</gr-button>
     <div class="controlsContainer" hidden$="[[!mutable]]">
       <gr-button
           link
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
index 72a7c9b..5ef99dd 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
@@ -14,6 +14,8 @@
 (function() {
   'use strict';
 
+  const MAX_REVIEWERS_DISPLAYED = 10;
+
   Polymer({
     is: 'gr-reviewer-list',
 
@@ -43,9 +45,13 @@
         value: false,
       },
 
+      _displayedReviewers: {
+        type: Array,
+        value() { return []; },
+      },
       _reviewers: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       _showInput: {
         type: Boolean,
@@ -55,6 +61,11 @@
         type: String,
         computed: '_computeAddLabel(ccsOnly)',
       },
+      _hiddenReviewerCount: {
+        type: Number,
+        computed: '_computeHiddenCount(_reviewers, _displayedReviewers)',
+      },
+
 
       // Used for testing.
       _lastAutocompleteRequest: Object,
@@ -65,10 +76,10 @@
       '_reviewersChanged(change.reviewers.*, change.owner)',
     ],
 
-    _reviewersChanged: function(changeRecord, owner) {
-      var result = [];
-      var reviewers = changeRecord.base;
-      for (var key in reviewers) {
+    _reviewersChanged(changeRecord, owner) {
+      let result = [];
+      const reviewers = changeRecord.base;
+      for (const key in reviewers) {
         if (this.reviewersOnly && key !== 'REVIEWER') {
           continue;
         }
@@ -79,66 +90,80 @@
           result = result.concat(reviewers[key]);
         }
       }
-      this._reviewers = result.filter(function(reviewer) {
+      this._reviewers = result.filter(reviewer => {
         return reviewer._account_id != owner._account_id;
       });
+      this._displayedReviewers =
+          this._reviewers.slice(0, MAX_REVIEWERS_DISPLAYED);
     },
 
-    _computeCanRemoveReviewer: function(reviewer, mutable) {
+    _computeHiddenCount(reviewers, displayedReviewers) {
+      return reviewers.length - displayedReviewers.length;
+    },
+
+    _computeCanRemoveReviewer(reviewer, mutable) {
       if (!mutable) { return false; }
 
-      for (var i = 0; i < this.change.removable_reviewers.length; i++) {
-        if (this.change.removable_reviewers[i]._account_id ==
-            reviewer._account_id) {
+      let current;
+      for (let i = 0; i < this.change.removable_reviewers.length; i++) {
+        current = this.change.removable_reviewers[i];
+        if (current._account_id === reviewer._account_id ||
+            (!reviewer._account_id && current.email === reviewer.email)) {
           return true;
         }
       }
       return false;
     },
 
-    _handleRemove: function(e) {
+    _handleRemove(e) {
       e.preventDefault();
-      var target = Polymer.dom(e).rootTarget;
-      var accountID = parseInt(target.getAttribute('data-account-id'), 10);
+      const target = Polymer.dom(e).rootTarget;
+      if (!target.account) { return; }
+      const accountID = target.account._account_id || target.account.email;
       this.disabled = true;
-      this._xhrPromise =
-          this._removeReviewer(accountID).then(function(response) {
+      this._xhrPromise = this._removeReviewer(accountID).then(response => {
         this.disabled = false;
         if (!response.ok) { return response; }
 
-        var reviewers = this.change.reviewers;
-        ['REVIEWER', 'CC'].forEach(function(type) {
+        const reviewers = this.change.reviewers;
+
+        for (const type of ['REVIEWER', 'CC']) {
           reviewers[type] = reviewers[type] || [];
-          for (var i = 0; i < reviewers[type].length; i++) {
-            if (reviewers[type][i]._account_id == accountID) {
+          for (let i = 0; i < reviewers[type].length; i++) {
+            if (reviewers[type][i]._account_id == accountID ||
+            reviewers[type][i].email == accountID) {
               this.splice('change.reviewers.' + type, i, 1);
               break;
             }
           }
-        }, this);
-      }.bind(this)).catch(function(err) {
+        }
+      }).catch(err => {
         this.disabled = false;
         throw err;
-      }.bind(this));
+      });
     },
 
-    _handleAddTap: function(e) {
+    _handleAddTap(e) {
       e.preventDefault();
-      var value = {};
+      const value = {};
       if (this.reviewersOnly) {
         value.reviewersOnly = true;
       }
       if (this.ccsOnly) {
         value.ccsOnly = true;
       }
-      this.fire('show-reply-dialog', {value: value});
+      this.fire('show-reply-dialog', {value});
     },
 
-    _removeReviewer: function(id) {
+    _handleViewAll(e) {
+      this._displayedReviewers = this._reviewers;
+    },
+
+    _removeReviewer(id) {
       return this.$.restAPI.removeChangeReviewer(this.change._number, id);
     },
 
-    _computeAddLabel: function(ccsOnly) {
+    _computeAddLabel(ccsOnly) {
       return ccsOnly ? 'Add CC' : 'Add reviewer';
     },
   });
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
index 4542df8..632edd2 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
@@ -33,47 +33,47 @@
 </test-fixture>
 
 <script>
-  suite('gr-reviewer-list tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-reviewer-list tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
-        removeChangeReviewer: function() {
+        getConfig() { return Promise.resolve({}); },
+        removeChangeReviewer() {
           return Promise.resolve({ok: true});
         },
       });
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('controls hidden on immutable element', function() {
+    test('controls hidden on immutable element', () => {
       element.mutable = false;
       assert.isTrue(element.$$('.controlsContainer').hasAttribute('hidden'));
       element.mutable = true;
       assert.isFalse(element.$$('.controlsContainer').hasAttribute('hidden'));
     });
 
-    test('add reviewer button opens reply dialog', function(done) {
-      element.addEventListener('show-reply-dialog', function() {
+    test('add reviewer button opens reply dialog', done => {
+      element.addEventListener('show-reply-dialog', () => {
         done();
       });
       MockInteractions.tap(element.$$('.addReviewer'));
     });
 
-    test('only show remove for removable reviewers', function() {
+    test('only show remove for removable reviewers', () => {
       element.mutable = true;
       element.change = {
         owner: {
           _account_id: 1,
         },
         reviewers: {
-          'REVIEWER': [
+          REVIEWER: [
             {
               _account_id: 2,
               name: 'Bojack Horseman',
@@ -84,13 +84,16 @@
               name: 'Pinky Penguin',
             },
           ],
-          'CC': [
+          CC: [
             {
               _account_id: 4,
               name: 'Diane Nguyen',
               email: 'macarthurfellow2B@juno.com',
             },
-          ]
+            {
+              email: 'test@e.mail',
+            },
+          ],
         },
         removable_reviewers: [
           {
@@ -102,36 +105,40 @@
             name: 'Diane Nguyen',
             email: 'macarthurfellow2B@juno.com',
           },
-        ]
+          {
+            email: 'test@e.mail',
+          },
+        ],
       };
       flushAsynchronousOperations();
-      var chips =
+      const chips =
           Polymer.dom(element.root).querySelectorAll('gr-account-chip');
-      assert.equal(chips.length, 3);
-      Array.from(chips).forEach(function(el) {
-        var accountID = parseInt(el.getAttribute('data-account-id'), 10);
+      assert.equal(chips.length, 4);
+
+      for (const el of Array.from(chips)) {
+        const accountID = el.account._account_id || el.account.email;
         assert.ok(accountID);
 
-        var buttonEl = el.$$('gr-button');
+        const buttonEl = el.$$('gr-button');
         assert.isNotNull(buttonEl);
         if (accountID == 2) {
           assert.isTrue(buttonEl.hasAttribute('hidden'));
         } else {
           assert.isFalse(buttonEl.hasAttribute('hidden'));
         }
-      });
+      }
     });
 
-    test('tracking reviewers and ccs', function() {
-      var counter = 0;
+    test('tracking reviewers and ccs', () => {
+      let counter = 0;
       function makeAccount() {
         return {_account_id: counter++};
       }
 
-      var owner = makeAccount();
-      var reviewer = makeAccount();
-      var cc = makeAccount();
-      var reviewers = {
+      const owner = makeAccount();
+      const reviewer = makeAccount();
+      const cc = makeAccount();
+      const reviewers = {
         REMOVED: [makeAccount()],
         REVIEWER: [owner, reviewer],
         CC: [owner, cc],
@@ -140,30 +147,30 @@
       element.ccsOnly = false;
       element.reviewersOnly = false;
       element.change = {
-        owner: owner,
-        reviewers: reviewers,
+        owner,
+        reviewers,
       };
       assert.deepEqual(element._reviewers, [reviewer, cc]);
 
       element.reviewersOnly = true;
       element.change = {
-        owner: owner,
-        reviewers: reviewers,
+        owner,
+        reviewers,
       };
       assert.deepEqual(element._reviewers, [reviewer]);
 
       element.ccsOnly = true;
       element.reviewersOnly = false;
       element.change = {
-        owner: owner,
-        reviewers: reviewers,
+        owner,
+        reviewers,
       };
       assert.deepEqual(element._reviewers, [cc]);
     });
 
-    test('_handleAddTap passes mode with event', function() {
-      var fireStub = sandbox.stub(element, 'fire');
-      var e = {preventDefault: function() {}};
+    test('_handleAddTap passes mode with event', () => {
+      const fireStub = sandbox.stub(element, 'fire');
+      const e = {preventDefault() {}};
 
       element.ccsOnly = false;
       element.reviewersOnly = false;
@@ -181,5 +188,58 @@
       assert.isTrue(fireStub.lastCall.calledWith('show-reply-dialog',
           {value: {ccsOnly: true}}));
     });
+
+    test('no show all reviewers button with 10 reviewers', () => {
+      const reviewers = [];
+      for (let i = 0; i < 10; i++) {
+        reviewers.push(
+          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
+      }
+      element.ccsOnly = true;
+
+      element.change = {
+        owner: {
+          _account_id: 1,
+        },
+        reviewers: {
+          CC: reviewers,
+        },
+      };
+      flushAsynchronousOperations();
+      assert.equal(element._hiddenReviewerCount, 0);
+      assert.equal(element._displayedReviewers.length, 10);
+      assert.equal(element._reviewers.length, 10);
+      assert.isTrue(element.$$('.hiddenReviewers').hidden);
+    });
+
+    test('show all reviewers button', () => {
+      const reviewers = [];
+      for (let i = 0; i < 100; i++) {
+        reviewers.push(
+          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
+      }
+      element.ccsOnly = true;
+
+      element.change = {
+        owner: {
+          _account_id: 1,
+        },
+        reviewers: {
+          CC: reviewers,
+        },
+      };
+      flushAsynchronousOperations();
+      assert.equal(element._hiddenReviewerCount, 90);
+      assert.equal(element._displayedReviewers.length, 10);
+      assert.equal(element._reviewers.length, 100);
+      assert.isFalse(element.$$('.hiddenReviewers').hidden);
+
+      MockInteractions.tap(element.$$('.hiddenReviewers'));
+      flushAsynchronousOperations();
+      assert.equal(element._hiddenReviewerCount, 0);
+      assert.equal(element._displayedReviewers.length, 100);
+      assert.equal(element._reviewers.length, 100);
+      assert.isTrue(element.$$('.hiddenReviewers').hidden);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
index 015cfc5..7e358fd 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
@@ -39,7 +39,7 @@
         items=[[links]]
         top-content=[[topContent]]
         horizontal-align="right">
-        <span hidden$="[[_hasAvatars]]" hidden>[[account.name]]</span>
+        <span hidden$="[[_hasAvatars]]" hidden>[[_accountName(account, _anonymousName)]]</span>
         <gr-avatar account="[[account]]" hidden$="[[!_hasAvatars]]" hidden
             image-size="56" aria-label="Account avatar"></gr-avatar>
     </gr-dropdown>
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
index 0e06647..318794f 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
@@ -14,38 +14,96 @@
 (function() {
   'use strict';
 
+  const INTERPOLATE_URL_PATTERN = /\$\{([\w]+)\}/g;
+
+  const ANONYMOUS_NAME = 'Anonymous';
+
   Polymer({
     is: 'gr-account-dropdown',
 
     properties: {
       account: Object,
-      _hasAvatars: Boolean,
+      _anonymousName: {
+        type: String,
+        value: ANONYMOUS_NAME,
+      },
       links: {
         type: Array,
-        value: [
-          {name: 'Settings', url: '/settings'},
-          {name: 'Switch account', url: '/switch-account'},
-          {name: 'Sign out', url: '/logout'},
-        ],
+        computed: '_getLinks(_switchAccountUrl, _path)',
       },
       topContent: {
         type: Array,
-        computed: '_getTopContent(account)',
+        computed: '_getTopContent(account, _anonymousName)',
       },
+      _path: {
+        type: String,
+        value: '/',
+      },
+      _hasAvatars: Boolean,
+      _switchAccountUrl: String,
     },
 
-    attached: function() {
-      this.$.restAPI.getConfig().then(function(cfg) {
+    attached() {
+      this._handleLocationChange();
+      this.listen(window, 'location-change', '_handleLocationChange');
+      this.$.restAPI.getConfig().then(cfg => {
+        if (cfg && cfg.auth && cfg.auth.switch_account_url) {
+          this._switchAccountUrl = cfg.auth.switch_account_url;
+        } else {
+          this._switchAccountUrl = null;
+        }
         this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
-      }.bind(this));
+
+        if (cfg && cfg.user &&
+            cfg.user.anonymous_coward_name &&
+            cfg.user.anonymous_coward_name !== 'Anonymous Coward') {
+          this._anonymousName = cfg.user.anonymous_coward_name;
+        }
+      });
     },
 
-    _getTopContent: function(account) {
-      // if (!account) { return []; }
+    detached() {
+      this.unlisten(window, 'location-change', '_handleLocationChange');
+    },
+
+    _getLinks(switchAccountUrl, path) {
+      const links = [{name: 'Settings', url: '/settings'}];
+      if (switchAccountUrl) {
+        const replacements = {path};
+        const url = this._interpolateUrl(switchAccountUrl, replacements);
+        links.push({name: 'Switch account', url});
+      }
+      links.push({name: 'Sign out', url: '/logout'});
+      return links;
+    },
+
+    _getTopContent(account, _anonymousName) {
       return [
-        {text: account.name, bold: true},
-        {text: account.email},
+        {text: this._accountName(account, _anonymousName), bold: true},
+        {text: account.email ? account.email : ''},
       ];
     },
+
+    _handleLocationChange() {
+      this._path =
+          window.location.pathname +
+          window.location.search +
+          window.location.hash;
+    },
+
+    _interpolateUrl(url, replacements) {
+      return url.replace(INTERPOLATE_URL_PATTERN, (match, p1) => {
+        return replacements[p1] || '';
+      });
+    },
+
+    _accountName(account, _anonymousName) {
+      if (account && account.name) {
+        return account.name;
+      } else if (account && account.email) {
+        return account.email;
+      }
+      return _anonymousName;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
index e833cd6..46f3ca3 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
@@ -33,20 +33,73 @@
 </test-fixture>
 
 <script>
-  suite('gr-account-dropdown tests', function() {
-    var element;
+  suite('gr-account-dropdown tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
+        getConfig() { return Promise.resolve({}); },
       });
       element = fixture('basic');
     });
 
-    test('account information', function() {
+    test('account information', () => {
       element.account = {name: 'John Doe', email: 'john@doe.com'};
       assert.deepEqual(element.topContent,
           [{text: 'John Doe', bold: true}, {text: 'john@doe.com'}]);
     });
+
+    test('test for account without a name', () => {
+      element.account = {id: '0001'};
+      assert.deepEqual(element.topContent,
+          [{text: 'Anonymous', bold: true}, {text: ''}]);
+    });
+
+    test('test for account without a name but using config', () => {
+      element._anonymousName = 'WikiGerrit';
+      element.account = {id: '0001'};
+      assert.deepEqual(element.topContent,
+          [{text: 'WikiGerrit', bold: true}, {text: ''}]);
+    });
+
+    test('test for account name as an email', () => {
+      element._anonymousName = 'WikiGerrit';
+      element.account = {email: 'john@doe.com'};
+      assert.deepEqual(element.topContent,
+          [{text: 'john@doe.com', bold: true}, {text: 'john@doe.com'}]);
+    });
+
+    test('switch account', () => {
+      // No switch account link.
+      assert.equal(element._getLinks(null).length, 2);
+
+      // Unparameterized switch account link.
+      let links = element._getLinks('/switch-account');
+      assert.equal(links.length, 3);
+      assert.deepEqual(links[1],
+          {name: 'Switch account', url: '/switch-account'});
+
+      // Parameterized switch account link.
+      links = element._getLinks('/switch-account${path}', '/c/123');
+      assert.equal(links.length, 3);
+      assert.deepEqual(links[1],
+          {name: 'Switch account', url: '/switch-account/c/123'});
+    });
+
+    test('_interpolateUrl', () => {
+      const replacements = {
+        foo: 'bar',
+        test: 'TEST',
+      };
+      const interpolate = function(url) {
+        return element._interpolateUrl(url, replacements);
+      };
+
+      assert.equal(interpolate('test'), 'test');
+      assert.equal(interpolate('${test}'), 'TEST');
+      assert.equal(
+          interpolate('${}, ${test}, ${TEST}, ${foo}'),
+          '${}, TEST, , bar');
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
index e3f2bbc..5765411 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
@@ -25,4 +25,3 @@
   </template>
   <script src="gr-error-manager.js"></script>
 </dom-module>
-
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 d48d870..1ccb30b 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
@@ -14,12 +14,13 @@
 (function() {
   'use strict';
 
-  var HIDE_ALERT_TIMEOUT_MS = 5000;
-  var CHECK_SIGN_IN_INTERVAL_MS = 60*1000;
-  var STALE_CREDENTIAL_THRESHOLD_MS = 10*60*1000;
-  var SIGN_IN_WIDTH_PX = 690;
-  var SIGN_IN_HEIGHT_PX = 500;
-  var TOO_MANY_FILES = 'too many files to find conflicts';
+  const HIDE_ALERT_TIMEOUT_MS = 5000;
+  const CHECK_SIGN_IN_INTERVAL_MS = 60 * 1000;
+  const STALE_CREDENTIAL_THRESHOLD_MS = 10 * 60 * 1000;
+  const SIGN_IN_WIDTH_PX = 690;
+  const SIGN_IN_HEIGHT_PX = 500;
+  const TOO_MANY_FILES = 'too many files to find conflicts';
+  const AUTHENTICATION_REQUIRED = 'Authentication required\n';
 
   Polymer({
     is: 'gr-error-manager',
@@ -47,91 +48,108 @@
        */
       _lastCredentialCheck: {
         type: Number,
-        value: function() { return Date.now(); },
-      }
+        value() { return Date.now(); },
+      },
     },
 
-    attached: function() {
+    attached() {
       this.listen(document, 'server-error', '_handleServerError');
       this.listen(document, 'network-error', '_handleNetworkError');
       this.listen(document, 'show-alert', '_handleShowAlert');
       this.listen(document, 'visibilitychange', '_handleVisibilityChange');
+      this.listen(document, 'show-auth-required', '_handleAuthRequired');
     },
 
-    detached: function() {
+    detached() {
       this._clearHideAlertHandle();
       this.unlisten(document, 'server-error', '_handleServerError');
       this.unlisten(document, 'network-error', '_handleNetworkError');
+      this.unlisten(document, 'show-auth-required', '_handleAuthRequired');
       this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
     },
 
-    _shouldSuppressError: function(msg) {
-      return msg.indexOf(TOO_MANY_FILES) > -1;
+    _shouldSuppressError(msg) {
+      return msg.includes(TOO_MANY_FILES);
     },
 
-    _handleServerError: function(e) {
-      if (e.detail.response.status === 403) {
-        this._getLoggedIn().then(function(loggedIn) {
-          if (loggedIn) {
-            // The app was logged at one point and is now getting auth errors.
-            // This indicates the auth token is no longer valid.
-            this._showAuthErrorAlert();
-          }
-        }.bind(this));
-      } else {
-        e.detail.response.text().then(function(text) {
-          if (!this._shouldSuppressError(text)) {
-            this._showAlert('Server error: ' + text);
-          }
-        }.bind(this));
-      }
+    _handleAuthRequired() {
+      this._showAuthErrorAlert(
+          'Log in is required to perform that action.', 'Log in.');
     },
 
-    _handleShowAlert: function(e) {
-      this._showAlert(e.detail.message);
+    _handleServerError(e) {
+      Promise.all([
+        e.detail.response.text(), this._getLoggedIn(),
+      ]).then(values => {
+        const text = values[0];
+        const loggedIn = values[1];
+        if (e.detail.response.status === 403 &&
+            loggedIn &&
+            text === AUTHENTICATION_REQUIRED) {
+          // The app was logged at one point and is now getting auth errors.
+          // This indicates the auth token is no longer valid.
+          this._showAuthErrorAlert('Auth error', 'Refresh credentials.');
+        } else if (!this._shouldSuppressError(text)) {
+          this._showAlert('Server error: ' + text);
+        }
+      });
     },
 
-    _handleNetworkError: function(e) {
+    _handleShowAlert(e) {
+      this._showAlert(e.detail.message, e.detail.action, e.detail.callback,
+          e.detail.dismissOnNavigation);
+    },
+
+    _handleNetworkError(e) {
       this._showAlert('Server unavailable');
       console.error(e.detail.error.message);
     },
 
-    _getLoggedIn: function() {
+    _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
     },
 
-    _showAlert: function(text) {
+    _showAlert(text, opt_actionText, opt_actionCallback,
+        dismissOnNavigation) {
       if (this._alertElement) { return; }
 
       this._clearHideAlertHandle();
-      this._hideAlertHandle =
-        this.async(this._hideAlert, HIDE_ALERT_TIMEOUT_MS);
-      var el = this._createToastAlert();
-      el.show(text);
+      if (dismissOnNavigation) {
+        // Persist alert until navigation.
+        this.listen(document, 'location-change', '_hideAlert');
+      } else {
+        this._hideAlertHandle =
+          this.async(this._hideAlert, HIDE_ALERT_TIMEOUT_MS);
+      }
+      const el = this._createToastAlert();
+      el.show(text, opt_actionText, opt_actionCallback);
       this._alertElement = el;
     },
 
-    _hideAlert: function() {
+    _hideAlert() {
       if (!this._alertElement) { return; }
 
       this._alertElement.hide();
       this._alertElement = null;
+
+      // Remove listener for page navigation, if it exists.
+      this.unlisten(document, 'location-change', '_hideAlert');
     },
 
-    _clearHideAlertHandle: function() {
+    _clearHideAlertHandle() {
       if (this._hideAlertHandle != null) {
         this.cancelAsync(this._hideAlertHandle);
         this._hideAlertHandle = null;
       }
     },
 
-    _showAuthErrorAlert: function() {
+    _showAuthErrorAlert(errorText, actionText) {
       // TODO(viktard): close alert if it's not for auth error.
       if (this._alertElement) { return; }
 
       this._alertElement = this._createToastAlert();
-      this._alertElement.show('Auth error', 'Refresh credentials.');
-      this.listen(this._alertElement, 'action', '_createLoginPopup');
+      this._alertElement.show(errorText, actionText,
+          this._createLoginPopup.bind(this));
 
       this._refreshingCredentials = true;
       this._requestCheckLoggedIn();
@@ -140,46 +158,40 @@
       }
     },
 
-    _createToastAlert: function() {
-      var el = document.createElement('gr-alert');
+    _createToastAlert() {
+      const el = document.createElement('gr-alert');
       el.toast = true;
       return el;
     },
 
-    _handleVisibilityChange: function() {
+    _handleVisibilityChange() {
       // Ignore when the page is transitioning to hidden (or hidden is
       // undefined).
       if (document.hidden !== false) { return; }
 
-      // If we're currently in a credential refresh, flush the debouncer so that
-      // it can be checked immediately.
-      if (this._refreshingCredentials) {
-        this.flushDebouncer('checkLoggedIn');
-        return;
-      }
-
-      // If the credentials are old, request them to confirm their validity or
-      // (display an auth toast if it fails).
-      var timeSinceLastCheck = Date.now() - this._lastCredentialCheck;
-      if (this.knownAccountId !== undefined &&
+      // If not currently refreshing credentials and the credentials are old,
+      // request them to confirm their validity or (display an auth toast if it
+      // fails).
+      const timeSinceLastCheck = Date.now() - this._lastCredentialCheck;
+      if (!this._refreshingCredentials &&
+          this.knownAccountId !== undefined &&
           timeSinceLastCheck > STALE_CREDENTIAL_THRESHOLD_MS) {
         this._lastCredentialCheck = Date.now();
         this.$.restAPI.checkCredentials();
       }
     },
 
-    _requestCheckLoggedIn: function() {
+    _requestCheckLoggedIn() {
       this.debounce(
-        'checkLoggedIn', this._checkSignedIn, CHECK_SIGN_IN_INTERVAL_MS);
+          'checkLoggedIn', this._checkSignedIn, CHECK_SIGN_IN_INTERVAL_MS);
     },
 
-    _checkSignedIn: function() {
-      this.$.restAPI.checkCredentials().then(function(account) {
-        var isLoggedIn = !!account;
+    _checkSignedIn() {
+      this.$.restAPI.checkCredentials().then(account => {
+        const isLoggedIn = !!account;
         this._lastCredentialCheck = Date.now();
         if (this._refreshingCredentials) {
           if (isLoggedIn) {
-
             // If the credentials were refreshed but the account is different
             // then reload the page completely.
             if (account._account_id !== this.knownAccountId) {
@@ -192,17 +204,19 @@
             this._requestCheckLoggedIn();
           }
         }
-      }.bind(this));
+      });
     },
 
-    _reloadPage: function() {
+    _reloadPage() {
       window.location.reload();
     },
 
-    _createLoginPopup: function() {
-      var left = window.screenLeft + (window.outerWidth - SIGN_IN_WIDTH_PX) / 2;
-      var top = window.screenTop + (window.outerHeight - SIGN_IN_HEIGHT_PX) / 2;
-      var options = [
+    _createLoginPopup() {
+      const left = window.screenLeft +
+          (window.outerWidth - SIGN_IN_WIDTH_PX) / 2;
+      const top = window.screenTop +
+          (window.outerHeight - SIGN_IN_HEIGHT_PX) / 2;
+      const options = [
         'width=' + SIGN_IN_WIDTH_PX,
         'height=' + SIGN_IN_HEIGHT_PX,
         'left=' + left,
@@ -210,13 +224,18 @@
       ];
       window.open(this.getBaseUrl() +
           '/login/%3FcloseAfterLogin', '_blank', options.join(','));
+      this.listen(window, 'focus', '_handleWindowFocus');
     },
 
-    _handleCredentialRefreshed: function() {
+    _handleCredentialRefreshed() {
+      this.unlisten(window, 'focus', '_handleWindowFocus');
       this._refreshingCredentials = false;
-      this.unlisten(this._alertElement, 'action', '_createLoginPopup');
       this._hideAlert();
       this._showAlert('Credentials refreshed.');
     },
+
+    _handleWindowFocus() {
+      this.flushDebouncer('checkLoggedIn');
+    },
   });
 })();
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 2013686..ba10f09 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
@@ -33,38 +33,69 @@
 </test-fixture>
 
 <script>
-  suite('gr-error-manager tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-error-manager tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
-        getLoggedIn: function() { return Promise.resolve(true); },
+        getLoggedIn() { return Promise.resolve(true); },
       });
       element = fixture('basic');
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('show auth error', function(done) {
-      var showAuthErrorStub = sandbox.stub(element, '_showAuthErrorAlert');
-      element.fire('server-error', {response: {status: 403}});
-      element.$.restAPI.getLoggedIn.lastCall.returnValue.then(function() {
+    test('does not show auth error on 403 by default', done => {
+      const showAuthErrorStub = sandbox.stub(element, '_showAuthErrorAlert');
+      const responseText = Promise.resolve('server says no.');
+      element.fire('server-error',
+          {response: {status: 403, text() { return responseText; }}}
+      );
+      Promise.all([
+        element.$.restAPI.getLoggedIn.lastCall.returnValue,
+        responseText,
+      ]).then(() => {
+        assert.isFalse(showAuthErrorStub.calledOnce);
+        done();
+      });
+    });
+
+    test('shows auth error on 403 and Authentication required', done => {
+      const showAuthErrorStub = sandbox.stub(element, '_showAuthErrorAlert');
+      const responseText = Promise.resolve('Authentication required\n');
+      element.fire('server-error',
+          {response: {status: 403, text() { return responseText; }}}
+      );
+      Promise.all([
+        element.$.restAPI.getLoggedIn.lastCall.returnValue,
+        responseText,
+      ]).then(() => {
         assert.isTrue(showAuthErrorStub.calledOnce);
         done();
       });
     });
 
-    test('show normal server error', function(done) {
-      var showAlertStub = sandbox.stub(element, '_showAlert');
-      var textSpy = sandbox.spy(function() { return Promise.resolve('ZOMG'); });
+    test('show logged in error', () => {
+      sandbox.stub(element, '_showAuthErrorAlert');
+      element.fire('show-auth-required');
+      assert.isTrue(element._showAuthErrorAlert.calledWithExactly(
+          'Log in is required to perform that action.', 'Log in.'));
+    });
+
+    test('show normal server error', done => {
+      const showAlertStub = sandbox.stub(element, '_showAlert');
+      const textSpy = sandbox.spy(() => { return Promise.resolve('ZOMG'); });
       element.fire('server-error', {response: {status: 500, text: textSpy}});
 
       assert.isTrue(textSpy.called);
-      textSpy.lastCall.returnValue.then(function() {
+      Promise.all([
+        element.$.restAPI.getLoggedIn.lastCall.returnValue,
+        textSpy.lastCall.returnValue,
+      ]).then(() => {
         assert.isTrue(showAlertStub.calledOnce);
         assert.isTrue(showAlertStub.lastCall.calledWithExactly(
             'Server error: ZOMG'));
@@ -72,25 +103,28 @@
       });
     });
 
-    test('suppress TOO_MANY_FILES error', function(done) {
-      var showAlertStub = sandbox.stub(element, '_showAlert');
-      var textSpy = sandbox.spy(function() {
+    test('suppress TOO_MANY_FILES error', done => {
+      const showAlertStub = sandbox.stub(element, '_showAlert');
+      const textSpy = sandbox.spy(() => {
         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() {
+      Promise.all([
+        element.$.restAPI.getLoggedIn.lastCall.returnValue,
+        textSpy.lastCall.returnValue,
+      ]).then(() => {
         assert.isFalse(showAlertStub.called);
         done();
       });
     });
 
-    test('show network error', function(done) {
-      var consoleErrorStub = sandbox.stub(console, 'error');
-      var showAlertStub = sandbox.stub(element, '_showAlert');
+    test('show network error', done => {
+      const consoleErrorStub = sandbox.stub(console, 'error');
+      const showAlertStub = sandbox.stub(element, '_showAlert');
       element.fire('network-error', {error: new Error('ZOMG')});
-      flush(function() {
+      flush(() => {
         assert.isTrue(showAlertStub.calledOnce);
         assert.isTrue(showAlertStub.lastCall.calledWithExactly(
             'Server unavailable'));
@@ -100,15 +134,21 @@
       });
     });
 
-    test('show auth refresh toast', function(done) {
-      var refreshStub = sandbox.stub(element.$.restAPI, 'checkCredentials',
-          function() { return Promise.resolve(true); });
-      var toastSpy = sandbox.spy(element, '_createToastAlert');
-      var windowOpen = sandbox.stub(window, 'open');
-      element.fire('server-error', {response: {status: 403}});
-      element.$.restAPI.getLoggedIn.lastCall.returnValue.then(function() {
+    test('show auth refresh toast', done => {
+      const refreshStub = sandbox.stub(element.$.restAPI, 'checkCredentials',
+          () => { return Promise.resolve(true); });
+      const toastSpy = sandbox.spy(element, '_createToastAlert');
+      const windowOpen = sandbox.stub(window, 'open');
+      const responseText = Promise.resolve('Authentication required\n');
+      element.fire('server-error',
+          {response: {status: 403, text() { return responseText; }}}
+      );
+      Promise.all([
+        element.$.restAPI.getLoggedIn.lastCall.returnValue,
+        responseText,
+      ]).then(() => {
         assert.isTrue(toastSpy.called);
-        var toast = toastSpy.lastCall.returnValue;
+        let toast = toastSpy.lastCall.returnValue;
         assert.isOk(toast);
         assert.include(
             Polymer.dom(toast.root).textContent, 'Auth error');
@@ -116,18 +156,19 @@
             Polymer.dom(toast.root).textContent, 'Refresh credentials.');
 
         assert.isFalse(windowOpen.called);
-        toast.fire('action');
+        MockInteractions.tap(toast.$$('gr-button.action'));
         assert.isTrue(windowOpen.called);
 
         // @see Issue 5822: noopener breaks closeAfterLogin
         assert.equal(windowOpen.lastCall.args[2].indexOf('noopener=yes'),
             -1);
 
-        var hideToastSpy = sandbox.spy(toast, 'hide');
+        const hideToastSpy = sandbox.spy(toast, 'hide');
 
+        element._handleWindowFocus();
         assert.isTrue(refreshStub.called);
         element.flushDebouncer('checkLoggedIn');
-        flush(function() {
+        flush(() => {
           assert.isTrue(refreshStub.called);
           assert.isTrue(hideToastSpy.called);
 
@@ -141,15 +182,18 @@
       });
     });
 
-    test('show alert', function() {
+    test('show alert', () => {
+      const alertObj = {message: 'foo'};
       sandbox.stub(element, '_showAlert');
-      element.fire('show-alert', {message: 'foo'});
+      element.fire('show-alert', alertObj);
       assert.isTrue(element._showAlert.calledOnce);
-      assert.isTrue(element._showAlert.lastCall.calledWithExactly('foo'));
+      assert.equal(element._showAlert.lastCall.args[0], 'foo');
+      assert.isNotOk(element._showAlert.lastCall.args[1]);
+      assert.isNotOk(element._showAlert.lastCall.args[2]);
     });
 
-    test('checks stale credentials on visibility change', function() {
-      var refreshStub = sandbox.stub(element.$.restAPI,
+    test('checks stale credentials on visibility change', () => {
+      const refreshStub = sandbox.stub(element.$.restAPI,
           'checkCredentials');
       sandbox.stub(Date, 'now').returns(999999);
       element._lastCredentialCheck = 0;
@@ -167,19 +211,19 @@
       assert.equal(element._lastCredentialCheck, 999999);
     });
 
-    test('refresh loop continues on credential fail', function(done) {
-      var accountPromise = Promise.resolve(null);
+    test('refresh loop continues on credential fail', done => {
+      const accountPromise = Promise.resolve(null);
       sandbox.stub(element.$.restAPI, 'checkCredentials')
           .returns(accountPromise);
-      var requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
-      var handleRefreshStub = sandbox.stub(element,
+      const requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
+      const handleRefreshStub = sandbox.stub(element,
           '_handleCredentialRefreshed');
-      var reloadStub = sandbox.stub(element, '_reloadPage');
+      const reloadStub = sandbox.stub(element, '_reloadPage');
 
       element._refreshingCredentials = true;
       element._checkSignedIn();
 
-      accountPromise.then(function() {
+      accountPromise.then(() => {
         assert.isTrue(requestCheckStub.called);
         assert.isFalse(handleRefreshStub.called);
         assert.isFalse(reloadStub.called);
@@ -187,20 +231,20 @@
       });
     });
 
-    test('refreshes with same credentials', function(done) {
-      var accountPromise = Promise.resolve({_account_id: 1234});
+    test('refreshes with same credentials', done => {
+      const accountPromise = Promise.resolve({_account_id: 1234});
       sandbox.stub(element.$.restAPI, 'checkCredentials')
           .returns(accountPromise);
-      var requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
-      var handleRefreshStub = sandbox.stub(element,
+      const requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
+      const handleRefreshStub = sandbox.stub(element,
           '_handleCredentialRefreshed');
-      var reloadStub = sandbox.stub(element, '_reloadPage');
+      const reloadStub = sandbox.stub(element, '_reloadPage');
 
       element.knownAccountId = 1234;
       element._refreshingCredentials = true;
       element._checkSignedIn();
 
-      accountPromise.then(function() {
+      accountPromise.then(() => {
         assert.isFalse(requestCheckStub.called);
         assert.isTrue(handleRefreshStub.called);
         assert.isFalse(reloadStub.called);
@@ -208,25 +252,41 @@
       });
     });
 
-    test('reloads when refreshed credentials differ', function(done) {
-      var accountPromise = Promise.resolve({_account_id: 1234});
+    test('reloads when refreshed credentials differ', done => {
+      const accountPromise = Promise.resolve({_account_id: 1234});
       sandbox.stub(element.$.restAPI, 'checkCredentials')
           .returns(accountPromise);
-      var requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
-      var handleRefreshStub = sandbox.stub(element,
+      const requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
+      const handleRefreshStub = sandbox.stub(element,
           '_handleCredentialRefreshed');
-      var reloadStub = sandbox.stub(element, '_reloadPage');
+      const reloadStub = sandbox.stub(element, '_reloadPage');
 
       element.knownAccountId = 4321; // Different from 1234
       element._refreshingCredentials = true;
       element._checkSignedIn();
 
-      accountPromise.then(function() {
+      accountPromise.then(() => {
         assert.isFalse(requestCheckStub.called);
         assert.isFalse(handleRefreshStub.called);
         assert.isTrue(reloadStub.called);
         done();
       });
     });
+
+    test('dismissOnNavigation respected', () => {
+      const asyncStub = sandbox.stub(element, 'async');
+      const hideSpy = sandbox.spy(element, '_hideAlert');
+      // No async call when dismissOnNavigation supplied.
+      element._showAlert('test', null, null, true);
+      assert.isFalse(asyncStub.called);
+
+      // When page nav happens, clear alert.
+      document.dispatchEvent(new CustomEvent('location-change'));
+      assert.isTrue(hideSpy.called);
+
+      // When timeout is not supplied, use HIDE_ALERT_TIMEOUT_MS.
+      element._showAlert('test');
+      assert.isTrue(asyncStub.called);
+    });
   });
 </script>
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 9a3a267..b3180e9 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
@@ -162,6 +162,13 @@
             </td>
             <td>Show selected change</td>
           </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">r</span>
+            </td>
+            <td>Refresh list of changes</td>
+          </tr>
         </tbody>
         <!-- Dashboard -->
         <tbody hidden$="[[!_computeInView(view, 'gr-dashboard-view')]]" hidden>
@@ -183,6 +190,13 @@
             </td>
             <td>Show selected change</td>
           </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">r</span>
+            </td>
+            <td>Refresh list of changes</td>
+          </tr>
         </tbody>
         <!-- Change View -->
         <tbody hidden$="[[!_computeInView(view, 'gr-change-view')]]" hidden>
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
index 1a286c7..b7900c0 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
@@ -31,11 +31,11 @@
       role: 'dialog',
     },
 
-    _computeInView: function(currentView, view) {
+    _computeInView(currentView, view) {
       return view === currentView;
     },
 
-    _handleCloseTap: function(e) {
+    _handleCloseTap(e) {
       e.preventDefault();
       this.fire('close', null, {bubbles: false});
     },
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
index 1e6596b..0473d66 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
@@ -40,6 +40,19 @@
       .bigTitle:hover {
         text-decoration: underline;
       }
+      .bigTitle::before {
+        background-image: var(--header-icon);
+        background-size: var(--header-icon-size) var(--header-icon-size);
+        content: "";
+        display: inline-block;
+        height: var(--header-icon-size);
+        margin: 0 .25em 0 0;
+        vertical-align: text-bottom;
+        width: var(--header-icon-size);
+      }
+      .bigTitle::after {
+        content: var(--header-title-content);
+      }
       ul {
         list-style: none;
       }
@@ -51,7 +64,7 @@
         position: relative;
       }
       .linksTitle {
-        color: black;
+        color: var(--primary-text-color);
         display: inline-block;
         position: relative;
       }
@@ -101,7 +114,7 @@
       }
     </style>
     <nav>
-      <a href$="[[_computeRelativeURL('/')]]" class="bigTitle">PolyGerrit</a>
+      <a href$="[[_computeRelativeURL('/')]]" class="bigTitle"></a>
       <ul class="links">
         <template is="dom-repeat" items="[[_links]]" as="linkGroup">
           <li>
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
index affa8e5..c2273d1 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
@@ -14,7 +14,7 @@
 (function() {
   'use strict';
 
-  var ADMIN_LINKS = [
+  const ADMIN_LINKS = [
     {
       url: '/admin/groups',
       name: 'Groups',
@@ -22,7 +22,7 @@
     {
       url: '/admin/create-group',
       name: 'Create Group',
-      capability: 'createGroup'
+      capability: 'createGroup',
     },
     {
       url: '/admin/projects',
@@ -40,7 +40,7 @@
     },
   ];
 
-  var DEFAULT_LINKS = [{
+  const DEFAULT_LINKS = [{
     title: 'Changes',
     links: [
       {
@@ -58,31 +58,31 @@
     ],
   }];
 
-  var DOCUMENTATION_LINKS = [
+  const DOCUMENTATION_LINKS = [
     {
-      url : '/index.html',
-      name : 'Table of Contents',
+      url: '/index.html',
+      name: 'Table of Contents',
     },
     {
-      url : '/user-search.html',
-      name : 'Searching',
+      url: '/user-search.html',
+      name: 'Searching',
     },
     {
-      url : '/user-upload.html',
-      name : 'Uploading',
+      url: '/user-upload.html',
+      name: 'Uploading',
     },
     {
-      url : '/access-control.html',
-      name : 'Access Control',
+      url: '/access-control.html',
+      name: 'Access Control',
     },
     {
-      url : '/rest-api.html',
-      name : 'REST API',
+      url: '/rest-api.html',
+      name: 'REST API',
     },
     {
-      url : '/intro-project-owner.html',
-      name : 'Project Owner Guide',
-    }
+      url: '/intro-project-owner.html',
+      name: 'Project Owner Guide',
+    },
   ];
 
   Polymer({
@@ -101,16 +101,17 @@
       _account: Object,
       _adminLinks: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       _defaultLinks: {
         type: Array,
-        value: function() {
+        value() {
           return DEFAULT_LINKS;
         },
       },
       _docBaseUrl: {
         type: String,
+        value: null,
       },
       _links: {
         type: Array,
@@ -123,7 +124,7 @@
       },
       _userLinks: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
     },
 
@@ -135,21 +136,21 @@
       '_accountLoaded(_account)',
     ],
 
-    attached: function() {
+    attached() {
       this._loadAccount();
       this._loadConfig();
       this.listen(window, 'location-change', '_handleLocationChange');
     },
 
-    detached: function() {
+    detached() {
       this.unlisten(window, 'location-change', '_handleLocationChange');
     },
 
-    reload: function() {
+    reload() {
       this._loadAccount();
     },
 
-    _handleLocationChange: function(e) {
+    _handleLocationChange(e) {
       if (this.getBaseUrl()) {
         // Strip the canonical path from the path since needing canonical in
         // the path is uneeded and breaks the url.
@@ -165,12 +166,12 @@
       }
     },
 
-    _computeRelativeURL: function(path) {
+    _computeRelativeURL(path) {
       return '//' + window.location.host + this.getBaseUrl() + path;
     },
 
-    _computeLinks: function(defaultLinks, userLinks, adminLinks, docBaseUrl) {
-      var links = defaultLinks.slice();
+    _computeLinks(defaultLinks, userLinks, adminLinks, docBaseUrl) {
+      const links = defaultLinks.slice();
       if (userLinks && userLinks.length > 0) {
         links.push({
           title: 'Your',
@@ -183,7 +184,7 @@
           links: adminLinks,
         });
       }
-      var docLinks = this._getDocLinks(docBaseUrl, DOCUMENTATION_LINKS);
+      const docLinks = this._getDocLinks(docBaseUrl, DOCUMENTATION_LINKS);
       if (docLinks.length) {
         links.push({
           title: 'Documentation',
@@ -193,12 +194,12 @@
       return links;
     },
 
-    _getDocLinks: function(docBaseUrl, docLinks) {
+    _getDocLinks(docBaseUrl, docLinks) {
       if (!docBaseUrl || !docLinks) {
         return [];
       }
-      return docLinks.map(function(link) {
-        var url = docBaseUrl;
+      return docLinks.map(link => {
+        let url = docBaseUrl;
         if (url && url[url.length - 1] === '/') {
           url = url.substring(0, url.length - 1);
         }
@@ -210,66 +211,76 @@
       });
     },
 
-    _loadAccount: function() {
-      this.$.restAPI.getAccount().then(function(account) {
+    _loadAccount() {
+      this.$.restAPI.getAccount().then(account => {
         this._account = account;
         this.$.accountContainer.classList.toggle('loggedIn', account != null);
         this.$.accountContainer.classList.toggle('loggedOut', account == null);
-      }.bind(this));
+      });
     },
 
-    _loadConfig: function() {
-      this.$.restAPI.getConfig().then(function(config) {
+    _loadConfig() {
+      this.$.restAPI.getConfig().then(config => {
         if (config && config.gerrit && config.gerrit.doc_url) {
           this._docBaseUrl = config.gerrit.doc_url;
         }
         if (!this._docBaseUrl) {
           return this._probeDocLink('/Documentation/index.html');
         }
-      }.bind(this));
+      });
     },
 
-    _probeDocLink: function(path) {
-      return this.$.restAPI.probePath(this.getBaseUrl() + path).then(function(ok) {
+    _probeDocLink(path) {
+      return this.$.restAPI.probePath(this.getBaseUrl() + path).then(ok => {
         if (ok) {
           this._docBaseUrl = this.getBaseUrl() + '/Documentation';
         } else {
           this._docBaseUrl = null;
         }
-      }.bind(this));
+      });
     },
 
-    _accountLoaded: function(account) {
+    _accountLoaded(account) {
       if (!account) { return; }
 
-      this.$.restAPI.getPreferences().then(function(prefs) {
+      this.$.restAPI.getPreferences().then(prefs => {
         this._userLinks =
-            prefs.my.map(this._stripHashPrefix).filter(this._isSupportedLink);
-      }.bind(this));
+            prefs.my.map(this._fixMyMenuItem).filter(this._isSupportedLink);
+      });
       this._loadAccountCapabilities();
     },
 
-    _loadAccountCapabilities: function() {
-      var params = ['createProject', 'createGroup', 'viewPlugins'];
+    _loadAccountCapabilities() {
+      const params = ['createProject', 'createGroup', 'viewPlugins'];
       return this.$.restAPI.getAccountCapabilities(params)
-          .then(function(capabilities) {
-        this._adminLinks = ADMIN_LINKS.filter(function(link) {
-          return !link.capability ||
+          .then(capabilities => {
+            this._adminLinks = ADMIN_LINKS.filter(link => {
+              return !link.capability ||
               capabilities.hasOwnProperty(link.capability);
-        });
-      }.bind(this));
+            });
+          });
     },
 
-    _stripHashPrefix: function(linkObj) {
-      if (linkObj.url.indexOf('#') === 0) {
+    _fixMyMenuItem(linkObj) {
+      // Normalize all urls to PolyGerrit style.
+      if (linkObj.url.startsWith('#')) {
         linkObj.url = linkObj.url.slice(1);
       }
+
+      // Delete target property due to complications of
+      // https://bugs.chromium.org/p/gerrit/issues/detail?id=5888
+      //
+      // The server tries to guess whether URL is a view within the UI.
+      // If not, it sets target='_blank' on the menu item. The server
+      // makes assumptions that work for the GWT UI, but not PolyGerrit,
+      // so we'll just disable it altogether for now.
+      delete linkObj.target;
       return linkObj;
     },
 
-    _isSupportedLink: function(linkObj) {
+    _isSupportedLink(linkObj) {
       // Groups are not yet supported.
-      return linkObj.url.indexOf('/groups') !== 0;
+      return !linkObj.url.startsWith('/groups');
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
index 4582b4f..f407ac5 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
@@ -33,88 +33,88 @@
 </test-fixture>
 
 <script>
-  suite('gr-main-header tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-main-header tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
-        probePath: function(path) { return Promise.resolve(false); },
+        getConfig() { return Promise.resolve({}); },
+        probePath(path) { return Promise.resolve(false); },
       });
       stub('gr-main-header', {
-        _loadAccount: function() {},
+        _loadAccount() {},
       });
       element = fixture('basic');
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('strip hash prefix', function() {
+    test('fix my menu item', () => {
       assert.deepEqual([
         {url: '#/q/owner:self+is:draft'},
         {url: 'https://awesometown.com/#hashyhash'},
-      ].map(element._stripHashPrefix),
-      [
+        {url: 'url', target: '_blank'},
+      ].map(element._fixMyMenuItem), [
         {url: '/q/owner:self+is:draft'},
         {url: 'https://awesometown.com/#hashyhash'},
+        {url: 'url'},
       ]);
     });
 
-    test('filter unsupported urls', function() {
+    test('filter unsupported urls', () => {
       assert.deepEqual([
         {url: '/q/owner:self+is:draft'},
         {url: '/c/331788/'},
         {url: '/groups/self'},
         {url: 'https://awesometown.com/#hashyhash'},
-      ].filter(element._isSupportedLink),
-      [
+      ].filter(element._isSupportedLink), [
         {url: '/q/owner:self+is:draft'},
         {url: '/c/331788/'},
         {url: 'https://awesometown.com/#hashyhash'},
       ]);
     });
 
-    test('_loadAccountCapabilities admin', function(done) {
-      sandbox.stub(element.$.restAPI, 'getAccountCapabilities', function() {
+    test('_loadAccountCapabilities admin', done => {
+      sandbox.stub(element.$.restAPI, 'getAccountCapabilities', () => {
         return Promise.resolve({
           createGroup: true,
           createProject: true,
           viewPlugins: true,
         });
       });
-      element._loadAccountCapabilities().then(function() {
+      element._loadAccountCapabilities().then(() => {
         assert.equal(element._adminLinks.length, 5);
         done();
       });
     });
 
-    test('_loadAccountCapabilities non admin', function(done) {
-      sandbox.stub(element.$.restAPI, 'getAccountCapabilities', function() {
+    test('_loadAccountCapabilities non admin', done => {
+      sandbox.stub(element.$.restAPI, 'getAccountCapabilities', () => {
         return Promise.resolve({});
       });
-      element._loadAccountCapabilities().then(function() {
+      element._loadAccountCapabilities().then(() => {
         assert.equal(element._adminLinks.length, 2);
         done();
       });
     });
 
-    test('user links', function() {
-      var defaultLinks = [{
+    test('user links', () => {
+      const defaultLinks = [{
         title: 'Faves',
         links: [{
           name: 'Pinterest',
           url: 'https://pinterest.com',
         }],
       }];
-      var userLinks = [{
+      const userLinks = [{
         name: 'Facebook',
         url: 'https://facebook.com',
       }];
-      var adminLinks = [{
+      const adminLinks = [{
         url: '/admin/groups',
         name: 'Groups',
       }];
@@ -132,8 +132,8 @@
           }));
     });
 
-    test('documentation links', function() {
-      var docLinks = [
+    test('documentation links', () => {
+      const docLinks = [
         {
           name: 'Table of Contents',
           url: '/index.html',
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 1f96014..2296567 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
@@ -15,7 +15,7 @@
   'use strict';
 
   // Latency reporting constants.
-  var TIMING = {
+  const TIMING = {
     TYPE: 'timing-report',
     CATEGORY: 'UI Latency',
     // Reported events - alphabetize below.
@@ -24,25 +24,25 @@
   };
 
   // Navigation reporting constants.
-  var NAVIGATION = {
+  const NAVIGATION = {
     TYPE: 'nav-report',
     CATEGORY: 'Location Changed',
     PAGE: 'Page',
   };
 
-  var ERROR = {
+  const ERROR = {
     TYPE: 'error',
     CATEGORY: 'exception',
   };
 
-  var INTERACTION_TYPE = 'interaction';
+  const INTERACTION_TYPE = 'interaction';
 
-  var CHANGE_VIEW_REGEX = /^\/c\/\d+\/?\d*$/;
-  var DIFF_VIEW_REGEX = /^\/c\/\d+\/\d+\/.+$/;
+  const CHANGE_VIEW_REGEX = /^\/c\/\d+\/?\d*$/;
+  const DIFF_VIEW_REGEX = /^\/c\/\d+\/\d+\/.+$/;
 
-  var pending = [];
+  const pending = [];
 
-  var onError = function(oldOnError, msg, url, line, column, error) {
+  const onError = function(oldOnError, msg, url, line, column, error) {
     if (oldOnError) {
       oldOnError(msg, url, line, column, error);
     }
@@ -51,23 +51,23 @@
       column = column || error.columnNumber;
       msg = msg || error.toString();
     }
-    var payload = {
-      url: url,
-      line: line,
-      column: column,
-      error: error,
+    const payload = {
+      url,
+      line,
+      column,
+      error,
     };
     GrReporting.prototype.reporter(ERROR.TYPE, ERROR.CATEGORY, msg, payload);
     return true;
   };
 
-  var catchErrors = function(opt_context) {
-    var context = opt_context || window;
+  const catchErrors = function(opt_context) {
+    const context = opt_context || window;
     context.onerror = onError.bind(null, context.onerror);
   };
   catchErrors();
 
-  var GrReporting = Polymer({
+  const GrReporting = Polymer({
     is: 'gr-reporting',
 
     properties: {
@@ -75,7 +75,7 @@
 
       _baselines: {
         type: Array,
-        value: function() { return {}; },
+        value() { return {}; },
       },
     },
 
@@ -87,24 +87,24 @@
       return window.performance.timing;
     },
 
-    now: function() {
+    now() {
       return Math.round(10 * window.performance.now()) / 10;
     },
 
-    reporter: function() {
-      var report = (Gerrit._arePluginsLoaded() && !pending.length) ?
+    reporter(...args) {
+      const report = (Gerrit._arePluginsLoaded() && !pending.length) ?
         this.defaultReporter : this.cachingReporter;
-      report.apply(this, arguments);
+      report.apply(this, args);
     },
 
-    defaultReporter: function(type, category, eventName, eventValue) {
-      var detail = {
-        type: type,
-        category: category,
+    defaultReporter(type, category, eventName, eventValue) {
+      const detail = {
+        type,
+        category,
         name: eventName,
         value: eventValue,
       };
-      document.dispatchEvent(new CustomEvent(type, {detail: detail}));
+      document.dispatchEvent(new CustomEvent(type, {detail}));
       if (type === ERROR.TYPE) {
         console.error(eventValue.error || eventName);
       } else {
@@ -113,15 +113,15 @@
       }
     },
 
-    cachingReporter: function(type, category, eventName, eventValue) {
+    cachingReporter(type, category, eventName, eventValue) {
       if (type === ERROR.TYPE) {
         console.error(eventValue.error || eventName);
       }
       if (Gerrit._arePluginsLoaded()) {
         if (pending.length) {
-          pending.splice(0).forEach(function(args) {
-            this.reporter.apply(this, args);
-          }, this);
+          for (const args of pending.splice(0)) {
+            this.reporter(...args);
+          }
         }
         this.reporter(type, category, eventName, eventValue);
       } else {
@@ -132,8 +132,8 @@
     /**
      * User-perceived app start time, should be reported when the app is ready.
      */
-    appStarted: function() {
-      var startTime =
+    appStarted() {
+      const startTime =
           new Date().getTime() - this.performanceTiming.navigationStart;
       this.reporter(
           TIMING.TYPE, TIMING.CATEGORY, TIMING.APP_STARTED, startTime);
@@ -142,22 +142,22 @@
     /**
      * Page load time, should be reported at any time after navigation.
      */
-    pageLoaded: function() {
+    pageLoaded() {
       if (this.performanceTiming.loadEventEnd === 0) {
         console.error('pageLoaded should be called after window.onload');
         this.async(this.pageLoaded, 100);
       } else {
-        var loadTime = this.performanceTiming.loadEventEnd -
+        const loadTime = this.performanceTiming.loadEventEnd -
             this.performanceTiming.navigationStart;
         this.reporter(
-          TIMING.TYPE, TIMING.CATEGORY, TIMING.PAGE_LOADED, loadTime);
+            TIMING.TYPE, TIMING.CATEGORY, TIMING.PAGE_LOADED, loadTime);
       }
     },
 
-    locationChanged: function() {
-      var page = '';
-      var pathname = this._getPathname();
-      if (pathname.indexOf('/q/') === 0) {
+    locationChanged() {
+      let page = '';
+      const pathname = this._getPathname();
+      if (pathname.startsWith('/q/')) {
         page = this.getBaseUrl() + '/q/';
       } else if (pathname.match(CHANGE_VIEW_REGEX)) { // change view
         page = this.getBaseUrl() + '/c/';
@@ -171,32 +171,32 @@
           NAVIGATION.TYPE, NAVIGATION.CATEGORY, NAVIGATION.PAGE, page);
     },
 
-    pluginsLoaded: function() {
+    pluginsLoaded() {
       this.timeEnd('PluginsLoaded');
     },
 
-    _getPathname: function() {
+    _getPathname() {
       return '/' + window.location.pathname.substring(this.getBaseUrl().length);
     },
 
     /**
      * Reset named timer.
      */
-    time: function(name) {
+    time(name) {
       this._baselines[name] = this.now();
     },
 
     /**
      * Finish named timer and report it to server.
      */
-    timeEnd: function(name) {
-      var baseTime = this._baselines[name] || 0;
-      var time = this.now() - baseTime;
+    timeEnd(name) {
+      const baseTime = this._baselines[name] || 0;
+      const time = this.now() - baseTime;
       this.reporter(TIMING.TYPE, TIMING.CATEGORY, name, time);
       delete this._baselines[name];
     },
 
-    reportInteraction: function(eventName, opt_msg) {
+    reportInteraction(eventName, opt_msg) {
       this.reporter(INTERACTION_TYPE, this.category, eventName, opt_msg);
     },
   });
@@ -204,5 +204,4 @@
   window.GrReporting = GrReporting;
   // Expose onerror installation so it would be accessible from tests.
   window.GrReporting._catchErrors = catchErrors;
-
 })();
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 2720ebd..bda03b1 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
@@ -32,15 +32,15 @@
 </test-fixture>
 
 <script>
-  suite('gr-reporting tests', function() {
-    var element;
-    var sandbox;
-    var clock;
-    var fakePerformance;
+  suite('gr-reporting tests', () => {
+    let element;
+    let sandbox;
+    let clock;
+    let fakePerformance;
 
-    var NOW_TIME = 100;
+    const NOW_TIME = 100;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
       clock = sinon.useFakeTimers(NOW_TIME);
       element = fixture('basic');
@@ -49,15 +49,15 @@
         loadEventEnd: 2,
       };
       sinon.stub(element, 'performanceTiming',
-          {get: function() {return fakePerformance;}});
+          {get() {return fakePerformance;}});
       sandbox.stub(element, 'reporter');
     });
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
       clock.restore();
     });
 
-    test('appStarted', function() {
+    test('appStarted', () => {
       element.appStarted();
       assert.isTrue(
           element.reporter.calledWithExactly(
@@ -66,7 +66,7 @@
       ));
     });
 
-    test('pageLoaded', function() {
+    test('pageLoaded', () => {
       element.pageLoaded();
       assert.isTrue(
           element.reporter.calledWithExactly(
@@ -75,8 +75,8 @@
       );
     });
 
-    test('time and timeEnd', function() {
-      var nowStub = sandbox.stub(element, 'now').returns(0);
+    test('time and timeEnd', () => {
+      const nowStub = sandbox.stub(element, 'now').returns(0);
       element.time('foo');
       nowStub.returns(1);
       element.time('bar');
@@ -92,14 +92,14 @@
       ));
     });
 
-    suite('plugins', function() {
-      setup(function() {
+    suite('plugins', () => {
+      setup(() => {
         element.reporter.restore();
         sandbox.stub(element, 'defaultReporter');
         sandbox.stub(Gerrit, '_arePluginsLoaded');
       });
 
-      test('pluginsLoaded reports time', function() {
+      test('pluginsLoaded reports time', () => {
         Gerrit._arePluginsLoaded.returns(true);
         sandbox.stub(element, 'now').returns(42);
         element.pluginsLoaded();
@@ -108,19 +108,19 @@
         ));
       });
 
-      test('caches reports if plugins are not loaded', function() {
+      test('caches reports if plugins are not loaded', () => {
         Gerrit._arePluginsLoaded.returns(false);
         element.timeEnd('foo');
         assert.isFalse(element.defaultReporter.called);
       });
 
-      test('reports if plugins are loaded', function() {
+      test('reports if plugins are loaded', () => {
         Gerrit._arePluginsLoaded.returns(true);
         element.timeEnd('foo');
         assert.isTrue(element.defaultReporter.called);
       });
 
-      test('reports cached events preserving order', function() {
+      test('reports cached events preserving order', () => {
         Gerrit._arePluginsLoaded.returns(false);
         element.timeEnd('foo');
         Gerrit._arePluginsLoaded.returns(true);
@@ -134,38 +134,38 @@
       });
     });
 
-    suite('location changed', function() {
-      var pathnameStub;
-      setup(function() {
+    suite('location changed', () => {
+      let pathnameStub;
+      setup(() => {
         pathnameStub = sinon.stub(element, '_getPathname');
       });
 
-      teardown(function() {
+      teardown(() => {
         pathnameStub.restore();
       });
 
-      test('search', function() {
+      test('search', () => {
         pathnameStub.returns('/q/foo');
         element.locationChanged();
         assert.isTrue(element.reporter.calledWithExactly(
             'nav-report', 'Location Changed', 'Page', '/q/'));
       });
 
-      test('change view', function() {
+      test('change view', () => {
         pathnameStub.returns('/c/42/');
         element.locationChanged();
         assert.isTrue(element.reporter.calledWithExactly(
             'nav-report', 'Location Changed', 'Page', '/c/'));
       });
 
-      test('change view', function() {
+      test('change view', () => {
         pathnameStub.returns('/c/41/2');
         element.locationChanged();
         assert.isTrue(element.reporter.calledWithExactly(
             'nav-report', 'Location Changed', 'Page', '/c/'));
       });
 
-      test('diff view', function() {
+      test('diff view', () => {
         pathnameStub.returns('/c/41/2/file.txt');
         element.locationChanged();
         assert.isTrue(element.reporter.calledWithExactly(
@@ -173,36 +173,35 @@
       });
     });
 
-    suite('exception logging', function() {
-      var fakeWindow;
-      var reporter;
+    suite('exception logging', () => {
+      let fakeWindow;
+      let reporter;
 
-      var emulateThrow = function(msg, url, line, column, error) {
+      const emulateThrow = function(msg, url, line, column, error) {
         return fakeWindow.onerror(msg, url, line, column, error);
       };
 
-      setup(function() {
+      setup(() => {
         reporter = sandbox.stub(GrReporting.prototype, 'reporter');
         fakeWindow = {};
         sandbox.stub(console, 'error');
         window.GrReporting._catchErrors(fakeWindow);
       });
 
-      test('is reported', function() {
-        var error = new Error('bar');
+      test('is reported', () => {
+        const error = new Error('bar');
         emulateThrow('bar', 'http://url', 4, 2, error);
-        assert.isTrue(
-            reporter.calledWith('error', 'exception', 'bar'));
-        var payload = reporter.lastCall.args[3];
+        assert.isTrue(reporter.calledWith('error', 'exception', 'bar'));
+        const payload = reporter.lastCall.args[3];
         assert.deepEqual(payload, {
           url: 'http://url',
           line: 4,
           column: 2,
-          error: error,
+          error,
         });
       });
 
-      test('prevent default event handler', function() {
+      test('prevent default event handler', () => {
         assert.isTrue(emulateThrow());
       });
     });
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 cc8d478..fcdb209 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -16,12 +16,12 @@
 
   // Polymer makes `app` intrinsically defined on the window by virtue of the
   // custom element having the id "app", but it is made explicit here.
-  var app = document.querySelector('#app');
+  const app = document.querySelector('#app');
   if (!app) {
     console.log('No gr-app found (running tests)');
   }
 
-  var _reporting;
+  let _reporting;
   function getReporting() {
     if (!_reporting) {
       _reporting = document.createElement('gr-reporting');
@@ -33,26 +33,26 @@
     getReporting().pageLoaded();
   };
 
-  window.addEventListener('WebComponentsReady', function() {
+  window.addEventListener('WebComponentsReady', () => {
     getReporting().timeEnd('WebComponentsReady');
   });
 
   function startRouter() {
-    var base = window.Gerrit.BaseUrlBehavior.getBaseUrl();
+    const base = window.Gerrit.BaseUrlBehavior.getBaseUrl();
     if (base) {
       page.base(base);
     }
 
-    var restAPI = document.createElement('gr-rest-api-interface');
-    var reporting = getReporting();
+    const restAPI = document.createElement('gr-rest-api-interface');
+    const reporting = getReporting();
 
     // Middleware
-    page(function(ctx, next) {
+    page((ctx, next) => {
       document.body.scrollTop = 0;
 
       // Fire asynchronously so that the URL is changed by the time the event
       // is processed.
-      app.async(function() {
+      app.async(() => {
         app.fire('location-change', {
           hash: window.location.hash,
           pathname: window.location.pathname,
@@ -63,13 +63,13 @@
     });
 
     function loadUser(ctx, next) {
-      restAPI.getLoggedIn().then(function() {
+      restAPI.getLoggedIn().then(() => {
         next();
       });
     }
 
     // Routes.
-    page('/', loadUser, function(data) {
+    page('/', loadUser, data => {
       if (data.querystring.match(/^closeAfterLogin/)) {
         // Close child window on redirect after login.
         window.close();
@@ -81,14 +81,14 @@
         if (data.hash[0] !== '/') {
           data.hash = '/' + data.hash;
         }
-        var newUrl = data.hash;
-        if (newUrl.indexOf('/VE/') === 0) {
+        let newUrl = data.hash;
+        if (newUrl.startsWith('/VE/')) {
           newUrl = '/settings' + data.hash;
         }
         page.redirect(newUrl);
         return;
       }
-      restAPI.getLoggedIn().then(function(loggedIn) {
+      restAPI.getLoggedIn().then(loggedIn => {
         if (loggedIn) {
           page.redirect('/dashboard/self');
         } else {
@@ -97,8 +97,8 @@
       });
     });
 
-    page('/dashboard/(.*)', loadUser, function(data) {
-      restAPI.getLoggedIn().then(function(loggedIn) {
+    page('/dashboard/(.*)', loadUser, data => {
+      restAPI.getLoggedIn().then(loggedIn => {
         if (loggedIn) {
           data.params.view = 'gr-dashboard-view';
           app.params = data.params;
@@ -108,8 +108,22 @@
       });
     });
 
-    page('/admin/(.*)', loadUser, function(data) {
-      restAPI.getLoggedIn().then(function(loggedIn) {
+    // Matches /admin/projects[,<offset>][/].
+    page(/^\/admin\/projects(,(\d+))?(\/)?$/, loadUser, data => {
+      restAPI.getLoggedIn().then(loggedIn => {
+        if (loggedIn) {
+          app.params = {
+            view: 'gr-admin-project-list',
+            offset: data.params[1] || 0,
+          };
+        } else {
+          page.redirect('/login/' + encodeURIComponent(data.canonicalPath));
+        }
+      });
+    });
+
+    page('/admin/(.*)', loadUser, data => {
+      restAPI.getLoggedIn().then(loggedIn => {
         if (loggedIn) {
           data.params.view = 'gr-admin-view';
           app.params = data.params;
@@ -127,7 +141,7 @@
     page('/q/:query,:offset', queryHandler);
     page('/q/:query', queryHandler);
 
-    page(/^\/(\d+)\/?/, function(ctx) {
+    page(/^\/(\d+)\/?/, ctx => {
       page.redirect('/c/' + encodeURIComponent(ctx.params[0]));
     });
 
@@ -139,9 +153,9 @@
     }
 
     // Matches /c/<changeNum>/[<basePatchNum>..][<patchNum>].
-    page(/^\/c\/(\d+)\/?(((\d+)(\.\.(\d+))?))?$/, function(ctx) {
+    page(/^\/c\/(\d+)\/?(((\d+)(\.\.(\d+))?))?$/, ctx => {
       // Parameter order is based on the regex group number matched.
-      var params = {
+      const params = {
         changeNum: ctx.params[0],
         basePatchNum: ctx.params[3],
         patchNum: ctx.params[5],
@@ -163,9 +177,9 @@
     });
 
     // Matches /c/<changeNum>/[<basePatchNum>..]<patchNum>/<path>.
-    page(/^\/c\/(\d+)\/((\d+)(\.\.(\d+))?)\/(.+)/, function(ctx) {
+    page(/^\/c\/(\d+)\/((\d+)(\.\.(\d+))?)\/(.+)/, ctx => {
       // Parameter order is based on the regex group number matched.
-      var params = {
+      const params = {
         changeNum: ctx.params[0],
         basePatchNum: ctx.params[2],
         patchNum: ctx.params[4],
@@ -177,7 +191,7 @@
         // TODO(kaspern): Utilize gr-url-encoding-behavior.html when the router
         // is replaced with a Polymer counterpart.
         // @see Issue 4255 regarding double-encoding.
-        var path = encodeURIComponent(encodeURIComponent(params.path));
+        let path = encodeURIComponent(encodeURIComponent(params.path));
         // @see Issue 4577 regarding more readable URLs.
         path = path.replace(/%252F/g, '/');
         path = path.replace(/%2520/g, '+');
@@ -194,7 +208,7 @@
       // Check if path has an '@' which indicates it was using GWT style line
       // numbers. Even if the filename had an '@' in it, it would have already
       // been URI encoded. Redirect to hash version of path.
-      if (ctx.path.indexOf('@') !== -1) {
+      if (ctx.path.includes('@')) {
         page.redirect(ctx.path.replace('@', '#'));
         return;
       }
@@ -203,8 +217,8 @@
       app.params = params;
     });
 
-    page(/^\/settings\/(agreements|new-agreement)/, loadUser, function(data) {
-      restAPI.getLoggedIn().then(function(loggedIn) {
+    page(/^\/settings\/(agreements|new-agreement)/, loadUser, data => {
+      restAPI.getLoggedIn().then(loggedIn => {
         if (loggedIn) {
           data.params.view = 'gr-cla-view';
           app.params = data.params;
@@ -214,8 +228,8 @@
       });
     });
 
-    page(/^\/settings\/VE\/(\S+)/, function(data) {
-      restAPI.getLoggedIn().then(function(loggedIn) {
+    page(/^\/settings\/VE\/(\S+)/, data => {
+      restAPI.getLoggedIn().then(loggedIn => {
         if (loggedIn) {
           app.params = {
             view: 'gr-settings-view',
@@ -227,8 +241,8 @@
       });
     });
 
-    page(/^\/settings\/?/, function(data) {
-      restAPI.getLoggedIn().then(function(loggedIn) {
+    page(/^\/settings\/?/, data => {
+      restAPI.getLoggedIn().then(loggedIn => {
         if (loggedIn) {
           app.params = {view: 'gr-settings-view'};
         } else {
@@ -237,9 +251,9 @@
       });
     });
 
-    page(/^\/register(\/.*)?/, function(ctx) {
+    page(/^\/register(\/.*)?/, ctx => {
       app.params = {justRegistered: true};
-      var path = ctx.params[0] || '/';
+      const path = ctx.params[0] || '/';
       page.show(path);
     });
 
@@ -248,7 +262,7 @@
 
   Polymer({
     is: 'gr-router',
-    start: function() {
+    start() {
       if (!app) { return; }
       startRouter();
     },
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
index d4c8380..ee08e75 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
@@ -15,7 +15,7 @@
   'use strict';
 
   // Possible static search options for auto complete.
-  var SEARCH_OPERATORS = [
+  const SEARCH_OPERATORS = [
     'added',
     'age',
     'age:1week', // Give an example age
@@ -44,6 +44,7 @@
     'is:abandoned',
     'is:closed',
     'is:draft',
+    'is:ignored',
     'is:mergeable',
     'is:merged',
     'is:open',
@@ -80,11 +81,12 @@
     'tr',
   ];
 
-  var SELF_EXPRESSION = 'self';
+  const SELF_EXPRESSION = 'self';
+  const ME_EXPRESSION = 'me';
 
-  var MAX_AUTOCOMPLETE_RESULTS = 10;
+  const MAX_AUTOCOMPLETE_RESULTS = 10;
 
-  var TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+\s*/g;
+  const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+\s*/g;
 
   Polymer({
     is: 'gr-search-bar',
@@ -111,22 +113,22 @@
       },
       keyEventTarget: {
         type: Object,
-        value: function() { return document.body; },
+        value() { return document.body; },
       },
       query: {
         type: Function,
-        value: function() {
+        value() {
           return this._getSearchSuggestions.bind(this);
         },
       },
       _inputVal: String,
     },
 
-    _valueChanged: function(value) {
+    _valueChanged(value) {
       this._inputVal = value;
     },
 
-    _handleInputCommit: function(e) {
+    _handleInputCommit(e) {
       this._preventDefaultAndNavigateToInputVal(e);
     },
 
@@ -138,9 +140,9 @@
      *
      * @param {!Event} e
      */
-    _preventDefaultAndNavigateToInputVal: function(e) {
+    _preventDefaultAndNavigateToInputVal(e) {
       e.preventDefault();
-      var target = Polymer.dom(e).rootTarget;
+      const target = Polymer.dom(e).rootTarget;
       // If the target is the #searchInput or has a sub-input component, that
       // is what holds the focus as opposed to the target from the DOM event.
       if (target.$.input) {
@@ -162,22 +164,25 @@
      * @return {!Promise} This returns a promise that resolves to an array of
      *     strings.
      */
-    _fetchAccounts: function(predicate, expression) {
+    _fetchAccounts(predicate, expression) {
       if (expression.length === 0) { return Promise.resolve([]); }
       return this.$.restAPI.getSuggestedAccounts(
           expression,
           MAX_AUTOCOMPLETE_RESULTS)
-          .then(function(accounts) {
+          .then(accounts => {
             if (!accounts) { return []; }
-            return accounts.map(function(acct) {
-              return predicate + ':"' + acct.name + ' <' + acct.email + '>"';
-            });
-          }).then(function(accounts) {
+            return accounts.map(acct =>
+                predicate + ':"' + acct.name + ' <' + acct.email + '>"');
+          }).then(accounts => {
             // When the expression supplied is a beginning substring of 'self',
             // add it as an autocomplete option.
-            return SELF_EXPRESSION.indexOf(expression) === 0 ?
-                accounts.concat([predicate + ':' + SELF_EXPRESSION]) :
-                accounts;
+            if (SELF_EXPRESSION.startsWith(expression)) {
+              return accounts.concat([predicate + ':' + SELF_EXPRESSION]);
+            } else if (ME_EXPRESSION.startsWith(expression)) {
+              return accounts.concat([predicate + ':' + ME_EXPRESSION]);
+            } else {
+              return accounts;
+            }
           });
     },
 
@@ -190,15 +195,15 @@
      * @return {!Promise} This returns a promise that resolves to an array of
      *     strings.
      */
-    _fetchGroups: function(predicate, expression) {
+    _fetchGroups(predicate, expression) {
       if (expression.length === 0) { return Promise.resolve([]); }
       return this.$.restAPI.getSuggestedGroups(
           expression,
           MAX_AUTOCOMPLETE_RESULTS)
-          .then(function(groups) {
+          .then(groups => {
             if (!groups) { return []; }
-            var keys = Object.keys(groups);
-            return keys.map(function(key) { return predicate + ':' + key; });
+            const keys = Object.keys(groups);
+            return keys.map(key => predicate + ':' + key);
           });
     },
 
@@ -211,14 +216,14 @@
      * @return {!Promise} This returns a promise that resolves to an array of
      *     strings.
      */
-    _fetchProjects: function(predicate, expression) {
+    _fetchProjects(predicate, expression) {
       return this.$.restAPI.getSuggestedProjects(
           expression,
           MAX_AUTOCOMPLETE_RESULTS)
-          .then(function(projects) {
+          .then(projects => {
             if (!projects) { return []; }
-            var keys = Object.keys(projects);
-            return keys.map(function(key) { return predicate + ':' + key; });
+            const keys = Object.keys(projects);
+            return keys.map(key => predicate + ':' + key);
           });
     },
 
@@ -229,11 +234,11 @@
      * @return {!Promise} This returns a promise that resolves to an array of
      *     strings.
      */
-    _fetchSuggestions: function(input) {
+    _fetchSuggestions(input) {
       // Split the input on colon to get a two part predicate/expression.
-      var splitInput = input.split(':');
-      var predicate = splitInput[0];
-      var expression = splitInput[1] || '';
+      const splitInput = input.split(':');
+      const predicate = splitInput[0];
+      const expression = splitInput[1] || '';
       // Switch on the predicate to determine what to autocomplete.
       switch (predicate) {
         case 'ownerin':
@@ -259,9 +264,7 @@
 
         default:
           return Promise.resolve(SEARCH_OPERATORS
-              .filter(function(operator) {
-                return operator.indexOf(input) !== -1;
-              }));
+              .filter(operator => operator.includes(input)));
       }
     },
 
@@ -271,19 +274,19 @@
      * @return {!Promise} This returns a promise that resolves to an array of
      *     strings.
      */
-    _getSearchSuggestions: function(input) {
+    _getSearchSuggestions(input) {
       // Allow spaces within quoted terms.
-      var tokens = input.match(TOKENIZE_REGEX);
-      var trimmedInput = tokens[tokens.length - 1].toLowerCase();
+      const tokens = input.match(TOKENIZE_REGEX);
+      const trimmedInput = tokens[tokens.length - 1].toLowerCase();
 
       return this._fetchSuggestions(trimmedInput)
-          .then(function(operators) {
+          .then(operators => {
             if (!operators || !operators.length) { return []; }
             return operators
                 // Prioritize results that start with the input.
-                .sort(function(a, b) {
-                  var aContains = a.toLowerCase().indexOf(trimmedInput);
-                  var bContains = b.toLowerCase().indexOf(trimmedInput);
+                .sort((a, b) => {
+                  const aContains = a.toLowerCase().indexOf(trimmedInput);
+                  const bContains = b.toLowerCase().indexOf(trimmedInput);
                   if (aContains === bContains) {
                     return a.localeCompare(b);
                   }
@@ -298,7 +301,7 @@
                 // Return only the first {MAX_AUTOCOMPLETE_RESULTS} results.
                 .slice(0, MAX_AUTOCOMPLETE_RESULTS - 1)
                 // Map to an object to play nice with gr-autocomplete.
-                .map(function(operator) {
+                .map(operator => {
                   return {
                     name: operator,
                     value: operator,
@@ -307,7 +310,7 @@
           });
     },
 
-    _handleForwardSlashKey: function(e) {
+    _handleForwardSlashKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
index 3ddc96b..54ca399 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
@@ -35,26 +35,26 @@
 </test-fixture>
 
 <script>
-  suite('gr-search-bar tests', function() {
-    var element;
+  suite('gr-search-bar tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
     });
 
-    test('value is propagated to _inputVal', function() {
+    test('value is propagated to _inputVal', () => {
       element.value = 'foo';
       assert.equal(element._inputVal, 'foo');
     });
 
-    function getActiveElement() {
+    getActiveElement = () => {
       return document.activeElement.shadowRoot ?
           document.activeElement.shadowRoot.activeElement :
           document.activeElement;
-    }
+    };
 
-    test('tap on search button triggers nav', function(done) {
-      sinon.stub(page, 'show', function() {
+    test('tap on search button triggers nav', done => {
+      sinon.stub(page, 'show', () => {
         page.show.restore();
         assert.notEqual(getActiveElement(), element.$.searchInput);
         assert.notEqual(getActiveElement(), element.$.searchButton);
@@ -64,8 +64,8 @@
       MockInteractions.tap(element.$.searchButton);
     });
 
-    test('enter in search input triggers nav', function(done) {
-      sinon.stub(page, 'show', function() {
+    test('enter in search input triggers nav', done => {
+      sinon.stub(page, 'show', () => {
         page.show.restore();
         assert.notEqual(getActiveElement(), element.$.searchInput);
         assert.notEqual(getActiveElement(), element.$.searchButton);
@@ -76,8 +76,8 @@
           null, 'enter');
     });
 
-    test('search query should be double-escaped', function() {
-      var showStub = sinon.stub(page, 'show');
+    test('search query should be double-escaped', () => {
+      const showStub = sinon.stub(page, 'show');
       element.$.searchInput.text = 'fate/stay';
       MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
           null, 'enter');
@@ -85,9 +85,9 @@
       showStub.restore();
     });
 
-    test('input blurred after commit', function() {
-      var showStub = sinon.stub(page, 'show');
-      var blurSpy = sinon.spy(element.$.searchInput.$.input, 'blur');
+    test('input blurred after commit', () => {
+      const showStub = sinon.stub(page, 'show');
+      const blurSpy = sinon.spy(element.$.searchInput.$.input, 'blur');
       element.$.searchInput.text = 'fate/stay';
       MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
           null, 'enter');
@@ -96,86 +96,97 @@
       blurSpy.restore();
     });
 
-    test('empty search query does not trigger nav', function() {
-      var showSpy = sinon.spy(page, 'show');
+    test('empty search query does not trigger nav', () => {
+      const showSpy = sinon.spy(page, 'show');
       element.value = '';
       MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
           null, 'enter');
       assert.isFalse(showSpy.called);
     });
 
-    test('keyboard shortcuts', function() {
-      var focusSpy = sinon.spy(element.$.searchInput, 'focus');
-      var selectAllSpy = sinon.spy(element.$.searchInput, 'selectAll');
+    test('keyboard shortcuts', () => {
+      const focusSpy = sinon.spy(element.$.searchInput, 'focus');
+      const selectAllSpy = sinon.spy(element.$.searchInput, 'selectAll');
       MockInteractions.pressAndReleaseKeyOn(document.body, 191, null, '/');
       assert.isTrue(focusSpy.called);
       assert.isTrue(selectAllSpy.called);
     });
 
-    suite('_getSearchSuggestions', function() {
-      setup(function() {
-        sinon.stub(element.$.restAPI, 'getSuggestedAccounts', function() {
-          return Promise.resolve([
+    suite('_getSearchSuggestions', () => {
+      setup(() => {
+        sinon.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
+          Promise.resolve([
             {
               name: 'fred',
               email: 'fred@goog.co',
             },
-          ]);
-        });
-        sinon.stub(element.$.restAPI, 'getSuggestedGroups', function() {
-          return Promise.resolve({
+          ])
+        );
+        sinon.stub(element.$.restAPI, 'getSuggestedGroups', () =>
+          Promise.resolve({
             Polygerrit: 0,
             gerrit: 0,
             gerrittest: 0,
-          });
-        });
-        sinon.stub(element.$.restAPI, 'getSuggestedProjects', function() {
-          return Promise.resolve({
+          })
+        );
+        sinon.stub(element.$.restAPI, 'getSuggestedProjects', () =>
+          Promise.resolve({
             Polygerrit: 0,
-          });
-        });
+          })
+        );
       });
 
-      teardown(function() {
+      teardown(() => {
         element.$.restAPI.getSuggestedAccounts.restore();
         element.$.restAPI.getSuggestedGroups.restore();
         element.$.restAPI.getSuggestedProjects.restore();
       });
 
-      test('Autocompletes accounts', function(done) {
-        element._getSearchSuggestions('owner:fr').then(function(s) {
+      test('Autocompletes accounts', done => {
+        element._getSearchSuggestions('owner:fr').then(s => {
           assert.equal(s[0].value, 'owner:"fred <fred@goog.co>"');
           done();
         });
       });
 
-      test('Inserts self as option when valid', function(done) {
-        element._getSearchSuggestions('owner:s').then(function(s) {
+      test('Inserts self as option when valid', done => {
+        element._getSearchSuggestions('owner:s').then(s => {
           assert.equal(s[0].value, 'owner:self');
-        }).then(function() {
-          element._getSearchSuggestions('owner:selfs').then(function(s) {
+        }).then(() => {
+          element._getSearchSuggestions('owner:selfs').then(s => {
             assert.notEqual(s[0].value, 'owner:self');
             done();
           });
         });
       });
 
-      test('Autocompletes groups', function(done) {
-        element._getSearchSuggestions('ownerin:pol').then(function(s) {
+      test('Inserts me as option when valid', done => {
+        element._getSearchSuggestions('owner:m').then(s => {
+          assert.equal(s[0].value, 'owner:me');
+        }).then(() => {
+          element._getSearchSuggestions('owner:meme').then(s => {
+            assert.notEqual(s[0].value, 'owner:me');
+            done();
+          });
+        });
+      });
+
+      test('Autocompletes groups', done => {
+        element._getSearchSuggestions('ownerin:pol').then(s => {
           assert.equal(s[0].value, 'ownerin:Polygerrit');
           done();
         });
       });
 
-      test('Autocompletes projects', function(done) {
-        element._getSearchSuggestions('project:pol').then(function(s) {
+      test('Autocompletes projects', done => {
+        element._getSearchSuggestions('project:pol').then(s => {
           assert.equal(s[0].value, 'project:Polygerrit');
           done();
         });
       });
 
-      test('Autocompletes simple searches', function(done) {
-        element._getSearchSuggestions('is:o').then(function(s) {
+      test('Autocompletes simple searches', done => {
+        element._getSearchSuggestions('is:o').then(s => {
           assert.equal(s[0].name, 'is:open');
           assert.equal(s[0].value, 'is:open');
           assert.equal(s[1].name, 'is:owner');
@@ -184,26 +195,25 @@
         });
       });
 
-      test('Does not autocomplete with no match', function(done) {
-        element._getSearchSuggestions('asdasdasdasd').then(function(s) {
+      test('Does not autocomplete with no match', done => {
+        element._getSearchSuggestions('asdasdasdasd').then(s => {
           assert.equal(s.length, 0);
           done();
         });
       });
 
-      test('Autocomplete doesnt override exact matches to input',
-          function(done) {
-        element._getSearchSuggestions('ownerin:gerrit').then(function(s) {
+      test('Autocomplete doesnt override exact matches to input', done => {
+        element._getSearchSuggestions('ownerin:gerrit').then(s => {
           assert.equal(s[0].value, 'ownerin:gerrit');
           done();
         });
       });
 
-      test('Autocomplete respects spaces', function(done) {
-        element._getSearchSuggestions('is:ope').then(function(s) {
+      test('Autocomplete respects spaces', done => {
+        element._getSearchSuggestions('is:ope').then(s => {
           assert.equal(s[0].name, 'is:open');
           assert.equal(s[0].value, 'is:open');
-          element._getSearchSuggestions('is:ope ').then(function(s) {
+          element._getSearchSuggestions('is:ope ').then(s => {
             assert.equal(s.length, 0);
             done();
           });
diff --git a/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html b/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html
new file mode 100644
index 0000000..1581744
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html
@@ -0,0 +1,69 @@
+<!--
+Copyright (C) 2017 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.
+-->
+
+<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+
+<dom-module id="gr-confirm-delete-comment-dialog">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      :host([disabled]) {
+        opacity: .5;
+        pointer-events: none;
+      }
+      .main {
+        display: flex;
+        flex-direction: column;
+        width: 100%;
+      }
+      label {
+        cursor: pointer;
+        display: block;
+        width: 100%;
+      }
+      iron-autogrow-textarea {
+        font-family: var(--monospace-font-family);
+        padding: 0;
+        width: 73ch; /* Add a char to account for the border. */
+
+        --iron-autogrow-textarea {
+          border: 1px solid #ddd;
+          font-family: var(--monospace-font-family);
+        }
+      }
+    </style>
+    <gr-confirm-dialog
+        confirm-label="Delete"
+        on-confirm="_handleConfirmTap"
+        on-cancel="_handleCancelTap">
+      <div class="header">Delete Comment</div>
+      <div class="main">
+        <label for="messageInput">Enter comment delete reason</label>
+        <iron-autogrow-textarea
+            id="messageInput"
+            class="message"
+            autocomplete="on"
+            placeholder="<Insert reasoning here>"
+            bind-value="{{message}}"></iron-autogrow-textarea>
+      </div>
+    </gr-confirm-dialog>
+  </template>
+  <script src="gr-confirm-delete-comment-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js b/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js
new file mode 100644
index 0000000..e0eb078
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js
@@ -0,0 +1,50 @@
+// Copyright (C) 2017 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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-confirm-delete-comment-dialog',
+
+    /**
+     * Fired when the confirm button is pressed.
+     *
+     * @event confirm
+     */
+
+    /**
+     * Fired when the cancel button is pressed.
+     *
+     * @event cancel
+     */
+
+    properties: {
+      message: String,
+    },
+
+    resetFocus() {
+      this.$.messageInput.textarea.focus();
+    },
+
+    _handleConfirmTap(e) {
+      e.preventDefault();
+      this.fire('confirm', {reason: this.message}, {bubbles: false});
+    },
+
+    _handleCancelTap(e) {
+      e.preventDefault();
+      this.fire('cancel', null, {bubbles: false});
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
index 6b1e59e..683f2fc 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
@@ -17,6 +17,8 @@
   // Prevent redefinition.
   if (window.GrDiffBuilderImage) { return; }
 
+  const IMAGE_MIME_PATTERN = /^image\/(bmp|gif|jpeg|jpg|png|tiff|webp)$/;
+
   function GrDiffBuilderImage(diff, comments, prefs, outputEl, baseImage,
       revisionImage) {
     GrDiffBuilderSideBySide.call(this, diff, comments, prefs, outputEl, []);
@@ -29,7 +31,7 @@
   GrDiffBuilderImage.prototype.constructor = GrDiffBuilderImage;
 
   GrDiffBuilderImage.prototype.renderDiffImages = function() {
-    var section = this._createElement('tbody', 'image-diff');
+    const section = this._createElement('tbody', 'image-diff');
 
     this._emitImagePair(section);
     this._emitImageLabels(section);
@@ -38,25 +40,30 @@
   };
 
   GrDiffBuilderImage.prototype._emitImagePair = function(section) {
-    var tr = this._createElement('tr');
+    const tr = this._createElement('tr');
 
     tr.appendChild(this._createElement('td'));
-    tr.appendChild(this._createImageCell(this._baseImage, 'left'));
+    tr.appendChild(this._createImageCell(this._baseImage, 'left', section));
 
     tr.appendChild(this._createElement('td'));
-    tr.appendChild(this._createImageCell(this._revisionImage, 'right'));
+    tr.appendChild(this._createImageCell(
+        this._revisionImage, 'right', section));
 
     section.appendChild(tr);
   };
 
-  GrDiffBuilderImage.prototype._createImageCell = function(image, className) {
-    var td = this._createElement('td', className);
-    if (image) {
-      var imageEl = this._createElement('img');
+  GrDiffBuilderImage.prototype._createImageCell = function(image, className,
+      section) {
+    const td = this._createElement('td', className);
+    if (image && IMAGE_MIME_PATTERN.test(image.type)) {
+      const imageEl = this._createElement('img');
+      imageEl.onload = function() {
+        image._height = imageEl.naturalHeight;
+        image._width = imageEl.naturalWidth;
+        this._updateImageLabel(section, className, image);
+      }.bind(this);
       imageEl.src = 'data:' + image.type + ';base64, ' + image.body;
-      image._height = imageEl.naturalHeight;
-      image._width = imageEl.naturalWidth;
-      imageEl.addEventListener('error', function(e) {
+      imageEl.addEventListener('error', () => {
         imageEl.remove();
         td.textContent = '[Image failed to load]';
       });
@@ -65,20 +72,61 @@
     return td;
   };
 
+  GrDiffBuilderImage.prototype._updateImageLabel = function(section, className,
+      image) {
+    const label = Polymer.dom(section)
+        .querySelector('.' + className + ' span.label');
+    this._setLabelText(label, image);
+  };
+
+  GrDiffBuilderImage.prototype._setLabelText = function(label, image) {
+    label.textContent = this._getImageLabel(image);
+  };
+
   GrDiffBuilderImage.prototype._emitImageLabels = function(section) {
-    var tr = this._createElement('tr');
+    const tr = this._createElement('tr');
+
+    let addNamesInLabel = false;
+
+    if (this._baseImage && this._revisionImage &&
+        this._baseImage._name !== this._revisionImage._name) {
+      addNamesInLabel = true;
+    }
 
     tr.appendChild(this._createElement('td'));
-    var td = this._createElement('td', 'left');
-    var label = this._createElement('label');
-    label.textContent = this._getImageLabel(this._baseImage);
+    let td = this._createElement('td', 'left');
+    let label = this._createElement('label');
+    let nameSpan;
+    let labelSpan = this._createElement('span', 'label');
+
+    if (addNamesInLabel) {
+      nameSpan = this._createElement('span', 'name');
+      nameSpan.textContent = this._baseImage._name;
+      label.appendChild(nameSpan);
+      label.appendChild(this._createElement('br'));
+    }
+
+    this._setLabelText(labelSpan, this._baseImage, addNamesInLabel);
+
+    label.appendChild(labelSpan);
     td.appendChild(label);
     tr.appendChild(td);
 
     tr.appendChild(this._createElement('td'));
     td = this._createElement('td', 'right');
     label = this._createElement('label');
-    label.textContent = this._getImageLabel(this._revisionImage);
+    labelSpan = this._createElement('span', 'label');
+
+    if (addNamesInLabel) {
+      nameSpan = this._createElement('span', 'name');
+      nameSpan.textContent = this._revisionImage._name;
+      label.appendChild(nameSpan);
+      label.appendChild(this._createElement('br'));
+    }
+
+    this._setLabelText(labelSpan, this._revisionImage, addNamesInLabel);
+
+    label.appendChild(labelSpan);
     td.appendChild(label);
     tr.appendChild(td);
 
@@ -87,7 +135,7 @@
 
   GrDiffBuilderImage.prototype._getImageLabel = function(image) {
     if (image) {
-      var type = image.type || image._expectedType;
+      const type = image.type || image._expectedType;
       if (image._width && image._height) {
         return image._width + '⨉' + image._height + ' ' + type;
       } else {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
index cbc00b8..6f47f9b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
@@ -24,13 +24,13 @@
   GrDiffBuilderSideBySide.prototype.constructor = GrDiffBuilderSideBySide;
 
   GrDiffBuilderSideBySide.prototype.buildSectionElement = function(group) {
-    var sectionEl = this._createElement('tbody', 'section');
+    const sectionEl = this._createElement('tbody', 'section');
     sectionEl.classList.add(group.type);
     if (this._isTotal(group)) {
       sectionEl.classList.add('total');
     }
-    var pairs = group.getSideBySidePairs();
-    for (var i = 0; i < pairs.length; i++) {
+    const pairs = group.getSideBySidePairs();
+    for (let i = 0; i < pairs.length; i++) {
       sectionEl.appendChild(this._createRow(sectionEl, pairs[i].left,
           pairs[i].right));
     }
@@ -38,11 +38,11 @@
   };
 
   GrDiffBuilderSideBySide.prototype.addColumns = function(outputEl, fontSize) {
-    var width = fontSize * 4;
-    var colgroup = document.createElement('colgroup');
+    const width = fontSize * 4;
+    const colgroup = document.createElement('colgroup');
 
     // Add left-side line number.
-    var col = document.createElement('col');
+    let col = document.createElement('col');
     col.setAttribute('width', width);
     colgroup.appendChild(col);
 
@@ -62,7 +62,7 @@
 
   GrDiffBuilderSideBySide.prototype._createRow = function(section, leftLine,
       rightLine) {
-    var row = this._createElement('tr');
+    const row = this._createElement('tr');
     row.classList.add('diff-row', 'side-by-side');
     row.setAttribute('left-type', leftLine.type);
     row.setAttribute('right-type', rightLine.type);
@@ -76,15 +76,15 @@
 
   GrDiffBuilderSideBySide.prototype._appendPair = function(section, row, line,
       lineNumber, side) {
-    var lineEl = this._createLineEl(line, lineNumber, line.type, side);
+    const lineEl = this._createLineEl(line, lineNumber, line.type, side);
     lineEl.classList.add(side);
     row.appendChild(lineEl);
-    var action = this._createContextControl(section, line);
+    const action = this._createContextControl(section, line);
     if (action) {
       row.appendChild(action);
     } else {
-      var textEl = this._createTextEl(line, side);
-      var threadGroupEl = this._commentThreadGroupForLine(line, side);
+      const textEl = this._createTextEl(line, side);
+      const threadGroupEl = this._commentThreadGroupForLine(line, side);
       if (threadGroupEl) {
         textEl.appendChild(threadGroupEl);
       }
@@ -94,7 +94,7 @@
 
   GrDiffBuilderSideBySide.prototype._getNextContentOnSide = function(
       content, side) {
-    var tr = content.parentElement.parentElement;
+    let tr = content.parentElement.parentElement;
     while (tr = tr.nextSibling) {
       content = tr.querySelector(
           'td.content .contentText[data-side="' + side + '"]');
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
index 55a6bea..64c8f95 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
@@ -24,24 +24,24 @@
   GrDiffBuilderUnified.prototype.constructor = GrDiffBuilderUnified;
 
   GrDiffBuilderUnified.prototype.buildSectionElement = function(group) {
-    var sectionEl = this._createElement('tbody', 'section');
+    const sectionEl = this._createElement('tbody', 'section');
     sectionEl.classList.add(group.type);
     if (this._isTotal(group)) {
       sectionEl.classList.add('total');
     }
 
-    for (var i = 0; i < group.lines.length; ++i) {
+    for (let i = 0; i < group.lines.length; ++i) {
       sectionEl.appendChild(this._createRow(sectionEl, group.lines[i]));
     }
     return sectionEl;
   };
 
   GrDiffBuilderUnified.prototype.addColumns = function(outputEl, fontSize) {
-    var width = fontSize * 4;
-    var colgroup = document.createElement('colgroup');
+    const width = fontSize * 4;
+    const colgroup = document.createElement('colgroup');
 
     // Add left-side line number.
-    var col = document.createElement('col');
+    let col = document.createElement('col');
     col.setAttribute('width', width);
     colgroup.appendChild(col);
 
@@ -57,8 +57,8 @@
   };
 
   GrDiffBuilderUnified.prototype._createRow = function(section, line) {
-    var row = this._createElement('tr', line.type);
-    var lineEl = this._createLineEl(line, line.beforeNumber,
+    const row = this._createElement('tr', line.type);
+    let lineEl = this._createLineEl(line, line.beforeNumber,
         GrDiffLine.Type.REMOVE);
     lineEl.classList.add('left');
     row.appendChild(lineEl);
@@ -68,12 +68,12 @@
     row.appendChild(lineEl);
     row.classList.add('diff-row', 'unified');
 
-    var action = this._createContextControl(section, line);
+    const action = this._createContextControl(section, line);
     if (action) {
       row.appendChild(action);
     } else {
-      var textEl = this._createTextEl(line);
-      var threadGroupEl = this._commentThreadGroupForLine(line);
+      const textEl = this._createTextEl(line);
+      const threadGroupEl = this._commentThreadGroupForLine(line);
       if (threadGroupEl) {
         textEl.appendChild(threadGroupEl);
       }
@@ -84,7 +84,7 @@
 
   GrDiffBuilderUnified.prototype._getNextContentOnSide = function(
       content, side) {
-    var tr = content.parentElement.parentElement;
+    let tr = content.parentElement.parentElement;
     while (tr = tr.nextSibling) {
       if (tr.classList.contains('both') || (
           (side === 'left' && tr.classList.contains('remove')) ||
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 a936934..e20aecc 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
@@ -48,12 +48,12 @@
     (function() {
       'use strict';
 
-      var DiffViewMode = {
+      const DiffViewMode = {
         SIDE_BY_SIDE: 'SIDE_BY_SIDE',
         UNIFIED: 'UNIFIED_DIFF',
       };
 
-      var TimingLabel = {
+      const TimingLabel = {
         TOTAL: 'Diff Total Render',
         CONTENT: 'Diff Content Render',
         SYNTAX: 'Diff Syntax Render',
@@ -61,9 +61,9 @@
 
       // 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;
+      const SYNTAX_MAX_LINE_LENGTH = 500;
 
-      var TRAILING_WHITESPACE_PATTERN = /\s+$/;
+      const TRAILING_WHITESPACE_PATTERN = /\s+$/;
 
       Polymer({
         is: 'gr-diff-builder',
@@ -108,7 +108,7 @@
           '_groupsChanged(_groups.splices)',
         ],
 
-        attached: function() {
+        attached() {
           // Setup annotation layers.
           this._layers = [
             this._createTrailingWhitespaceLayer(),
@@ -118,12 +118,12 @@
             this.$.rangeLayer,
           ];
 
-          this.async(function() {
+          this.async(() => {
             this._preRenderThread();
           });
         },
 
-        render: function(comments, prefs) {
+        render(comments, prefs) {
           this.$.syntaxLayer.enabled = prefs.syntax_highlighting;
           this._showTabs = !!prefs.show_tabs;
           this._showTrailingWhitespace = !!prefs.show_whitespace_errors;
@@ -140,33 +140,35 @@
           this._clearDiffContent();
           this._builder.addColumns(this.diffElement, prefs.font_size);
 
-          var reporting = this.$.reporting;
+          const reporting = this.$.reporting;
 
           reporting.time(TimingLabel.TOTAL);
           reporting.time(TimingLabel.CONTENT);
           this.dispatchEvent(new CustomEvent('render-start', {bubbles: true}));
-          return this.$.processor.process(this.diff.content).then(function() {
-            if (this.isImageDiff) {
-              this._builder.renderDiffImages();
-            }
-            this.dispatchEvent(new CustomEvent('render-content',
-                {bubbles: true}));
+          return this.$.processor.process(this.diff.content, this.isImageDiff)
+              .then(() => {
+                if (this.isImageDiff) {
+                  this._builder.renderDiffImages();
+                }
+                this.dispatchEvent(new CustomEvent('render-content',
+                    {bubbles: true}));
 
-            if (this._anyLineTooLong()) {
-              this.$.syntaxLayer.enabled = false;
-            }
+                if (this._anyLineTooLong()) {
+                  this.$.syntaxLayer.enabled = false;
+                }
 
-            reporting.timeEnd(TimingLabel.CONTENT);
-            reporting.time(TimingLabel.SYNTAX);
-            return this.$.syntaxLayer.process().then(function() {
-              reporting.timeEnd(TimingLabel.SYNTAX);
-              reporting.timeEnd(TimingLabel.TOTAL);
-              this.dispatchEvent(new CustomEvent('render', {bubbles: true}));
-            }.bind(this));
-          }.bind(this));
+                reporting.timeEnd(TimingLabel.CONTENT);
+                reporting.time(TimingLabel.SYNTAX);
+                return this.$.syntaxLayer.process().then(() => {
+                  reporting.timeEnd(TimingLabel.SYNTAX);
+                  reporting.timeEnd(TimingLabel.TOTAL);
+                  this.dispatchEvent(
+                      new CustomEvent('render', {bubbles: true}));
+                });
+              });
         },
 
-        getLineElByChild: function(node) {
+        getLineElByChild(node) {
           while (node) {
             if (node instanceof Element) {
               if (node.classList.contains('lineNum')) {
@@ -181,154 +183,154 @@
           return null;
         },
 
-        getLineNumberByChild: function(node) {
-          var lineEl = this.getLineElByChild(node);
+        getLineNumberByChild(node) {
+          const lineEl = this.getLineElByChild(node);
           return lineEl ?
-              parseInt(lineEl.getAttribute('data-value'), 10) : null;
+              parseInt(lineEl.getAttribute('data-value'), 10) :
+              null;
         },
 
-        getContentByLine: function(lineNumber, opt_side, opt_root) {
+        getContentByLine(lineNumber, opt_side, opt_root) {
           return this._builder.getContentByLine(lineNumber, opt_side, opt_root);
         },
 
-        getContentByLineEl: function(lineEl) {
-          var root = Polymer.dom(lineEl.parentElement);
-          var side = this.getSideByLineEl(lineEl);
-          var line = lineEl.getAttribute('data-value');
+        getContentByLineEl(lineEl) {
+          const root = Polymer.dom(lineEl.parentElement);
+          const side = this.getSideByLineEl(lineEl);
+          const line = lineEl.getAttribute('data-value');
           return this.getContentByLine(line, side, root);
         },
 
-        getLineElByNumber: function(lineNumber, opt_side) {
-          var sideSelector = !!opt_side ? ('.' + opt_side) : '';
+        getLineElByNumber(lineNumber, opt_side) {
+          const sideSelector = opt_side ? ('.' + opt_side) : '';
           return this.diffElement.querySelector(
               '.lineNum[data-value="' + lineNumber + '"]' + sideSelector);
         },
 
-        getContentsByLineRange: function(startLine, endLine, opt_side) {
-          var result = [];
+        getContentsByLineRange(startLine, endLine, opt_side) {
+          const result = [];
           this._builder.findLinesByRange(startLine, endLine, opt_side, null,
               result);
           return result;
         },
 
-        getSideByLineEl: function(lineEl) {
+        getSideByLineEl(lineEl) {
           return lineEl.classList.contains(GrDiffBuilder.Side.RIGHT) ?
-              GrDiffBuilder.Side.RIGHT : GrDiffBuilder.Side.LEFT;
+          GrDiffBuilder.Side.RIGHT : GrDiffBuilder.Side.LEFT;
         },
 
-        createCommentThreadGroup: function(changeNum, patchNum, path,
+        createCommentThreadGroup(changeNum, patchNum, path,
             isOnParent, commentSide, projectConfig) {
           return this._builder.createCommentThreadGroup(changeNum, patchNum,
               path, isOnParent, commentSide, projectConfig);
         },
 
-        emitGroup: function(group, sectionEl) {
+        emitGroup(group, sectionEl) {
           this._builder.emitGroup(group, sectionEl);
         },
 
-        showContext: function(newGroups, sectionEl) {
-          var groups = this._builder.groups;
-          // TODO(viktard): Polyfill findIndex for IE10.
-          var contextIndex = groups.findIndex(function(group) {
-            return group.element == sectionEl;
-          });
-          groups.splice.apply(groups, [contextIndex, 1].concat(newGroups));
+        showContext(newGroups, sectionEl) {
+          const groups = this._builder.groups;
 
-          newGroups.forEach(function(newGroup) {
+          const contextIndex = groups.findIndex(group =>
+            group.element === sectionEl
+          );
+          groups.splice(...[contextIndex, 1].concat(newGroups));
+
+          for (const newGroup of newGroups) {
             this._builder.emitGroup(newGroup, sectionEl);
-          }, this);
+          }
           sectionEl.parentNode.removeChild(sectionEl);
 
-          this.async(function() {
-            this.fire('render');
-          }, 1);
+          this.async(() => this.fire('render-content'), 1);
         },
 
-        _getDiffBuilder: function(diff, comments, prefs) {
+        _getDiffBuilder(diff, comments, prefs) {
           if (this.isImageDiff) {
             return new GrDiffBuilderImage(diff, comments, prefs,
-                this.diffElement, this.baseImage, this.revisionImage);
+            this.diffElement, this.baseImage, this.revisionImage);
           } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
             return new GrDiffBuilderSideBySide(
-                diff, comments, prefs, this.diffElement, this._layers);
+            diff, comments, prefs, this.diffElement, this._layers);
           } else if (this.viewMode === DiffViewMode.UNIFIED) {
             return new GrDiffBuilderUnified(
-                diff, comments, prefs, this.diffElement, this._layers);
+            diff, comments, prefs, this.diffElement, this._layers);
           }
           throw Error('Unsupported diff view mode: ' + this.viewMode);
         },
 
-        _clearDiffContent: function() {
+        _clearDiffContent() {
           this.diffElement.innerHTML = null;
         },
 
-        _getCommentLocations: function(comments) {
-          var result = {
+        _getCommentLocations(comments) {
+          const result = {
             left: {},
             right: {},
           };
-          for (var side in comments) {
+          for (const side in comments) {
             if (side !== GrDiffBuilder.Side.LEFT &&
                 side !== GrDiffBuilder.Side.RIGHT) {
               continue;
             }
-            comments[side].forEach(function(c) {
+            for (const c of comments[side]) {
               result[side][c.line || GrDiffLine.FILE] = true;
-            });
+            }
           }
           return result;
         },
 
-        _groupsChanged: function(changeRecord) {
+        _groupsChanged(changeRecord) {
           if (!changeRecord) { return; }
-          changeRecord.indexSplices.forEach(function(splice) {
-            var group;
-            for (var i = 0; i < splice.addedCount; i++) {
+          for (const splice of changeRecord.indexSplices) {
+            let group;
+            for (let i = 0; i < splice.addedCount; i++) {
               group = splice.object[splice.index + i];
               this._builder.groups.push(group);
               this._builder.emitGroup(group);
             }
-          }, this);
+          }
         },
 
-        _createIntralineLayer: function() {
+        _createIntralineLayer() {
           return {
             // Take a DIV.contentText element and a line object with intraline
             // differences to highlight and apply them to the element as
             // annotations.
-            annotate: function(el, line) {
-              var HL_CLASS = 'style-scope gr-diff intraline';
-              line.highlights.forEach(function(highlight) {
+            annotate(el, line) {
+              const HL_CLASS = 'style-scope gr-diff intraline';
+              for (const highlight of line.highlights) {
                 // The start and end indices could be the same if a highlight is
                 // meant to start at the end of a line and continue onto the
                 // next one. Ignore it.
-                if (highlight.startIndex === highlight.endIndex) { return; }
+                if (highlight.startIndex === highlight.endIndex) { continue; }
 
                 // If endIndex isn't present, continue to the end of the line.
-                var endIndex = highlight.endIndex === undefined ?
-                    line.text.length : highlight.endIndex;
+                const endIndex = highlight.endIndex === undefined ?
+                    line.text.length :
+                    highlight.endIndex;
 
                 GrAnnotation.annotateElement(
                     el,
                     highlight.startIndex,
                     endIndex - highlight.startIndex,
                     HL_CLASS);
-              });
+              }
             },
           };
         },
 
-        _createTabIndicatorLayer: function() {
-          var show = function() { return this._showTabs; }.bind(this);
+        _createTabIndicatorLayer() {
+          const show = () => this._showTabs;
           return {
-            annotate: function(el, line) {
+            annotate(el, line) {
               // If visible tabs are disabled, do nothing.
               if (!show()) { return; }
 
               // Find and annotate the locations of tabs.
-              var split = line.text.split('\t');
+              const split = line.text.split('\t');
               if (!split) { return; }
-              for (var i = 0, pos = 0; i < split.length - 1; i++) {
+              for (let i = 0, pos = 0; i < split.length - 1; i++) {
                 // Skip forward by the length of the content
                 pos += split[i].length;
 
@@ -342,22 +344,22 @@
           };
         },
 
-        _createTrailingWhitespaceLayer: function() {
-          var show = function() {
+        _createTrailingWhitespaceLayer() {
+          const show = function() {
             return this._showTrailingWhitespace;
           }.bind(this);
 
           return {
-            annotate: function(el, line) {
+            annotate(el, line) {
               if (!show()) { return; }
 
-              var match = line.text.match(TRAILING_WHITESPACE_PATTERN);
+              const match = line.text.match(TRAILING_WHITESPACE_PATTERN);
               if (match) {
                 // Normalize string positions in case there is unicode before or
                 // within the match.
-                var index = GrAnnotation.getStringLength(
+                const index = GrAnnotation.getStringLength(
                     line.text.substr(0, match.index));
-                var length = GrAnnotation.getStringLength(match[0]);
+                const length = GrAnnotation.getStringLength(match[0]);
                 GrAnnotation.annotateElement(el, index, length,
                     'style-scope gr-diff trailing-whitespace');
               }
@@ -374,11 +376,11 @@
          * already exist and the user's comment will be quick to load.
          * @see https://gerrit-review.googlesource.com/c/82213/
          */
-        _preRenderThread: function() {
-          var thread = document.createElement('gr-diff-comment-thread');
+        _preRenderThread() {
+          const thread = document.createElement('gr-diff-comment-thread');
           thread.setAttribute('hidden', true);
           thread.addDraft();
-          var parent = Polymer.dom(this.root);
+          const parent = Polymer.dom(this.root);
           parent.appendChild(thread);
           Polymer.dom.flush();
           parent.removeChild(thread);
@@ -388,9 +390,9 @@
          * @return {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) {
+        _anyLineTooLong() {
+          return this._groups.reduce((acc, group) => {
+            return acc || group.lines.reduce((acc, line) => {
               return acc || line.text.length >= SYNTAX_MAX_LINE_LENGTH;
             }, false);
           }, false);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
index 214454a..bb23572 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
@@ -14,8 +14,8 @@
 (function(window, GrDiffGroup, GrDiffLine) {
   'use strict';
 
-  var HTML_ENTITY_PATTERN = /[&<>"'`\/]/g;
-  var HTML_ENTITY_MAP = {
+  const HTML_ENTITY_PATTERN = /[&<>"'`\/]/g;
+  const HTML_ENTITY_MAP = {
     '&': '&amp;',
     '<': '&lt;',
     '>': '&gt;',
@@ -28,7 +28,7 @@
   // Prevent redefinition.
   if (window.GrDiffBuilder) { return; }
 
-  var REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+  const REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
   function GrDiffBuilder(diff, comments, prefs, outputEl, layers) {
     this._diff = diff;
@@ -39,11 +39,11 @@
 
     this.layers = layers || [];
 
-    this.layers.forEach(function(layer) {
+    for (const layer of this.layers) {
       if (layer.addListener) {
         layer.addListener(this._handleLayerUpdate.bind(this));
       }
-    }.bind(this));
+    }
   }
 
   GrDiffBuilder.LESS_THAN_CODE = '<'.charCodeAt(0);
@@ -76,7 +76,7 @@
     ALL: 'all',
   };
 
-  var PARTIAL_CONTEXT_AMOUNT = 10;
+  const PARTIAL_CONTEXT_AMOUNT = 10;
 
   /**
    * Abstract method
@@ -96,16 +96,16 @@
   };
 
   GrDiffBuilder.prototype.emitGroup = function(group, opt_beforeSection) {
-    var element = this.buildSectionElement(group);
+    const element = this.buildSectionElement(group);
     this._outputEl.insertBefore(element, opt_beforeSection);
     group.element = element;
   };
 
   GrDiffBuilder.prototype.renderSection = function(element) {
-    for (var i = 0; i < this.groups.length; i++) {
-      var group = this.groups[i];
+    for (let i = 0; i < this.groups.length; i++) {
+      const group = this.groups[i];
       if (group.element === element) {
-        var newElement = this.buildSectionElement(group);
+        const newElement = this.buildSectionElement(group);
         group.element.parentElement.replaceChild(newElement, group.element);
         group.element = newElement;
         break;
@@ -115,14 +115,14 @@
 
   GrDiffBuilder.prototype.getGroupsByLineRange = function(
       startLine, endLine, opt_side) {
-    var groups = [];
-    for (var i = 0; i < this.groups.length; i++) {
-      var group = this.groups[i];
+    const groups = [];
+    for (let i = 0; i < this.groups.length; i++) {
+      const group = this.groups[i];
       if (group.lines.length === 0) {
         continue;
       }
-      var groupStartLine = 0;
-      var groupEndLine = 0;
+      let groupStartLine = 0;
+      let groupEndLine = 0;
       if (opt_side) {
         groupStartLine = group.lineRange[opt_side].start;
         groupEndLine = group.lineRange[opt_side].end;
@@ -143,8 +143,8 @@
 
   GrDiffBuilder.prototype.getContentByLine = function(lineNumber, opt_side,
       opt_root) {
-    var root = Polymer.dom(opt_root || this._outputEl);
-    var sideSelector = !!opt_side ? ('.' + opt_side) : '';
+    const root = Polymer.dom(opt_root || this._outputEl);
+    const sideSelector = opt_side ? ('.' + opt_side) : '';
     return root.querySelector('td.lineNum[data-value="' + lineNumber +
         '"]' + sideSelector + ' ~ td.content .contentText');
   };
@@ -162,17 +162,17 @@
    */
   GrDiffBuilder.prototype.findLinesByRange = function(start, end, opt_side,
       out_lines, out_elements) {
-    var groups = this.getGroupsByLineRange(start, end, opt_side);
-    groups.forEach(function(group) {
-      var content = null;
-      group.lines.forEach(function(line) {
+    const groups = this.getGroupsByLineRange(start, end, opt_side);
+    for (const group of groups) {
+      let content = null;
+      for (const line of group.lines) {
         if ((opt_side === 'left' && line.type === GrDiffLine.Type.ADD) ||
             (opt_side === 'right' && line.type === GrDiffLine.Type.REMOVE)) {
-          return;
+          continue;
         }
-        var lineNumber = opt_side === 'left' ?
+        const lineNumber = opt_side === 'left' ?
             line.beforeNumber : line.afterNumber;
-        if (lineNumber < start || lineNumber > end) { return; }
+        if (lineNumber < start || lineNumber > end) { continue; }
 
         if (out_lines) { out_lines.push(line); }
         if (out_elements) {
@@ -184,8 +184,8 @@
           }
           if (content) { out_elements.push(content); }
         }
-      }.bind(this));
-    }.bind(this));
+      }
+    }
   };
 
   /**
@@ -193,12 +193,12 @@
    * diff content.
    */
   GrDiffBuilder.prototype._renderContentByRange = function(start, end, side) {
-    var lines = [];
-    var elements = [];
-    var line;
-    var el;
+    const lines = [];
+    const elements = [];
+    let line;
+    let el;
     this.findLinesByRange(start, end, side, lines, elements);
-    for (var i = 0; i < lines.length; i++) {
+    for (let i = 0; i < lines.length; i++) {
       line = lines[i];
       el = elements[i];
       el.parentElement.replaceChild(this._createTextEl(line, side).firstChild,
@@ -209,7 +209,7 @@
   GrDiffBuilder.prototype.getSectionsByLineRange = function(
       startLine, endLine, opt_side) {
     return this.getGroupsByLineRange(startLine, endLine, opt_side).map(
-        function(group) { return group.element; });
+        group => { return group.element; });
   };
 
   GrDiffBuilder.prototype._commentIsAtLineNum = function(side, lineNum) {
@@ -219,15 +219,15 @@
   // TODO(wyatta): Move this completely into the processor.
   GrDiffBuilder.prototype._insertContextGroups = function(groups, lines,
       hiddenRange) {
-    var linesBeforeCtx = lines.slice(0, hiddenRange[0]);
-    var hiddenLines = lines.slice(hiddenRange[0], hiddenRange[1]);
-    var linesAfterCtx = lines.slice(hiddenRange[1]);
+    const linesBeforeCtx = lines.slice(0, hiddenRange[0]);
+    const hiddenLines = lines.slice(hiddenRange[0], hiddenRange[1]);
+    const linesAfterCtx = lines.slice(hiddenRange[1]);
 
     if (linesBeforeCtx.length > 0) {
       groups.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesBeforeCtx));
     }
 
-    var ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL);
+    const ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL);
     ctxLine.contextGroup =
         new GrDiffGroup(GrDiffGroup.Type.BOTH, hiddenLines);
     groups.push(new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL,
@@ -243,8 +243,8 @@
       return null;
     }
 
-    var td = this._createElement('td');
-    var showPartialLinks =
+    const td = this._createElement('td');
+    const showPartialLinks =
         line.contextGroup.lines.length > PARTIAL_CONTEXT_AMOUNT;
 
     if (showPartialLinks) {
@@ -266,14 +266,14 @@
   };
 
   GrDiffBuilder.prototype._createContextButton = function(type, section, line) {
-    var contextLines = line.contextGroup.lines;
-    var context = PARTIAL_CONTEXT_AMOUNT;
+    const contextLines = line.contextGroup.lines;
+    const context = PARTIAL_CONTEXT_AMOUNT;
 
-    var button = this._createElement('gr-button', 'showContext');
+    const button = this._createElement('gr-button', 'showContext');
     button.setAttribute('link', true);
 
-    var text;
-    var groups = []; // The groups that replace this one if tapped.
+    let text;
+    const groups = []; // The groups that replace this one if tapped.
 
     if (type === GrDiffBuilder.ContextButtonType.ALL) {
       text = 'Show ' + contextLines.length + ' common line';
@@ -291,10 +291,10 @@
 
     button.textContent = text;
 
-    button.addEventListener('tap', function(e) {
+    button.addEventListener('tap', e => {
       e.detail = {
-        groups: groups,
-        section: section,
+        groups,
+        section,
       };
       // Let it bubble up the DOM tree.
     });
@@ -310,15 +310,15 @@
                (c.line === undefined && lineNum === GrDiffLine.FILE);
       };
     }
-    var leftComments =
+    const leftComments =
         comments[GrDiffBuilder.Side.LEFT].filter(byLineNum(line.beforeNumber));
-    var rightComments =
+    const rightComments =
         comments[GrDiffBuilder.Side.RIGHT].filter(byLineNum(line.afterNumber));
 
-    leftComments.forEach(function(c) { c.__commentSide = 'left'; });
-    rightComments.forEach(function(c) { c.__commentSide = 'right'; });
+    leftComments.forEach(c => { c.__commentSide = 'left'; });
+    rightComments.forEach(c => { c.__commentSide = 'right'; });
 
-    var result;
+    let result;
 
     switch (opt_side) {
       case GrDiffBuilder.Side.LEFT:
@@ -337,7 +337,7 @@
 
   GrDiffBuilder.prototype.createCommentThreadGroup = function(changeNum,
       patchNum, path, isOnParent, projectConfig, range) {
-    var threadGroupEl =
+    const threadGroupEl =
         document.createElement('gr-diff-comment-thread-group');
     threadGroupEl.changeNum = changeNum;
     threadGroupEl.patchForNewThreads = patchNum;
@@ -348,24 +348,25 @@
     return threadGroupEl;
   };
 
-  GrDiffBuilder.prototype._commentThreadGroupForLine =
-      function(line, opt_side) {
-    var comments = this._getCommentsForLine(this._comments, line, opt_side);
+  GrDiffBuilder.prototype._commentThreadGroupForLine = function(line,
+      opt_side) {
+    const comments =
+        this._getCommentsForLine(this._comments, line, opt_side);
     if (!comments || comments.length === 0) {
       return null;
     }
 
-    var patchNum = this._comments.meta.patchRange.patchNum;
-    var isOnParent = comments[0].side === 'PARENT' || false;
+    let patchNum = this._comments.meta.patchRange.patchNum;
+    let isOnParent = comments[0].side === 'PARENT' || false;
     if (line.type === GrDiffLine.Type.REMOVE ||
-        opt_side === GrDiffBuilder.Side.LEFT) {
+    opt_side === GrDiffBuilder.Side.LEFT) {
       if (this._comments.meta.patchRange.basePatchNum === 'PARENT') {
         isOnParent = true;
       } else {
         patchNum = this._comments.meta.patchRange.basePatchNum;
       }
     }
-    var threadGroupEl = this.createCommentThreadGroup(
+    const threadGroupEl = this.createCommentThreadGroup(
         this._comments.meta.changeNum,
         patchNum,
         this._comments.meta.path,
@@ -380,7 +381,7 @@
 
   GrDiffBuilder.prototype._createLineEl = function(line, number, type,
       opt_class) {
-    var td = this._createElement('td');
+    const td = this._createElement('td');
     if (opt_class) {
       td.classList.add(opt_class);
     }
@@ -397,22 +398,21 @@
   };
 
   GrDiffBuilder.prototype._createTextEl = function(line, opt_side) {
-    var td = this._createElement('td');
-    var text = line.text;
+    const td = this._createElement('td');
+    const text = line.text;
     if (line.type !== GrDiffLine.Type.BLANK) {
       td.classList.add('content');
     }
     td.classList.add(line.type);
-    var html = this._escapeHTML(text);
+    let html = this._escapeHTML(text);
     html = this._addTabWrappers(html, this._prefs.tab_size);
-
     if (!this._prefs.line_wrapping &&
         this._textLength(text, this._prefs.tab_size) >
         this._prefs.line_length) {
       html = this._addNewlines(text, html);
     }
 
-    var contentText = this._createElement('div', 'contentText');
+    const contentText = this._createElement('div', 'contentText');
     if (opt_side) {
       contentText.setAttribute('data-side', opt_side);
     }
@@ -425,9 +425,9 @@
       contentText.innerHTML = html;
     }
 
-    this.layers.forEach(function(layer) {
+    for (const layer of this.layers) {
       layer.annotate(contentText, line);
-    });
+    }
 
     td.appendChild(contentText);
 
@@ -440,8 +440,8 @@
    */
   GrDiffBuilder.prototype._textLength = function(text, tabSize) {
     text = text.replace(REGEX_ASTRAL_SYMBOL, '_');
-    var numChars = 0;
-    for (var i = 0; i < text.length; i++) {
+    let numChars = 0;
+    for (let i = 0; i < text.length; i++) {
       if (text[i] === '\t') {
         numChars += tabSize - (numChars % tabSize);
       } else {
@@ -489,11 +489,11 @@
   };
 
   GrDiffBuilder.prototype._addNewlines = function(text, html) {
-    var htmlIndex = 0;
-    var indices = [];
-    var numChars = 0;
-    var prevHtmlIndex = 0;
-    for (var i = 0; i < text.length; i++) {
+    let htmlIndex = 0;
+    const indices = [];
+    let numChars = 0;
+    let prevHtmlIndex = 0;
+    for (let i = 0; i < text.length; i++) {
       if (numChars > 0 && numChars % this._prefs.line_length === 0) {
         indices.push(htmlIndex);
       }
@@ -513,11 +513,11 @@
       }
       prevHtmlIndex = htmlIndex;
     }
-    var result = html;
+    let result = html;
     // Since the result string is being altered in place, start from the end
     // of the string so that the insertion indices are not affected as the
     // result string changes.
-    for (var i = indices.length - 1; i >= 0; i--) {
+    for (let i = indices.length - 1; i >= 0; i--) {
       result = result.slice(0, indices[i]) + GrDiffBuilder.LINE_FEED_HTML +
           result.slice(indices[i]);
     }
@@ -536,12 +536,12 @@
   GrDiffBuilder.prototype._addTabWrappers = function(line, tabSize) {
     if (!line.length) { return ''; }
 
-    var result = '';
-    var offset = 0;
-    var split = line.split('\t');
-    var width;
+    let result = '';
+    let offset = 0;
+    const split = line.split('\t');
+    let width;
 
-    for (var i = 0; i < split.length - 1; i++) {
+    for (let i = 0; i < split.length - 1; i++) {
       offset += split[i].length;
       width = tabSize - (offset % tabSize);
       result += split[i] + this._getTabWrapper(width);
@@ -561,7 +561,7 @@
       throw Error('Invalid tab size from preferences.');
     }
 
-    var str = '<span class="style-scope gr-diff tab ';
+    let str = '<span class="style-scope gr-diff tab ';
     str += '" style="';
     // TODO(andybons): CSS tab-size is not supported in IE.
     str += 'tab-size:' + tabSize + ';';
@@ -571,7 +571,7 @@
   };
 
   GrDiffBuilder.prototype._createElement = function(tagName, className) {
-    var el = document.createElement(tagName);
+    const el = document.createElement(tagName);
     // When Shady DOM is being used, these classes are added to account for
     // Polymer's polyfill behavior. In order to guarantee sufficient
     // specificity within the CSS rules, these are added to every element.
@@ -579,7 +579,7 @@
     // automatically) are not being used for performance reasons, this is
     // done manually.
     el.classList.add('style-scope', 'gr-diff');
-    if (!!className) {
+    if (className) {
       el.classList.add(className);
     }
     return el;
@@ -613,7 +613,7 @@
   };
 
   GrDiffBuilder.prototype._escapeHTML = function(str) {
-    return str.replace(HTML_ENTITY_PATTERN, function(s) {
+    return str.replace(HTML_ENTITY_PATTERN, s => {
       return HTML_ENTITY_MAP[s];
     });
   };
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 591fa9c..6c4a9d9 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
@@ -54,15 +54,15 @@
 </test-fixture>
 
 <script>
-  suite('gr-diff-builder tests', function() {
-    var element;
-    var builder;
+  suite('gr-diff-builder tests', () => {
+    let element;
+    let builder;
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getLoggedIn: function() { return Promise.resolve(false); },
+        getLoggedIn() { return Promise.resolve(false); },
       });
-      var prefs = {
+      const prefs = {
         line_length: 10,
         show_tabs: true,
         tab_size: 4,
@@ -70,18 +70,18 @@
       builder = new GrDiffBuilder({content: []}, {left: [], right: []}, prefs);
     });
 
-    test('context control buttons', function() {
-      var section = {};
-      var line = {contextGroup: {lines: []}};
+    test('context control buttons', () => {
+      const section = {};
+      const line = {contextGroup: {lines: []}};
 
       // Create 10 lines.
-      for (var i = 0; i < 10; i++) {
+      for (let i = 0; i < 10; i++) {
         line.contextGroup.lines.push('lorem upsum');
       }
 
       // Does not include +10 buttons when there are fewer than 11 lines.
-      var td = builder._createContextControl(section, line);
-      var buttons = td.querySelectorAll('gr-button.showContext');
+      let td = builder._createContextControl(section, line);
+      let buttons = td.querySelectorAll('gr-button.showContext');
 
       assert.equal(buttons.length, 1);
       assert.equal(buttons[0].textContent, 'Show 10 common lines');
@@ -99,8 +99,8 @@
       assert.equal(buttons[2].textContent, '+10↓');
     });
 
-    test('newlines 1', function() {
-      var text = 'abcdef';
+    test('newlines 1', () => {
+      let text = 'abcdef';
       assert.equal(builder._addNewlines(text, text), text);
       text = 'a'.repeat(20);
       assert.equal(builder._addNewlines(text, text),
@@ -109,9 +109,10 @@
           'a'.repeat(10));
     });
 
-    test('newlines 2', function() {
-      var text = '<span class="thumbsup">👍</span>';
-      var html = '&lt;span class=&quot;thumbsup&quot;&gt;👍&lt;&#x2F;span&gt;';
+    test('newlines 2', () => {
+      const text = '<span class="thumbsup">👍</span>';
+      const html =
+          '&lt;span class=&quot;thumbsup&quot;&gt;👍&lt;&#x2F;span&gt;';
       assert.equal(builder._addNewlines(text, html),
           '&lt;span clas' +
           GrDiffBuilder.LINE_FEED_HTML +
@@ -122,23 +123,23 @@
           'n&gt;');
     });
 
-    test('newlines 3', function() {
-      var text = '01234\t56789';
-      var html = '01234<span>\t</span>56789';
+    test('newlines 3', () => {
+      const text = '01234\t56789';
+      const html = '01234<span>\t</span>56789';
       assert.equal(builder._addNewlines(text, html),
           '01234<span>\t</span>5' +
           GrDiffBuilder.LINE_FEED_HTML +
           '6789');
     });
 
-    test('_addNewlines not called if line_wrapping is true', function(done) {
+    test('_addNewlines not called if line_wrapping is true', done => {
       builder._prefs = {line_wrapping: true, tab_size: 4, line_length: 50};
-      var text = (new Array(52)).join('a');
+      const text = (new Array(52)).join('a');
 
-      var line = {text: text, highlights: []};
-      var newLineStub = sinon.stub(builder, '_addNewlines');
+      const line = {text, highlights: []};
+      const newLineStub = sinon.stub(builder, '_addNewlines');
       builder._createTextEl(line);
-      flush(function() {
+      flush(() => {
         assert.isFalse(newLineStub.called);
         newLineStub.restore();
         done();
@@ -146,33 +147,33 @@
     });
 
     test('_addNewlines called if line_wrapping is true and meets other ' +
-        'conditions', function(done) {
+        'conditions', done => {
       builder._prefs = {line_wrapping: false, tab_size: 4, line_length: 50};
-      var text = (new Array(52)).join('a');
+      const text = (new Array(52)).join('a');
 
-      var line = {text: text, highlights: []};
-      var newLineStub = sinon.stub(builder, '_addNewlines');
+      const line = {text, highlights: []};
+      const newLineStub = sinon.stub(builder, '_addNewlines');
       builder._createTextEl(line);
 
-      flush(function() {
+      flush(() => {
         assert.isTrue(newLineStub.called);
         newLineStub.restore();
         done();
       });
     });
 
-    test('_createTextEl linewrap with tabs', function() {
-      var text = _.times(7, _.constant('\t')).join('') + '!';
-      var line = {text: text, highlights: []};
-      var el = builder._createTextEl(line);
-      var tabEl = el.querySelector('.contentText > .br');
+    test('_createTextEl linewrap with tabs', () => {
+      const text = _.times(7, _.constant('\t')).join('') + '!';
+      const line = {text, highlights: []};
+      const el = builder._createTextEl(line);
+      const tabEl = el.querySelector('.contentText > .br');
       assert.isOk(tabEl);
       assert.equal(
           el.querySelector('.contentText .tab:nth-child(2)').nextSibling,
           tabEl);
     });
 
-    test('text length with tabs and unicode', function() {
+    test('text length with tabs and unicode', () => {
       assert.equal(builder._textLength('12345', 4), 5);
       assert.equal(builder._textLength('\t\t12', 4), 10);
       assert.equal(builder._textLength('abc💢123', 4), 7);
@@ -186,9 +187,9 @@
       assert.equal(builder._textLength('\t\t\t\t\t', 20), 100);
     });
 
-    test('tab wrapper insertion', function() {
-      var html = 'abc\tdef';
-      var wrapper = builder._getTabWrapper(
+    test('tab wrapper insertion', () => {
+      const html = 'abc\tdef';
+      const wrapper = builder._getTabWrapper(
           builder._prefs.tab_size - 3,
           builder._prefs.show_tabs);
       assert.ok(wrapper);
@@ -202,12 +203,12 @@
           true));
     });
 
-    test('comments', function() {
-      var line = new GrDiffLine(GrDiffLine.Type.BOTH);
+    test('comments', () => {
+      const line = new GrDiffLine(GrDiffLine.Type.BOTH);
       line.beforeNumber = 3;
       line.afterNumber = 5;
 
-      var comments = {left: [], right: []};
+      let comments = {left: [], right: []};
       assert.deepEqual(builder._getCommentsForLine(comments, line), []);
       assert.deepEqual(builder._getCommentsForLine(comments, line,
           GrDiffBuilder.Side.LEFT), []);
@@ -229,19 +230,19 @@
           {id: 'r5', line: 5, __commentSide: 'right'}]);
       assert.deepEqual(builder._getCommentsForLine(comments, line,
           GrDiffBuilder.Side.LEFT), [{id: 'l3', line: 3,
-          __commentSide: 'left'}]);
+            __commentSide: 'left'}]);
       assert.deepEqual(builder._getCommentsForLine(comments, line,
           GrDiffBuilder.Side.RIGHT), [{id: 'r5', line: 5,
-          __commentSide: 'right'}]);
+            __commentSide: 'right'}]);
     });
 
-    test('comment thread group creation', function() {
-      var l3 = {id: 'l3', line: 3, updated: '2016-08-09 00:42:32.000000000',
-          __commentSide: 'left'};
-      var l5 = {id: 'l5', line: 5, updated: '2016-08-09 00:42:32.000000000',
-          __commentSide: 'left'};
-      var r5 = {id: 'r5', line: 5, updated: '2016-08-09 00:42:32.000000000',
-          __commentSide: 'right'};
+    test('comment thread group creation', () => {
+      const l3 = {id: 'l3', line: 3, updated: '2016-08-09 00:42:32.000000000',
+        __commentSide: 'left'};
+      const l5 = {id: 'l5', line: 5, updated: '2016-08-09 00:42:32.000000000',
+        __commentSide: 'left'};
+      const r5 = {id: 'r5', line: 5, updated: '2016-08-09 00:42:32.000000000',
+        __commentSide: 'right'};
 
       builder._comments = {
         meta: {
@@ -267,10 +268,10 @@
         assert.deepEqual(threadGroupEl.comments, comments);
       }
 
-      var line = new GrDiffLine(GrDiffLine.Type.BOTH);
+      let line = new GrDiffLine(GrDiffLine.Type.BOTH);
       line.beforeNumber = 5;
       line.afterNumber = 5;
-      var threadGroupEl = builder._commentThreadGroupForLine(line);
+      let threadGroupEl = builder._commentThreadGroupForLine(line);
       checkThreadGroupProps(threadGroupEl, '3', false, [l5, r5]);
 
       threadGroupEl =
@@ -306,51 +307,51 @@
       line.beforeNumber = 3;
       line.afterNumber = 5;
       threadGroupEl = builder._commentThreadGroupForLine(line);
-    checkThreadGroupProps(threadGroupEl, '3', false, [l3, r5]);
+      checkThreadGroupProps(threadGroupEl, '3', false, [l3, r5]);
     });
 
-    suite('_isTotal', function() {
-      test('is total for add', function() {
-        var group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
-        for (var idx = 0; idx < 10; idx++) {
+    suite('_isTotal', () => {
+      test('is total for add', () => {
+        const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+        for (let idx = 0; idx < 10; idx++) {
           group.addLine(new GrDiffLine(GrDiffLine.Type.ADD));
         }
         assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
       });
 
-      test('is total for remove', function() {
-        var group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
-        for (var idx = 0; idx < 10; idx++) {
+      test('is total for remove', () => {
+        const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+        for (let idx = 0; idx < 10; idx++) {
           group.addLine(new GrDiffLine(GrDiffLine.Type.REMOVE));
         }
         assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
       });
 
-      test('not total for empty', function() {
-        var group = new GrDiffGroup(GrDiffGroup.Type.BOTH);
+      test('not total for empty', () => {
+        const group = new GrDiffGroup(GrDiffGroup.Type.BOTH);
         assert.isFalse(GrDiffBuilder.prototype._isTotal(group));
       });
 
-      test('not total for non-delta', function() {
-        var group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
-        for (var idx = 0; idx < 10; idx++) {
+      test('not total for non-delta', () => {
+        const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+        for (let idx = 0; idx < 10; idx++) {
           group.addLine(new GrDiffLine(GrDiffLine.Type.BOTH));
         }
         assert.isFalse(GrDiffBuilder.prototype._isTotal(group));
       });
     });
 
-    suite('intraline differences', function() {
-      var el;
-      var str;
-      var annotateElementSpy;
-      var layer;
+    suite('intraline differences', () => {
+      let el;
+      let str;
+      let annotateElementSpy;
+      let layer;
 
       function slice(str, start, end) {
         return Array.from(str).slice(start, end).join('');
       }
 
-      setup(function() {
+      setup(() => {
         el = fixture('div-with-text');
         str = el.textContent;
         annotateElementSpy = sinon.spy(GrAnnotation, 'annotateElement');
@@ -358,12 +359,12 @@
             ._createIntralineLayer();
       });
 
-      teardown(function() {
+      teardown(() => {
         annotateElementSpy.restore();
       });
 
-      test('annotate no highlights', function() {
-        var line = {
+      test('annotate no highlights', () => {
+        const line = {
           text: str,
           highlights: [],
         };
@@ -377,19 +378,19 @@
         assert.equal(str, el.childNodes[0].textContent);
       });
 
-      test('annotate with highlights', function() {
-        var line = {
+      test('annotate with highlights', () => {
+        const line = {
           text: str,
           highlights: [
             {startIndex: 6, endIndex: 12},
             {startIndex: 18, endIndex: 22},
           ],
         };
-        var str0 = slice(str, 0, 6);
-        var str1 = slice(str, 6, 12);
-        var str2 = slice(str, 12, 18);
-        var str3 = slice(str, 18, 22);
-        var str4 = slice(str, 22);
+        const str0 = slice(str, 0, 6);
+        const str1 = slice(str, 6, 12);
+        const str2 = slice(str, 12, 18);
+        const str3 = slice(str, 18, 22);
+        const str4 = slice(str, 22);
 
         layer.annotate(el, line);
 
@@ -412,16 +413,16 @@
         assert.equal(el.childNodes[4].textContent, str4);
       });
 
-      test('annotate without endIndex', function() {
-        var line = {
+      test('annotate without endIndex', () => {
+        const line = {
           text: str,
           highlights: [
             {startIndex: 28},
           ],
         };
 
-        var str0 = slice(str, 0, 28);
-        var str1 = slice(str, 28);
+        const str0 = slice(str, 0, 28);
+        const str1 = slice(str, 28);
 
         layer.annotate(el, line);
 
@@ -435,8 +436,8 @@
         assert.equal(el.childNodes[1].textContent, str1);
       });
 
-      test('annotate ignores empty highlights', function() {
-        var line = {
+      test('annotate ignores empty highlights', () => {
+        const line = {
           text: str,
           highlights: [
             {startIndex: 28, endIndex: 28},
@@ -449,20 +450,20 @@
         assert.equal(el.childNodes.length, 1);
       });
 
-      test('annotate handles unicode', function() {
+      test('annotate handles unicode', () => {
         // Put some unicode into the string:
         str = str.replace(/\s/g, '💢');
         el.textContent = str;
-        var line = {
+        const line = {
           text: str,
           highlights: [
             {startIndex: 6, endIndex: 12},
           ],
         };
 
-        var str0 = slice(str, 0, 6);
-        var str1 = slice(str, 6, 12);
-        var str2 = slice(str, 12);
+        const str0 = slice(str, 0, 6);
+        const str1 = slice(str, 6, 12);
+        const str2 = slice(str, 12);
 
         layer.annotate(el, line);
 
@@ -479,20 +480,20 @@
         assert.equal(el.childNodes[2].textContent, str2);
       });
 
-      test('annotate handles unicode w/o endIndex', function() {
+      test('annotate handles unicode w/o endIndex', () => {
         // Put some unicode into the string:
         str = str.replace(/\s/g, '💢');
         el.textContent = str;
 
-        var line = {
+        const line = {
           text: str,
           highlights: [
             {startIndex: 6},
           ],
         };
 
-        var str0 = slice(str, 0, 6);
-        var str1 = slice(str, 6);
+        const str0 = slice(str, 0, 6);
+        const str1 = slice(str, 6);
 
         layer.annotate(el, line);
 
@@ -507,87 +508,92 @@
       });
     });
 
-    suite('tab indicators', function() {
-      var sandbox;
-      var element;
-      var layer;
+    suite('tab indicators', () => {
+      let sandbox;
+      let element;
+      let layer;
 
-      setup(function() {
+      setup(() => {
         sandbox = sinon.sandbox.create();
         element = fixture('basic');
         element._showTabs = true;
         layer = element._createTabIndicatorLayer();
       });
 
-      teardown(function() {
+      teardown(() => {
         sandbox.restore();
       });
 
-      test('does nothing with empty line', function() {
-        var line = {text: ''};
-        var el = document.createElement('div');
-        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+      test('does nothing with empty line', () => {
+        const line = {text: ''};
+        const el = document.createElement('div');
+        const annotateElementStub =
+            sandbox.stub(GrAnnotation, 'annotateElement');
 
         layer.annotate(el, line);
 
         assert.isFalse(annotateElementStub.called);
       });
 
-      test('does nothing with no tabs', function() {
-        var str = 'lorem ipsum no tabs';
-        var line = {text: str};
-        var el = document.createElement('div');
+      test('does nothing with no tabs', () => {
+        const str = 'lorem ipsum no tabs';
+        const line = {text: str};
+        const el = document.createElement('div');
         el.textContent = str;
-        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        const annotateElementStub =
+            sandbox.stub(GrAnnotation, 'annotateElement');
 
         layer.annotate(el, line);
 
         assert.isFalse(annotateElementStub.called);
       });
 
-      test('annotates tab at beginning', function() {
-        var str = '\tlorem upsum';
-        var line = {text: str};
-        var el = document.createElement('div');
+      test('annotates tab at beginning', () => {
+        const str = '\tlorem upsum';
+        const line = {text: str};
+        const el = document.createElement('div');
         el.textContent = str;
-        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        const annotateElementStub =
+            sandbox.stub(GrAnnotation, 'annotateElement');
 
         layer.annotate(el, line);
 
         assert.equal(annotateElementStub.callCount, 1);
-        var args = annotateElementStub.getCalls()[0].args;
+        const args = annotateElementStub.getCalls()[0].args;
         assert.equal(args[0], el);
         assert.equal(args[1], 0, 'offset of tab indicator');
         assert.equal(args[2], 1, 'length of tab indicator');
         assert.include(args[3], 'tab-indicator');
       });
 
-      test('does not annotate when disabled', function() {
+      test('does not annotate when disabled', () => {
         element._showTabs = false;
 
-        var str = '\tlorem upsum';
-        var line = {text: str};
-        var el = document.createElement('div');
+        const str = '\tlorem upsum';
+        const line = {text: str};
+        const el = document.createElement('div');
         el.textContent = str;
-        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        const annotateElementStub =
+            sandbox.stub(GrAnnotation, 'annotateElement');
 
         layer.annotate(el, line);
 
         assert.isFalse(annotateElementStub.called);
       });
 
-      test('annotates multiple in beginning', function() {
-        var str = '\t\tlorem upsum';
-        var line = {text: str};
-        var el = document.createElement('div');
+      test('annotates multiple in beginning', () => {
+        const str = '\t\tlorem upsum';
+        const line = {text: str};
+        const el = document.createElement('div');
         el.textContent = str;
-        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        const annotateElementStub =
+            sandbox.stub(GrAnnotation, 'annotateElement');
 
         layer.annotate(el, line);
 
         assert.equal(annotateElementStub.callCount, 2);
 
-        var args = annotateElementStub.getCalls()[0].args;
+        let args = annotateElementStub.getCalls()[0].args;
         assert.equal(args[0], el);
         assert.equal(args[1], 0, 'offset of tab indicator');
         assert.equal(args[2], 1, 'length of tab indicator');
@@ -600,17 +606,18 @@
         assert.include(args[3], 'tab-indicator');
       });
 
-      test('annotates intermediate tabs', function() {
-        var str = 'lorem\tupsum';
-        var line = {text: str};
-        var el = document.createElement('div');
+      test('annotates intermediate tabs', () => {
+        const str = 'lorem\tupsum';
+        const line = {text: str};
+        const el = document.createElement('div');
         el.textContent = str;
-        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        const annotateElementStub =
+            sandbox.stub(GrAnnotation, 'annotateElement');
 
         layer.annotate(el, line);
 
         assert.equal(annotateElementStub.callCount, 1);
-        var args = annotateElementStub.getCalls()[0].args;
+        const args = annotateElementStub.getCalls()[0].args;
         assert.equal(args[0], el);
         assert.equal(args[1], 5, 'offset of tab indicator');
         assert.equal(args[2], 1, 'length of tab indicator');
@@ -618,108 +625,115 @@
       });
     });
 
-    suite('trailing whitespace', function() {
-      var sandbox;
-      var element;
-      var layer;
+    suite('trailing whitespace', () => {
+      let sandbox;
+      let element;
+      let layer;
 
-      setup(function() {
+      setup(() => {
         sandbox = sinon.sandbox.create();
         element = fixture('basic');
         element._showTrailingWhitespace = true;
         layer = element._createTrailingWhitespaceLayer();
       });
 
-      teardown(function() {
+      teardown(() => {
         sandbox.restore();
       });
 
-      test('does nothing with empty line', function() {
-        var line = {text: ''};
-        var el = document.createElement('div');
-        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+      test('does nothing with empty line', () => {
+        const line = {text: ''};
+        const el = document.createElement('div');
+        const annotateElementStub =
+            sandbox.stub(GrAnnotation, 'annotateElement');
         layer.annotate(el, line);
         assert.isFalse(annotateElementStub.called);
       });
 
-      test('does nothing with no trailing whitespace', function() {
-        var str = 'lorem ipsum blah blah';
-        var line = {text: str};
-        var el = document.createElement('div');
+      test('does nothing with no trailing whitespace', () => {
+        const str = 'lorem ipsum blah blah';
+        const line = {text: str};
+        const el = document.createElement('div');
         el.textContent = str;
-        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        const annotateElementStub =
+            sandbox.stub(GrAnnotation, 'annotateElement');
         layer.annotate(el, line);
         assert.isFalse(annotateElementStub.called);
       });
 
-      test('annotates trailing spaces', function() {
-        var str = 'lorem ipsum   ';
-        var line = {text: str};
-        var el = document.createElement('div');
+      test('annotates trailing spaces', () => {
+        const str = 'lorem ipsum   ';
+        const line = {text: str};
+        const el = document.createElement('div');
         el.textContent = str;
-        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        const annotateElementStub =
+            sandbox.stub(GrAnnotation, 'annotateElement');
         layer.annotate(el, line);
         assert.isTrue(annotateElementStub.called);
         assert.equal(annotateElementStub.lastCall.args[1], 11);
         assert.equal(annotateElementStub.lastCall.args[2], 3);
       });
 
-      test('annotates trailing tabs', function() {
-        var str = 'lorem ipsum\t\t\t';
-        var line = {text: str};
-        var el = document.createElement('div');
+      test('annotates trailing tabs', () => {
+        const str = 'lorem ipsum\t\t\t';
+        const line = {text: str};
+        const el = document.createElement('div');
         el.textContent = str;
-        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        const annotateElementStub =
+            sandbox.stub(GrAnnotation, 'annotateElement');
         layer.annotate(el, line);
         assert.isTrue(annotateElementStub.called);
         assert.equal(annotateElementStub.lastCall.args[1], 11);
         assert.equal(annotateElementStub.lastCall.args[2], 3);
       });
 
-      test('annotates mixed trailing whitespace', function() {
-        var str = 'lorem ipsum\t \t';
-        var line = {text: str};
-        var el = document.createElement('div');
+      test('annotates mixed trailing whitespace', () => {
+        const str = 'lorem ipsum\t \t';
+        const line = {text: str};
+        const el = document.createElement('div');
         el.textContent = str;
-        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        const annotateElementStub =
+            sandbox.stub(GrAnnotation, 'annotateElement');
         layer.annotate(el, line);
         assert.isTrue(annotateElementStub.called);
         assert.equal(annotateElementStub.lastCall.args[1], 11);
         assert.equal(annotateElementStub.lastCall.args[2], 3);
       });
 
-      test('unicode preceding trailing whitespace', function() {
-        var str = '💢\t';
-        var line = {text: str};
-        var el = document.createElement('div');
+      test('unicode preceding trailing whitespace', () => {
+        const str = '💢\t';
+        const line = {text: str};
+        const el = document.createElement('div');
         el.textContent = str;
-        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        const annotateElementStub =
+            sandbox.stub(GrAnnotation, 'annotateElement');
         layer.annotate(el, line);
         assert.isTrue(annotateElementStub.called);
         assert.equal(annotateElementStub.lastCall.args[1], 1);
         assert.equal(annotateElementStub.lastCall.args[2], 1);
       });
 
-      test('does not annotate when disabled', function() {
+      test('does not annotate when disabled', () => {
         element._showTrailingWhitespace = false;
-        var str = 'lorem upsum\t \t ';
-        var line = {text: str};
-        var el = document.createElement('div');
+        const str = 'lorem upsum\t \t ';
+        const line = {text: str};
+        const el = document.createElement('div');
         el.textContent = str;
-        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        const annotateElementStub =
+            sandbox.stub(GrAnnotation, 'annotateElement');
         layer.annotate(el, line);
         assert.isFalse(annotateElementStub.called);
       });
     });
 
-    suite('rendering', function() {
-      var content;
-      var outputEl;
-      var sandbox;
+    suite('rendering', () => {
+      let content;
+      let outputEl;
+      let sandbox;
 
-      setup(function(done) {
+      setup(done => {
         sandbox = sinon.sandbox.create();
-        var prefs = {
+        const prefs = {
           line_length: 10,
           show_tabs: true,
           tab_size: 4,
@@ -729,13 +743,13 @@
         content = [
           {
             a: ['all work and no play make andybons a dull boy'],
-            b: ['elgoog elgoog elgoog']
+            b: ['elgoog elgoog elgoog'],
           },
           {
             ab: [
               'Non eram nescius, Brute, cum, quae summis ingeniis ',
               'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-            ]
+            ],
           },
         ];
         stub('gr-reporting', {
@@ -744,30 +758,30 @@
         });
         element = fixture('basic');
         outputEl = element.queryEffectiveChildren('#diffTable');
-        sandbox.stub(element, '_getDiffBuilder', function() {
-          var builder = new GrDiffBuilder(
-              {content: content}, {left: [], right: []}, prefs, outputEl);
+        sandbox.stub(element, '_getDiffBuilder', () => {
+          const builder = new GrDiffBuilder(
+              {content}, {left: [], right: []}, prefs, outputEl);
           sandbox.stub(builder, 'addColumns');
           builder.buildSectionElement = function(group) {
-            var section = document.createElement('stub');
-            section.textContent = group.lines.reduce(function(acc, line) {
+            const section = document.createElement('stub');
+            section.textContent = group.lines.reduce((acc, line) => {
               return acc + line.text;
             }, '');
             return section;
           };
           return builder;
         });
-        element.diff = {content: content};
+        element.diff = {content};
         element.render({left: [], right: []}, prefs).then(done);
       });
 
-      teardown(function() {
+      teardown(() => {
         sandbox.restore();
       });
 
-      test('reporting', function(done) {
-        var timeStub = element.$.reporting.time;
-        var timeEndStub = element.$.reporting.timeEnd;
+      test('reporting', done => {
+        const timeStub = element.$.reporting.time;
+        const timeEndStub = element.$.reporting.timeEnd;
         assert.isTrue(timeStub.calledWithExactly('Diff Total Render'));
         assert.isTrue(timeStub.calledWithExactly('Diff Content Render'));
         assert.isTrue(timeStub.calledWithExactly('Diff Syntax Render'));
@@ -777,43 +791,43 @@
         done();
       });
 
-      test('renderSection', function() {
-        var section = outputEl.querySelector('stub:nth-of-type(2)');
-        var prevInnerHTML = section.innerHTML;
+      test('renderSection', () => {
+        let section = outputEl.querySelector('stub:nth-of-type(2)');
+        const prevInnerHTML = section.innerHTML;
         section.innerHTML = 'wiped';
         element._builder.renderSection(section);
         section = outputEl.querySelector('stub:nth-of-type(2)');
         assert.equal(section.innerHTML, prevInnerHTML);
       });
 
-      test('addColumns is called', function(done) {
+      test('addColumns is called', done => {
         element.render({left: [], right: []}, {}).then(done);
         assert.isTrue(element._builder.addColumns.called);
       });
 
-      test('getSectionsByLineRange one line', function() {
-        var section = outputEl.querySelector('stub:nth-of-type(2)');
-        var sections = element._builder.getSectionsByLineRange(1, 1, 'left');
+      test('getSectionsByLineRange one line', () => {
+        const section = outputEl.querySelector('stub:nth-of-type(2)');
+        const sections = element._builder.getSectionsByLineRange(1, 1, 'left');
         assert.equal(sections.length, 1);
         assert.strictEqual(sections[0], section);
       });
 
-      test('getSectionsByLineRange over diff', function() {
-        var section = [
+      test('getSectionsByLineRange over diff', () => {
+        const section = [
           outputEl.querySelector('stub:nth-of-type(2)'),
           outputEl.querySelector('stub:nth-of-type(3)'),
         ];
-        var sections = element._builder.getSectionsByLineRange(1, 2, 'left');
+        const sections = element._builder.getSectionsByLineRange(1, 2, 'left');
         assert.equal(sections.length, 2);
         assert.strictEqual(sections[0], section[0]);
         assert.strictEqual(sections[1], section[1]);
       });
 
-      test('render-start and render are fired', function(done) {
-        var dispatchEventStub = sinon.stub(element, 'dispatchEvent');
-        element.render({left: [], right: []}, {}).then(function() {
-          var firedEventTypes = dispatchEventStub.getCalls()
-              .map(function(c) { return c.args[0].type; });
+      test('render-start and render are fired', done => {
+        const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+        element.render({left: [], right: []}, {}).then(() => {
+          const firedEventTypes = dispatchEventStub.getCalls()
+              .map(c => { return c.args[0].type; });
           assert.include(firedEventTypes, 'render-start');
           assert.include(firedEventTypes, 'render-content');
           assert.include(firedEventTypes, 'render');
@@ -821,18 +835,18 @@
         });
       });
 
-      test('rendering normal-sized diff does not disable syntax', function() {
+      test('rendering normal-sized diff does not disable syntax', () => {
         assert.isTrue(element.$.syntaxLayer.enabled);
       });
 
-      test('rendering large diff disables syntax', function(done) {
+      test('rendering large diff disables syntax', done => {
         // Before it renders, set the first diff line to 500 '*' characters.
         element.diff.content[0].a = [new Array(501).join('*')];
-        element.addEventListener('render', function() {
+        element.addEventListener('render', () => {
           assert.isFalse(element.$.syntaxLayer.enabled);
           done();
         });
-        var prefs = {
+        const prefs = {
           line_length: 10,
           show_tabs: true,
           tab_size: 4,
@@ -843,13 +857,13 @@
       });
     });
 
-    suite('mock-diff', function() {
-      var element;
-      var builder;
-      var diff;
-      var prefs;
+    suite('mock-diff', () => {
+      let element;
+      let builder;
+      let diff;
+      let prefs;
 
-      setup(function(done) {
+      setup(done => {
         element = fixture('mock-diff');
         diff = document.createElement('mock-diff-response').diffResponse;
         element.diff = diff;
@@ -860,14 +874,14 @@
           tab_size: 4,
         };
 
-        element.render({left: [], right: []}, prefs).then(function() {
+        element.render({left: [], right: []}, prefs).then(() => {
           builder = element._builder;
           done();
         });
       });
 
-      test('getContentByLine', function() {
-        var actual;
+      test('getContentByLine', () => {
+        let actual;
 
         actual = builder.getContentByLine(2, 'left');
         assert.equal(actual.textContent, diff.content[0].ab[1]);
@@ -882,19 +896,19 @@
         assert.equal(actual.textContent, diff.content[1].b[0]);
       });
 
-      test('findLinesByRange', function() {
-        var lines = [];
-        var elems = [];
-        var start = 6;
-        var end = 10;
-        var count = end - start + 1;
+      test('findLinesByRange', () => {
+        const lines = [];
+        const elems = [];
+        const start = 6;
+        const end = 10;
+        const count = end - start + 1;
 
         builder.findLinesByRange(start, end, 'right', lines, elems);
 
         assert.equal(lines.length, count);
         assert.equal(elems.length, count);
 
-        for (var i = 0; i < 5; i++) {
+        for (let i = 0; i < 5; i++) {
           assert.instanceOf(lines[i], GrDiffLine);
           assert.equal(lines[i].afterNumber, start + i);
           assert.instanceOf(elems[i], HTMLElement);
@@ -902,59 +916,59 @@
         }
       });
 
-      test('_renderContentByRange', function() {
-        var spy = sinon.spy(builder, '_createTextEl');
-        var start = 9;
-        var end = 14;
-        var count = end - start + 1;
+      test('_renderContentByRange', () => {
+        const spy = sinon.spy(builder, '_createTextEl');
+        const start = 9;
+        const end = 14;
+        const count = end - start + 1;
 
         builder._renderContentByRange(start, end, 'left');
 
         assert.equal(spy.callCount, count);
-        spy.getCalls().forEach(function(call, i) {
+        spy.getCalls().forEach((call, i) => {
           assert.equal(call.args[0].beforeNumber, start + i);
         });
 
         spy.restore();
       });
 
-      test('_getNextContentOnSide side-by-side left', function() {
-        var startElem = builder.getContentByLine(5, 'left',
+      test('_getNextContentOnSide side-by-side left', () => {
+        const startElem = builder.getContentByLine(5, 'left',
             element.$.diffTable);
-        var expectedStartString = diff.content[2].ab[0];
-        var expectedNextString = diff.content[2].ab[1];
+        const expectedStartString = diff.content[2].ab[0];
+        const expectedNextString = diff.content[2].ab[1];
         assert.equal(startElem.textContent, expectedStartString);
 
-        var nextElem = builder._getNextContentOnSide(startElem,
+        const nextElem = builder._getNextContentOnSide(startElem,
             'left');
         assert.equal(nextElem.textContent, expectedNextString);
       });
 
-      test('_getNextContentOnSide side-by-side right', function() {
-        var startElem = builder.getContentByLine(5, 'right',
+      test('_getNextContentOnSide side-by-side right', () => {
+        const startElem = builder.getContentByLine(5, 'right',
             element.$.diffTable);
-        var expectedStartString = diff.content[1].b[0];
-        var expectedNextString = diff.content[1].b[1];
+        const expectedStartString = diff.content[1].b[0];
+        const expectedNextString = diff.content[1].b[1];
         assert.equal(startElem.textContent, expectedStartString);
 
-        var nextElem = builder._getNextContentOnSide(startElem,
+        const nextElem = builder._getNextContentOnSide(startElem,
             'right');
         assert.equal(nextElem.textContent, expectedNextString);
       });
 
-      test('_getNextContentOnSide unified left', function(done) {
+      test('_getNextContentOnSide unified left', done => {
         // Re-render as unified:
         element.viewMode = 'UNIFIED_DIFF';
-        element.render({left: [], right: []}, prefs).then(function() {
+        element.render({left: [], right: []}, prefs).then(() => {
           builder = element._builder;
 
-          var startElem = builder.getContentByLine(5, 'left',
+          const startElem = builder.getContentByLine(5, 'left',
               element.$.diffTable);
-          var expectedStartString = diff.content[2].ab[0];
-          var expectedNextString = diff.content[2].ab[1];
+          const expectedStartString = diff.content[2].ab[0];
+          const expectedNextString = diff.content[2].ab[1];
           assert.equal(startElem.textContent, expectedStartString);
 
-          var nextElem = builder._getNextContentOnSide(startElem,
+          const nextElem = builder._getNextContentOnSide(startElem,
               'left');
           assert.equal(nextElem.textContent, expectedNextString);
 
@@ -962,19 +976,19 @@
         });
       });
 
-      test('_getNextContentOnSide unified right', function(done) {
+      test('_getNextContentOnSide unified right', done => {
         // Re-render as unified:
         element.viewMode = 'UNIFIED_DIFF';
-        element.render({left: [], right: []}, prefs).then(function() {
+        element.render({left: [], right: []}, prefs).then(() => {
           builder = element._builder;
 
-          var startElem = builder.getContentByLine(5, 'right',
+          const startElem = builder.getContentByLine(5, 'right',
               element.$.diffTable);
-          var expectedStartString = diff.content[1].b[0];
-          var expectedNextString = diff.content[1].b[1];
+          const expectedStartString = diff.content[1].b[0];
+          const expectedNextString = diff.content[1].b[1];
           assert.equal(startElem.textContent, expectedStartString);
 
-          var nextElem = builder._getNextContentOnSide(startElem,
+          const nextElem = builder._getNextContentOnSide(startElem,
               'right');
           assert.equal(nextElem.textContent, expectedNextString);
 
@@ -982,11 +996,11 @@
         });
       });
 
-      test('_escapeHTML', function() {
-        var input = '<script>alert("XSS");<' + '/script>';
-        var expected = '&lt;script&gt;alert(&quot;XSS&quot;);' +
+      test('_escapeHTML', () => {
+        let input = '<script>alert("XSS");<' + '/script>';
+        let expected = '&lt;script&gt;alert(&quot;XSS&quot;);' +
             '&lt;&#x2F;script&gt;';
-        var result = GrDiffBuilder.prototype._escapeHTML(input);
+        let result = GrDiffBuilder.prototype._escapeHTML(input);
         assert.equal(result, expected);
 
         input = '& < > " \' / `';
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js
index df75d52..7899fc7 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js
@@ -21,7 +21,7 @@
       changeNum: String,
       comments: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       patchForNewThreads: String,
       projectConfig: Object,
@@ -32,7 +32,7 @@
       },
       _threads: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
     },
 
@@ -40,16 +40,16 @@
       '_commentsChanged(comments.*)',
     ],
 
-    addNewThread: function(locationRange) {
+    addNewThread(locationRange) {
       this.push('_threads', {
         comments: [],
-        locationRange: locationRange,
+        locationRange,
         patchNum: this.patchForNewThreads,
       });
     },
 
-    removeThread: function(locationRange) {
-      for (var i = 0; i < this._threads.length; i++) {
+    removeThread(locationRange) {
+      for (let i = 0; i < this._threads.length; i++) {
         if (this._threads[i].locationRange === locationRange) {
           this.splice('_threads', i, 1);
           return;
@@ -57,10 +57,10 @@
       }
     },
 
-    getThreadForRange: function(rangeToCheck) {
-      var threads = [].filter.call(
+    getThreadForRange(rangeToCheck) {
+      const threads = [].filter.call(
           Polymer.dom(this.root).querySelectorAll('gr-diff-comment-thread'),
-          function(thread) {
+          thread => {
             return thread.locationRange === rangeToCheck;
           });
       if (threads.length === 1) {
@@ -68,13 +68,13 @@
       }
     },
 
-    _commentsChanged: function() {
+    _commentsChanged() {
       this._threads = this._getThreadGroups(this.comments);
     },
 
-    _sortByDate: function(threadGroups) {
+    _sortByDate(threadGroups) {
       if (!threadGroups.length) { return; }
-      return threadGroups.sort(function(a, b) {
+      return threadGroups.sort((a, b) => {
         // If a comment is a draft, it doesn't have a start_datetime yet.
         // Assume it is newer than the comment it is being compared to.
         if (!a.start_datetime) {
@@ -88,7 +88,7 @@
       });
     },
 
-    _calculateLocationRange: function(range, comment) {
+    _calculateLocationRange(range, comment) {
       return 'range-' + range.start_line + '-' +
           range.start_character + '-' +
           range.end_line + '-' +
@@ -102,15 +102,15 @@
      * This is needed for switching between side-by-side and unified views when
      * there are unsaved drafts.
      */
-    _getPatchNum: function(comment) {
+    _getPatchNum(comment) {
       return comment.patchNum || this.patchForNewThreads;
     },
 
-    _getThreadGroups: function(comments) {
-      var threadGroups = {};
+    _getThreadGroups(comments) {
+      const threadGroups = {};
 
-      comments.forEach(function(comment) {
-        var locationRange;
+      for (const comment of comments) {
+        let locationRange;
         if (!comment.range) {
           locationRange = 'line-' + comment.__commentSide;
         } else {
@@ -123,18 +123,18 @@
           threadGroups[locationRange] = {
             start_datetime: comment.updated,
             comments: [comment],
-            locationRange: locationRange,
+            locationRange,
             commentSide: comment.__commentSide,
             patchNum: this._getPatchNum(comment),
           };
         }
-      }.bind(this));
+      }
 
-      var threadGroupArr = [];
-      var threadGroupKeys = Object.keys(threadGroups);
-      threadGroupKeys.forEach(function(threadGroupKey) {
+      const threadGroupArr = [];
+      const threadGroupKeys = Object.keys(threadGroups);
+      for (const threadGroupKey of threadGroupKeys) {
         threadGroupArr.push(threadGroups[threadGroupKey]);
-      });
+      }
 
       return this._sortByDate(threadGroupArr);
     },
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html
index 53a8e81..056b74f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html
@@ -34,25 +34,25 @@
 </test-fixture>
 
 <script>
-  suite('gr-diff-comment-thread-group tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-diff-comment-thread-group tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
-        getLoggedIn: function() { return Promise.resolve(false); },
+        getLoggedIn() { return Promise.resolve(false); },
       });
       element = fixture('basic');
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('_getThreadGroups', function() {
+    test('_getThreadGroups', () => {
       element.patchForNewThreads = 3;
-      var comments = [
+      const comments = [
         {
           id: 'sallys_confession',
           message: 'i like you, jack',
@@ -66,23 +66,23 @@
         },
       ];
 
-      var expectedThreadGroups = [
+      let expectedThreadGroups = [
         {
           start_datetime: '2015-12-23 15:00:20.396000000',
           commentSide: 'left',
           comments: [{
-              id: 'sallys_confession',
-              message: 'i like you, jack',
-              updated: '2015-12-23 15:00:20.396000000',
-              __commentSide: 'left',
-            }, {
-              id: 'jacks_reply',
-              message: 'i like you, too',
-              updated: '2015-12-24 15:00:20.396000000',
-              __commentSide: 'left',
-            }],
+            id: 'sallys_confession',
+            message: 'i like you, jack',
+            updated: '2015-12-23 15:00:20.396000000',
+            __commentSide: 'left',
+          }, {
+            id: 'jacks_reply',
+            message: 'i like you, too',
+            updated: '2015-12-24 15:00:20.396000000',
+            __commentSide: 'left',
+          }],
           locationRange: 'line-left',
-          patchNum: 3
+          patchNum: 3,
         },
       ];
 
@@ -91,33 +91,33 @@
 
       // Patch num should get inherited from comment rather
       comments.push({
-          id: 'betsys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:10.396000000',
-          range: {
-            start_line: 1,
-            start_character: 1,
-            end_line: 1,
-            end_character: 2,
-          },
-          __commentSide: 'left',
-        });
+        id: 'betsys_confession',
+        message: 'i like you, jack',
+        updated: '2015-12-24 15:00:10.396000000',
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 1,
+          end_character: 2,
+        },
+        __commentSide: 'left',
+      });
 
       expectedThreadGroups = [
         {
           start_datetime: '2015-12-23 15:00:20.396000000',
           commentSide: 'left',
           comments: [{
-              id: 'sallys_confession',
-              message: 'i like you, jack',
-              updated: '2015-12-23 15:00:20.396000000',
-              __commentSide: 'left',
-            }, {
-              id: 'jacks_reply',
-              message: 'i like you, too',
-              updated: '2015-12-24 15:00:20.396000000',
-              __commentSide: 'left',
-            }],
+            id: 'sallys_confession',
+            message: 'i like you, jack',
+            updated: '2015-12-23 15:00:20.396000000',
+            __commentSide: 'left',
+          }, {
+            id: 'jacks_reply',
+            message: 'i like you, too',
+            updated: '2015-12-24 15:00:20.396000000',
+            __commentSide: 'left',
+          }],
           patchNum: 3,
           locationRange: 'line-left',
         },
@@ -145,8 +145,8 @@
           expectedThreadGroups);
     });
 
-    test('_sortByDate', function() {
-      var threadGroups = [
+    test('_sortByDate', () => {
+      let threadGroups = [
         {
           start_datetime: '2015-12-23 15:00:20.396000000',
           comments: [],
@@ -159,12 +159,12 @@
         },
       ];
 
-      var expectedResult = [
+      let expectedResult = [
         {
           start_datetime: '2015-12-22 15:00:10.396000000',
           comments: [],
           locationRange: 'range-1-1-1-2',
-        },{
+        }, {
           start_datetime: '2015-12-23 15:00:20.396000000',
           comments: [],
           locationRange: 'line',
@@ -175,7 +175,7 @@
 
       // When a comment doesn't have a date, the one without the date should be
       // last.
-      var threadGroups = [
+      threadGroups = [
         {
           start_datetime: '2015-12-23 15:00:20.396000000',
           comments: [],
@@ -187,7 +187,7 @@
         },
       ];
 
-      var expectedResult = [
+      expectedResult = [
         {
           start_datetime: '2015-12-23 15:00:20.396000000',
           comments: [],
@@ -198,22 +198,25 @@
           locationRange: 'range-1-1-1-2',
         },
       ];
+
+      assert.deepEqual(element._sortByDate(threadGroups), expectedResult);
     });
 
-    test('_calculateLocationRange', function() {
-      var comment = {__commentSide: 'left'};
-      var range = {
+    test('_calculateLocationRange', () => {
+      const comment = {__commentSide: 'left'};
+      const range = {
         start_line: 1,
         start_character: 2,
         end_line: 3,
         end_character: 4,
       };
       assert.equal(
-        element._calculateLocationRange(range, comment), 'range-1-2-3-4-left');
+          element._calculateLocationRange(range, comment),
+          'range-1-2-3-4-left');
     });
 
-    test('thread groups are updated when comments change', function() {
-      var commentsChangedStub = sandbox.stub(element, '_commentsChanged');
+    test('thread groups are updated when comments change', () => {
+      const commentsChangedStub = sandbox.stub(element, '_commentsChanged');
       element.comments = [];
       element.comments.push({
         id: 'sallys_confession',
@@ -223,16 +226,16 @@
       assert(commentsChangedStub.called);
     });
 
-    test('addNewThread', function() {
-      var locationRange = 'range-1-2-3-4';
+    test('addNewThread', () => {
+      const locationRange = 'range-1-2-3-4';
       element._threads = [{locationRange: 'line'}];
       element.addNewThread(locationRange);
       assert(element._threads.length, 2);
     });
 
-    test('_getPatchNum', function() {
+    test('_getPatchNum', () => {
       element.patchForNewThreads = 3;
-      var comment = {
+      const comment = {
         id: 'sallys_confession',
         message: 'i like you, jack',
         updated: '2015-12-23 15:00:20.396000000',
@@ -242,11 +245,11 @@
       assert.equal(element._getPatchNum(comment), 4);
     });
 
-    test('removeThread', function() {
-      var locationRange = 'range-1-2-3-4';
+    test('removeThread', () => {
+      const locationRange = 'range-1-2-3-4';
       element._threads = [
         {locationRange: 'range-1-2-3-4', comments: []},
-        {locationRange: 'line', comments: []}
+        {locationRange: 'line', comments: []},
       ];
       flushAsynchronousOperations();
       element.removeThread(locationRange);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
index c19b643..ab1e296 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
@@ -56,7 +56,7 @@
             robot-button-disabled="[[_hideActions(_showActions, _lastComment)]]"
             change-num="[[changeNum]]"
             patch-num="[[patchNum]]"
-            draft="[[comment.__draft]]"
+            draft="[[_isDraft(comment)]]"
             show-actions="[[_showActions]]"
             comment-side="[[comment.__commentSide]]"
             side="[[comment.side]]"
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 be88e476..96f1bac 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
@@ -14,8 +14,8 @@
 (function() {
   'use strict';
 
-  var UNRESOLVED_EXPAND_COUNT = 5;
-  var NEWLINE_PATTERN = /\n/g;
+  const UNRESOLVED_EXPAND_COUNT = 5;
+  const NEWLINE_PATTERN = /\n/g;
 
   Polymer({
     is: 'gr-diff-comment-thread',
@@ -30,12 +30,12 @@
       changeNum: String,
       comments: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       locationRange: String,
       keyEventTarget: {
         type: Object,
-        value: function() { return document.body; },
+        value() { return document.body; },
       },
       commentSide: String,
       patchNum: String,
@@ -71,17 +71,17 @@
       'e shift+e': '_handleEKey',
     },
 
-    attached: function() {
-      this._getLoggedIn().then(function(loggedIn) {
+    attached() {
+      this._getLoggedIn().then(loggedIn => {
         this._showActions = loggedIn;
-      }.bind(this));
+      });
       this._setInitialExpandedState();
     },
 
-    addOrEditDraft: function(opt_lineNum, opt_range) {
-      var lastComment = this.comments[this.comments.length - 1] || {};
+    addOrEditDraft(opt_lineNum, opt_range) {
+      const lastComment = this.comments[this.comments.length - 1] || {};
       if (lastComment.__draft) {
-        var commentEl = this._commentElWithDraftID(
+        const commentEl = this._commentElWithDraftID(
             lastComment.id || lastComment.__draftID);
         commentEl.editing = true;
 
@@ -89,25 +89,25 @@
         // actions are available.
         commentEl.collapsed = false;
       } else {
-        var range = opt_range ? opt_range :
+        const range = opt_range ? opt_range :
             lastComment ? lastComment.range : undefined;
-        var unresolved = lastComment ? lastComment.unresolved : undefined;
+        const unresolved = lastComment ? lastComment.unresolved : undefined;
         this.addDraft(opt_lineNum, range, unresolved);
       }
     },
 
-    addDraft: function(opt_lineNum, opt_range, opt_unresolved) {
-      var draft = this._newDraft(opt_lineNum, opt_range);
+    addDraft(opt_lineNum, opt_range, opt_unresolved) {
+      const draft = this._newDraft(opt_lineNum, opt_range);
       draft.__editing = true;
       draft.unresolved = opt_unresolved === false ? opt_unresolved : true;
       this.push('comments', draft);
     },
 
-    _getLoggedIn: function() {
+    _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
     },
 
-    _commentsChanged: function(changeRecord) {
+    _commentsChanged(changeRecord) {
       this._orderedComments = this._sortedComments(this.comments);
       if (this._orderedComments.length) {
         this._lastComment = this._getLastComment();
@@ -115,15 +115,15 @@
       }
     },
 
-    _hideActions: function(_showActions, _lastComment) {
+    _hideActions(_showActions, _lastComment) {
       return !_showActions || !_lastComment || !!_lastComment.__draft;
     },
 
-    _getLastComment: function() {
+    _getLastComment() {
       return this._orderedComments[this._orderedComments.length - 1] || {};
     },
 
-    _handleEKey: function(e) {
+    _handleEKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       // Don’t preventDefault in this case because it will render the event
@@ -136,12 +136,12 @@
       }
     },
 
-    _expandCollapseComments: function(actionIsCollapse) {
-      var comments =
+    _expandCollapseComments(actionIsCollapse) {
+      const comments =
           Polymer.dom(this.root).querySelectorAll('gr-diff-comment');
-      comments.forEach(function(comment) {
+      for (const comment of comments) {
         comment.collapsed = actionIsCollapse;
-      });
+      }
     },
 
     /**
@@ -149,33 +149,32 @@
      * {UNRESOLVED_EXPAND_COUNT} comments expanded by default if the
      * thread is unresolved.
      */
-    _setInitialExpandedState: function() {
-      var comment;
+    _setInitialExpandedState() {
+      let comment;
       if (this._orderedComments) {
-        for (var i = 0; i < this._orderedComments.length; i++) {
+        for (let i = 0; i < this._orderedComments.length; i++) {
           comment = this._orderedComments[i];
           comment.collapsed =
               this._orderedComments.length - i - 1 >= UNRESOLVED_EXPAND_COUNT ||
               !this._unresolved;
         }
       }
-
     },
 
-    _sortedComments: function(comments) {
-      return comments.slice().sort(function(c1, c2) {
-        var c1Date = c1.__date || util.parseDate(c1.updated);
-        var c2Date = c2.__date || util.parseDate(c2.updated);
-        var dateCompare = c1Date - c2Date;
+    _sortedComments(comments) {
+      return comments.slice().sort((c1, c2) => {
+        const c1Date = c1.__date || util.parseDate(c1.updated);
+        const c2Date = c2.__date || util.parseDate(c2.updated);
+        const dateCompare = c1Date - c2Date;
         if (!c1.id || !c1.id.localeCompare) { return 0; }
         // If same date, fall back to sorting by id.
         return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
       });
     },
 
-    _createReplyComment: function(parent, content, opt_isEditing,
+    _createReplyComment(parent, content, opt_isEditing,
         opt_unresolved) {
-      var reply = this._newReply(
+      const reply = this._newReply(
           this._orderedComments[this._orderedComments.length - 1].id,
           parent.line,
           content,
@@ -184,7 +183,7 @@
 
       // If there is currently a comment in an editing state, add an attribute
       // so that the gr-diff-comment knows not to populate the draft text.
-      for (var i = 0; i < this.comments.length; i++) {
+      for (let i = 0; i < this.comments.length; i++) {
         if (this.comments[i].__editing) {
           reply.__otherEditing = true;
           break;
@@ -199,62 +198,66 @@
 
       if (!opt_isEditing) {
         // Allow the reply to render in the dom-repeat.
-        this.async(function() {
-          var commentEl = this._commentElWithDraftID(reply.__draftID);
+        this.async(() => {
+          const commentEl = this._commentElWithDraftID(reply.__draftID);
           commentEl.save();
         }, 1);
       }
     },
 
-    _processCommentReply: function(opt_quote) {
-      var comment = this._lastComment;
-      var quoteStr;
+    _isDraft(comment) {
+      return !!comment.__draft;
+    },
+
+    _processCommentReply(opt_quote) {
+      const comment = this._lastComment;
+      let quoteStr;
       if (opt_quote) {
-        var msg = comment.message;
+        const msg = comment.message;
         quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
       }
       this._createReplyComment(comment, quoteStr, true, comment.unresolved);
     },
 
-    _handleCommentReply: function(e) {
+    _handleCommentReply(e) {
       this._processCommentReply();
     },
 
-    _handleCommentQuote: function(e) {
+    _handleCommentQuote(e) {
       this._processCommentReply(true);
     },
 
-    _handleCommentAck: function(e) {
-      var comment = this._lastComment;
+    _handleCommentAck(e) {
+      const comment = this._lastComment;
       this._createReplyComment(comment, 'Ack', false, false);
     },
 
-    _handleCommentDone: function(e) {
-      var comment = this._lastComment;
+    _handleCommentDone(e) {
+      const comment = this._lastComment;
       this._createReplyComment(comment, 'Done', false, false);
     },
 
-    _handleCommentFix: function(e) {
-      var comment = e.detail.comment;
-      var msg = comment.message;
-      var quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
-      var response = quoteStr + 'Please Fix';
+    _handleCommentFix(e) {
+      const comment = e.detail.comment;
+      const msg = comment.message;
+      const quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
+      const response = quoteStr + 'Please Fix';
       this._createReplyComment(comment, response, false, true);
     },
 
-    _commentElWithDraftID: function(id) {
-      var els = Polymer.dom(this.root).querySelectorAll('gr-diff-comment');
-      for (var i = 0; i < els.length; i++) {
-        if (els[i].comment.id === id || els[i].comment.__draftID === id) {
-          return els[i];
+    _commentElWithDraftID(id) {
+      const els = Polymer.dom(this.root).querySelectorAll('gr-diff-comment');
+      for (const el of els) {
+        if (el.comment.id === id || el.comment.__draftID === id) {
+          return el;
         }
       }
       return null;
     },
 
-    _newReply: function(inReplyTo, opt_lineNum, opt_message, opt_unresolved,
-          opt_range) {
-      var d = this._newDraft(opt_lineNum);
+    _newReply(inReplyTo, opt_lineNum, opt_message, opt_unresolved,
+        opt_range) {
+      const d = this._newDraft(opt_lineNum);
       d.in_reply_to = inReplyTo;
       d.range = opt_range;
       if (opt_message != null) {
@@ -266,8 +269,8 @@
       return d;
     },
 
-    _newDraft: function(opt_lineNum, opt_range) {
-      var d = {
+    _newDraft(opt_lineNum, opt_range) {
+      const d = {
         __draft: true,
         __draftID: Math.random().toString(36),
         __date: new Date(),
@@ -290,15 +293,15 @@
       return d;
     },
 
-    _getSide: function(isOnParent) {
+    _getSide(isOnParent) {
       if (isOnParent) { return 'PARENT'; }
       return 'REVISION';
     },
 
-    _handleCommentDiscard: function(e) {
-      var diffCommentEl = Polymer.dom(e).rootTarget;
-      var comment = diffCommentEl.comment;
-      var idx = this._indexOf(comment, this.comments);
+    _handleCommentDiscard(e) {
+      const diffCommentEl = Polymer.dom(e).rootTarget;
+      const comment = diffCommentEl.comment;
+      const idx = this._indexOf(comment, this.comments);
       if (idx == -1) {
         throw Error('Cannot find comment ' +
             JSON.stringify(diffCommentEl.comment));
@@ -310,23 +313,23 @@
 
       // Check to see if there are any other open comments getting edited and
       // set the local storage value to its message value.
-      for (var i = 0; i < this.comments.length; i++) {
-        if (this.comments[i].__editing) {
-          var commentLocation = {
+      for (const changeComment of this.comments) {
+        if (changeComment.__editing) {
+          const commentLocation = {
             changeNum: this.changeNum,
             patchNum: this.patchNum,
-            path: this.comments[i].path,
-            line: this.comments[i].line,
+            path: changeComment.path,
+            line: changeComment.line,
           };
           return this.$.storage.setDraftComment(commentLocation,
-              this.comments[i].message);
+              changeComment.message);
         }
       }
     },
 
-    _handleCommentUpdate: function(e) {
-      var comment = e.detail.comment;
-      var index = this._indexOf(comment, this.comments);
+    _handleCommentUpdate(e) {
+      const comment = e.detail.comment;
+      const index = this._indexOf(comment, this.comments);
       if (index === -1) {
         // This should never happen: comment belongs to another thread.
         console.warn('Comment update for another comment thread.');
@@ -335,9 +338,9 @@
       this.set(['comments', index], comment);
     },
 
-    _indexOf: function(comment, arr) {
-      for (var i = 0; i < arr.length; i++) {
-        var c = arr[i];
+    _indexOf(comment, arr) {
+      for (let i = 0; i < arr.length; i++) {
+        const c = arr[i];
         if ((c.__draftID != null && c.__draftID == comment.__draftID) ||
             (c.id != null && c.id == comment.id)) {
           return i;
@@ -346,7 +349,7 @@
       return -1;
     },
 
-    _computeHostClass: function(unresolved) {
+    _computeHostClass(unresolved) {
       return unresolved ? 'unresolved' : '';
     },
   });
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 546308e..85beca6 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
@@ -40,25 +40,25 @@
 </test-fixture>
 
 <script>
-  suite('gr-diff-comment-thread tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-diff-comment-thread tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
-        getLoggedIn: function() { return Promise.resolve(false); },
+        getLoggedIn() { return Promise.resolve(false); },
       });
       sandbox = sinon.sandbox.create();
       element = fixture('basic');
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('comments are sorted correctly', function() {
-      var comments = [
+    test('comments are sorted correctly', () => {
+      const comments = [
         {
           id: 'jacks_reply',
           message: 'i like you, too',
@@ -86,9 +86,9 @@
           id: 'sallys_mission',
           message: 'i have to find santa',
           updated: '2015-12-24 15:00:20.396000000',
-        }
+        },
       ];
-      var results = element._sortedComments(comments);
+      const results = element._sortedComments(comments);
       assert.deepEqual(results, [
         {
           id: 'sally_to_dr_finklestein',
@@ -117,11 +117,11 @@
           message: 'i like you, too',
           in_reply_to: 'sallys_confession',
           updated: '2015-12-25 15:00:20.396000000',
-        }
+        },
       ]);
     });
 
-    test('addOrEditDraft w/ edit draft', function() {
+    test('addOrEditDraft w/ edit draft', () => {
       element.comments = [{
         id: 'jacks_reply',
         message: 'i like you, too',
@@ -129,9 +129,9 @@
         updated: '2015-12-25 15:00:20.396000000',
         __draft: true,
       }];
-      var commentElStub = sandbox.stub(element, '_commentElWithDraftID',
-          function() { return {}; });
-      var addDraftStub = sandbox.stub(element, 'addDraft');
+      const commentElStub = sandbox.stub(element, '_commentElWithDraftID',
+          () => { return {}; });
+      const addDraftStub = sandbox.stub(element, 'addDraft');
 
       element.addOrEditDraft(123);
 
@@ -139,11 +139,11 @@
       assert.isFalse(addDraftStub.called);
     });
 
-    test('addOrEditDraft w/o edit draft', function() {
+    test('addOrEditDraft w/o edit draft', () => {
       element.comments = [];
-      var commentElStub = sandbox.stub(element, '_commentElWithDraftID',
-          function() { return {}; });
-      var addDraftStub = sandbox.stub(element, 'addDraft');
+      const commentElStub = sandbox.stub(element, '_commentElWithDraftID',
+          () => { return {}; });
+      const addDraftStub = sandbox.stub(element, 'addDraft');
 
       element.addOrEditDraft(123);
 
@@ -151,40 +151,41 @@
       assert.isTrue(addDraftStub.called);
     });
 
-    test('_hideActions', function() {
-      var showActions = true;
-      var lastComment = {};
+    test('_hideActions', () => {
+      let showActions = true;
+      const lastComment = {};
       assert.equal(element._hideActions(showActions, lastComment), false);
       showActions = false;
       assert.equal(element._hideActions(showActions, lastComment), true);
-      var showActions = true;
+      showActions = true;
       lastComment.__draft = true;
       assert.equal(element._hideActions(showActions, lastComment), true);
     });
   });
 
-  suite('comment action tests', function() {
-    var element;
+  suite('comment action tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getLoggedIn: function() { return Promise.resolve(false); },
-        saveDiffDraft: function() {
+        getLoggedIn() { return Promise.resolve(false); },
+        saveDiffDraft() {
           return Promise.resolve({
             ok: true,
-            text: function() { return Promise.resolve(')]}\'\n' +
-                JSON.stringify({
-                  id: '7afa4931_de3d65bd',
-                  path: '/path/to/file.txt',
-                  line: 5,
-                  in_reply_to: 'baf0414d_60047215',
-                  updated: '2015-12-21 02:01:10.850000000',
-                  message: 'Done',
-                }));
+            text() {
+              return Promise.resolve(')]}\'\n' +
+                  JSON.stringify({
+                    id: '7afa4931_de3d65bd',
+                    path: '/path/to/file.txt',
+                    line: 5,
+                    in_reply_to: 'baf0414d_60047215',
+                    updated: '2015-12-21 02:01:10.850000000',
+                    message: 'Done',
+                  }));
             },
           });
         },
-        deleteDiffDraft: function() { return Promise.resolve({ok: true}); },
+        deleteDiffDraft() { return Promise.resolve({ok: true}); },
       });
       element = fixture('withComment');
       element.comments = [{
@@ -200,15 +201,15 @@
       flushAsynchronousOperations();
     });
 
-    test('reply', function(done) {
-      var commentEl = element.$$('gr-diff-comment');
+    test('reply', done => {
+      const commentEl = element.$$('gr-diff-comment');
       assert.ok(commentEl);
 
-      var replyBtn = element.$.replyBtn;
+      const replyBtn = element.$.replyBtn;
       MockInteractions.tap(replyBtn);
       flushAsynchronousOperations();
 
-      var drafts = element._orderedComments.filter(function(c) {
+      const drafts = element._orderedComments.filter(c => {
         return c.__draft == true;
       });
       assert.equal(drafts.length, 1);
@@ -217,16 +218,16 @@
       done();
     });
 
-    test('quote reply', function(done) {
-      var commentEl = element.$$('gr-diff-comment');
+    test('quote reply', done => {
+      const commentEl = element.$$('gr-diff-comment');
       assert.ok(commentEl);
 
-      var quoteBtn = element.$.quoteBtn;
+      const quoteBtn = element.$.quoteBtn;
       MockInteractions.tap(quoteBtn);
       flushAsynchronousOperations();
 
-      var drafts = element._orderedComments.filter(function(c) {
-          return c.__draft == true;
+      const drafts = element._orderedComments.filter(c => {
+        return c.__draft == true;
       });
       assert.equal(drafts.length, 1);
       assert.equal(drafts[0].message, '> is this a crossover episode!?\n\n');
@@ -234,7 +235,7 @@
       done();
     });
 
-    test('quote reply multiline', function(done) {
+    test('quote reply multiline', done => {
       element.comments = [{
         author: {
           name: 'Mr. Peanutbutter',
@@ -247,14 +248,14 @@
       }];
       flushAsynchronousOperations();
 
-      var commentEl = element.$$('gr-diff-comment');
+      const commentEl = element.$$('gr-diff-comment');
       assert.ok(commentEl);
 
-      var quoteBtn = element.$.quoteBtn;
+      const quoteBtn = element.$.quoteBtn;
       MockInteractions.tap(quoteBtn);
       flushAsynchronousOperations();
 
-      var drafts = element._orderedComments.filter(function(c) {
+      const drafts = element._orderedComments.filter(c => {
         return c.__draft == true;
       });
       assert.equal(drafts.length, 1);
@@ -264,17 +265,17 @@
       done();
     });
 
-    test('ack', function(done) {
+    test('ack', done => {
       element.changeNum = '42';
       element.patchNum = '1';
 
-      var commentEl = element.$$('gr-diff-comment');
+      const commentEl = element.$$('gr-diff-comment');
       assert.ok(commentEl);
 
-      var ackBtn = element.$.ackBtn;
+      const ackBtn = element.$.ackBtn;
       MockInteractions.tap(ackBtn);
-      flush(function() {
-        var drafts = element.comments.filter(function(c) {
+      flush(() => {
+        const drafts = element.comments.filter(c => {
           return c.__draft == true;
         });
         assert.equal(drafts.length, 1);
@@ -285,16 +286,16 @@
       });
     });
 
-    test('done', function(done) {
+    test('done', done => {
       element.changeNum = '42';
       element.patchNum = '1';
-      var commentEl = element.$$('gr-diff-comment');
+      const commentEl = element.$$('gr-diff-comment');
       assert.ok(commentEl);
 
-      var doneBtn = element.$.doneBtn;
+      const doneBtn = element.$.doneBtn;
       MockInteractions.tap(doneBtn);
-      flush(function() {
-        var drafts = element.comments.filter(function(c) {
+      flush(() => {
+        const drafts = element.comments.filter(c => {
           return c.__draft == true;
         });
         assert.equal(drafts.length, 1);
@@ -305,13 +306,13 @@
       });
     });
 
-    test('please fix', function(done) {
+    test('please fix', done => {
       element.changeNum = '42';
       element.patchNum = '1';
-      var commentEl = element.$$('gr-diff-comment');
+      const commentEl = element.$$('gr-diff-comment');
       assert.ok(commentEl);
-      commentEl.addEventListener('create-fix-comment', function() {
-        var drafts = element._orderedComments.filter(function(c) {
+      commentEl.addEventListener('create-fix-comment', () => {
+        const drafts = element._orderedComments.filter(c => {
           return c.__draft == true;
         });
         assert.equal(drafts.length, 1);
@@ -325,21 +326,21 @@
           {bubbles: false});
     });
 
-    test('discard', function(done) {
+    test('discard', done => {
       element.changeNum = '42';
       element.patchNum = '1';
       element.push('comments', element._newReply(
-        element.comments[0].id,
-        element.comments[0].line,
-        element.comments[0].path,
-        'it’s pronouced jiff, not giff'));
+          element.comments[0].id,
+          element.comments[0].line,
+          element.comments[0].path,
+          'it’s pronouced jiff, not giff'));
       flushAsynchronousOperations();
 
-      var draftEl =
+      const draftEl =
           Polymer.dom(element.root).querySelectorAll('gr-diff-comment')[1];
       assert.ok(draftEl);
-      draftEl.addEventListener('comment-discard', function() {
-        var drafts = element.comments.filter(function(c) {
+      draftEl.addEventListener('comment-discard', () => {
+        const drafts = element.comments.filter(c => {
           return c.__draft == true;
         });
         assert.equal(drafts.length, 0);
@@ -348,9 +349,7 @@
       draftEl.fire('comment-discard', null, {bubbles: false});
     });
 
-    test('first editing comment does not add __otherEditing attribute',
-        function() {
-      var commentEl = element.$$('gr-diff-comment');
+    test('first editing comment does not add __otherEditing attribute', () => {
       element.comments = [{
         author: {
           name: 'Mr. Peanutbutter',
@@ -363,19 +362,19 @@
         __draft: true,
       }];
 
-      var replyBtn = element.$.replyBtn;
+      const replyBtn = element.$.replyBtn;
       MockInteractions.tap(replyBtn);
       flushAsynchronousOperations();
 
-      var editing = element._orderedComments.filter(function(c) {
+      const editing = element._orderedComments.filter(c => {
         return c.__editing == true;
       });
       assert.equal(editing.length, 1);
       assert.equal(!!editing[0].__otherEditing, false);
     });
 
-    test('When not editing other comments, local storage not set after discard',
-        function(done) {
+    test('When not editing other comments, local storage not set' +
+        ' after discard', done => {
       element.changeNum = '42';
       element.patchNum = '1';
       element.comments = [{
@@ -413,13 +412,13 @@
         updated: '2015-12-08 19:48:33.843000000',
         __draft: true,
       }];
-      var storageStub = sinon.stub(element.$.storage, 'setDraftComment');
+      const storageStub = sinon.stub(element.$.storage, 'setDraftComment');
       flushAsynchronousOperations();
 
-      var draftEl =
-          Polymer.dom(element.root).querySelectorAll('gr-diff-comment')[1];
+      const draftEl =
+      Polymer.dom(element.root).querySelectorAll('gr-diff-comment')[1];
       assert.ok(draftEl);
-      draftEl.addEventListener('comment-discard', function() {
+      draftEl.addEventListener('comment-discard', () => {
         assert.isFalse(storageStub.called);
         storageStub.restore();
         done();
@@ -427,9 +426,9 @@
       draftEl.fire('comment-discard', null, {bubbles: false});
     });
 
-    test('comment-update', function() {
-      var commentEl = element.$$('gr-diff-comment');
-      var updatedComment = {
+    test('comment-update', () => {
+      const commentEl = element.$$('gr-diff-comment');
+      const updatedComment = {
         id: element.comments[0].id,
         foo: 'bar',
       };
@@ -437,88 +436,81 @@
       assert.strictEqual(element.comments[0], updatedComment);
     });
 
-    suite('jack and sally comment data test consolidation', function() {
-      var getComments = function() {
-        return Polymer.dom(element.root).querySelectorAll('gr-diff-comment');
-      };
-
-      setup(function() {
+    suite('jack and sally comment data test consolidation', () => {
+      setup(() => {
         element.comments = [
-        {
-          id: 'jacks_reply',
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession',
-          updated: '2015-12-25 15:00:20.396000000',
-          unresolved: false,
-        }, {
-          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',
-        }];
+          {
+            id: 'jacks_reply',
+            message: 'i like you, too',
+            in_reply_to: 'sallys_confession',
+            updated: '2015-12-25 15:00:20.396000000',
+            unresolved: false,
+          }, {
+            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',
+          }];
       });
 
-      test('orphan replies', function() {
+      test('orphan replies', () => {
         assert.equal(4, element._orderedComments.length);
       });
 
-      test('keyboard shortcuts', function() {
-        var expandCollapseStub = sinon.stub(element, '_expandCollapseComments');
+      test('keyboard shortcuts', () => {
+        const expandCollapseStub =
+            sinon.stub(element, '_expandCollapseComments');
         MockInteractions.pressAndReleaseKeyOn(element, 69, null, 'e');
         assert.isTrue(expandCollapseStub.lastCall.calledWith(false));
 
         MockInteractions.pressAndReleaseKeyOn(element, 69, 'shift', 'e');
         assert.isTrue(expandCollapseStub.lastCall.calledWith(true));
-        expandCollapseStub.restore();
       });
 
-      test('comment in_reply_to is either null or most recent comment id',
-          function() {
+      test('comment in_reply_to is either null or most recent comment', () => {
         element._createReplyComment(element.comments[3], 'dummy', true);
         flushAsynchronousOperations();
         assert.equal(element._orderedComments.length, 5);
         assert.equal(element._orderedComments[4].in_reply_to, 'jacks_reply');
       });
 
-      test('resolvable comments', function() {
+      test('resolvable comments', () => {
         assert.isFalse(element._unresolved);
         element._createReplyComment(element.comments[3], 'dummy', true, true);
         flushAsynchronousOperations();
         assert.isTrue(element._unresolved);
       });
 
-      test('_setInitialExpandedState', function() {
+      test('_setInitialExpandedState', () => {
         element._unresolved = true;
         element._setInitialExpandedState();
-        var comments = getComments();
-        for (var i = 0; i < element.comments.length; i++) {
+        for (let i = 0; i < element.comments.length; i++) {
           assert.isFalse(element.comments[i].collapsed);
         }
         element._unresolved = false;
         element._setInitialExpandedState();
-        var comments = getComments();
-        for (var i = 0; i < element.comments.length; i++) {
+        for (let i = 0; i < element.comments.length; i++) {
           assert.isTrue(element.comments[i].collapsed);
         }
       });
     });
 
-    test('_computeHostClass', function() {
+    test('_computeHostClass', () => {
       assert.equal(element._computeHostClass(true), 'unresolved');
       assert.equal(element._computeHostClass(false), '');
     });
 
-    test('addDraft sets unresolved state correctly', function() {
-      var unresolved = true;
+    test('addDraft sets unresolved state correctly', () => {
+      let unresolved = true;
       element.comments = [];
       element.addDraft(null, null, unresolved);
       assert.equal(element.comments[0].unresolved, true);
@@ -533,15 +525,15 @@
       assert.equal(element.comments[0].unresolved, true);
     });
 
-    test('_newDraft', function() {
+    test('_newDraft', () => {
       element.commentSide = 'left';
       element.patchNum = 3;
-      var draft = element._newDraft();
+      const draft = element._newDraft();
       assert.equal(draft.__commentSide, 'left');
       assert.equal(draft.patchNum, 3);
     });
 
-    test('new comment gets created', function() {
+    test('new comment gets created', () => {
       element.comments = [];
       element.addOrEditDraft(1);
       assert.equal(element.comments.length, 1);
@@ -552,7 +544,7 @@
       assert.equal(element.comments.length, 2);
     });
 
-    test('unresolved label', function() {
+    test('unresolved label', () => {
       element._unresolved = false;
       assert.isTrue(element.$.unresolvedLabel.hasAttribute('hidden'));
       element._unresolved = true;
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 4c00132..e90dc41d 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
@@ -16,13 +16,17 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../shared/gr-textarea/gr-textarea.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
-<link rel="import" href="../../shared/gr-storage/gr-storage.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-storage/gr-storage.html">
 <link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
+<link rel="import" href="../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html">
 
+<script src="../../../scripts/rootElement.js"></script>
 
 <dom-module id="gr-diff-comment">
   <template>
@@ -72,6 +76,8 @@
       .date {
         justify-content: flex-end;
         margin-left: 5px;
+        min-width: 4.5em;
+        text-align: right;
         white-space: nowrap;
       }
       a.date:link,
@@ -117,7 +123,6 @@
         display: none;
       }
       .editing .editMessage {
-        background-color: #fff;
         display: block;
       }
       .show-hide {
@@ -163,7 +168,7 @@
       }
       #container.collapsed .actions,
       #container.collapsed gr-formatted-text,
-      #container.collapsed iron-autogrow-textarea {
+      #container.collapsed gr-textarea {
         display: none;
       }
       .resolve,
@@ -177,6 +182,19 @@
         color: #333;
         font-size: 12px;
       }
+      gr-confirm-dialog .main {
+        background-color: #fef;
+        display: flex;
+        flex-direction: column;
+        width: 100%;
+      }
+      #deleteBtn {
+        display: none;
+        margin-top: .5em;
+      }
+      #deleteBtn.showDeleteButtons {
+        display: block;
+      }
     </style>
     <div id="container"
         class="container"
@@ -215,14 +233,14 @@
           [[comment.robot_id]]
         </div>
       </template>
-      <iron-autogrow-textarea
+      <gr-textarea
           id="editTextarea"
           class="editMessage"
           autocomplete="on"
           disabled="{{disabled}}"
           rows="4"
-          bind-value="{{_messageText}}"
-          on-keydown="_handleTextareaKeydown"></iron-autogrow-textarea>
+          text="{{_messageText}}"
+          on-keydown="_handleTextareaKeydown"></gr-textarea>
       <gr-formatted-text class="message"
           content="[[comment.message]]"
           no-trailing-margin="[[!comment.__draft]]"
@@ -256,6 +274,12 @@
         <div class="action unresolved hideOnPublished" hidden$="[[resolved]]">
           Unresolved
         </div>
+        <gr-button
+            id="deleteBtn"
+            class$="action delete [[_computeDeleteButtonClass(_isAdmin, draft)]]"
+            on-tap="_handleCommentDelete">
+          Delete
+        </gr-button>
       </div>
       <div class="actions robotActions" hidden$="[[!_showRobotActions]]">
         <gr-button class="action fix"
@@ -265,6 +289,12 @@
         </gr-button>
       </div>
     </div>
+    <gr-overlay id="overlay" with-backdrop>
+      <gr-confirm-delete-comment-dialog id="confirmDeleteComment"
+          on-confirm="_handleConfirmDeleteComment"
+          on-cancel="_handleCancelDeleteComment">
+      </gr-confirm-delete-comment-dialog>
+    </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-storage id="storage"></gr-storage>
   </template>
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 0791193..82ebfbb 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
@@ -14,7 +14,7 @@
 (function() {
   'use strict';
 
-  var STORAGE_DEBOUNCE_INTERVAL = 400;
+  const STORAGE_DEBOUNCE_INTERVAL = 400;
 
   Polymer({
     is: 'gr-diff-comment',
@@ -90,6 +90,10 @@
       },
       projectConfig: Object,
       robotButtonDisabled: Boolean,
+      _isAdmin: {
+        type: Boolean,
+        value: false,
+      },
 
       _xhrPromise: Object,  // Used for testing.
       _messageText: {
@@ -112,48 +116,56 @@
       '_calculateActionstoShow(showActions, isRobotComment)',
     ],
 
-    attached: function() {
+    attached() {
       if (this.editing) {
         this.collapsed = false;
       } else if (this.comment) {
         this.collapsed = this.comment.collapsed;
       }
+      this._getIsAdmin().then(isAdmin => {
+        this._isAdmin = isAdmin;
+      });
     },
 
-    detached: function() {
+    detached() {
       this.cancelDebouncer('fire-update');
+      this.$.editTextarea.closeDropdown();
     },
 
-    _computeShowHideText: function(collapsed) {
+    _computeShowHideText(collapsed) {
       return collapsed ? '◀' : '▼';
     },
 
-    _calculateActionstoShow: function(showActions, isRobotComment) {
+    _calculateActionstoShow(showActions, isRobotComment) {
       this._showHumanActions = showActions && !isRobotComment;
       this._showRobotActions = showActions && isRobotComment;
     },
 
-    _isRobotComment: function(comment) {
+    _isRobotComment(comment) {
       this.isRobotComment = !!comment.robot_id;
     },
 
-    isOnParent: function() {
+    isOnParent() {
       return this.side === 'PARENT';
     },
 
-    save: function() {
+    _getIsAdmin() {
+      return this.$.restAPI.getIsAdmin();
+    },
+
+    save() {
       this.comment.message = this._messageText;
 
       this.disabled = true;
 
       this._eraseDraftComment();
 
-      this._xhrPromise = this._saveDraft(this.comment).then(function(response) {
+      this._xhrPromise = this._saveDraft(this.comment).then(response => {
         this.disabled = false;
         if (!response.ok) { return response; }
 
-        return this.$.restAPI.getResponseObject(response).then(function(obj) {
-          var comment = obj;
+        return this.$.restAPI.getResponseObject(response).then(obj => {
+          const comment = obj;
           comment.__draft = true;
           // Maintain the ephemeral draft ID for identification by other
           // elements.
@@ -165,14 +177,18 @@
           this.editing = false;
           this._fireSave();
           return obj;
-        }.bind(this));
-      }.bind(this)).catch(function(err) {
+        });
+      }).catch(err => {
         this.disabled = false;
         throw err;
-      }.bind(this));
+      });
     },
 
-    _eraseDraftComment: function() {
+    _eraseDraftComment() {
+      // Prevents a race condition in which removing the draft comment occurs
+      // prior to it being saved.
+      this.cancelDebouncer('store');
+
       this.$.storage.eraseDraftComment({
         changeNum: this.changeNum,
         patchNum: this._getPatchNum(),
@@ -182,7 +198,7 @@
       });
     },
 
-    _commentChanged: function(comment) {
+    _commentChanged(comment) {
       this.editing = !!comment.__editing;
       this.resolved = !comment.unresolved;
       if (this.editing) { // It's a new draft/reply, notify.
@@ -190,41 +206,31 @@
       }
     },
 
-    _getEventPayload: function(opt_mixin) {
-      var payload = {
+    _getEventPayload(opt_mixin) {
+      return Object.assign({}, opt_mixin, {
         comment: this.comment,
         patchNum: this.patchNum,
-      };
-      for (var k in opt_mixin) {
-        payload[k] = opt_mixin[k];
-      }
-      return payload;
+      });
     },
 
-    _fireSave: function() {
+    _fireSave() {
       this.fire('comment-save', this._getEventPayload());
     },
 
-    _fireUpdate: function() {
-      this.debounce('fire-update', function() {
+    _fireUpdate() {
+      this.debounce('fire-update', () => {
         this.fire('comment-update', this._getEventPayload());
       });
     },
 
-    _draftChanged: function(draft) {
+    _draftChanged(draft) {
       this.$.container.classList.toggle('draft', draft);
     },
 
-    _editingChanged: function(editing, previousValue) {
+    _editingChanged(editing, previousValue) {
       this.$.container.classList.toggle('editing', editing);
       if (editing) {
-        var textarea = this.$.editTextarea.textarea;
-        // Put the cursor at the end always.
-        textarea.selectionStart = textarea.value.length;
-        textarea.selectionEnd = textarea.selectionStart;
-        this.async(function() {
-          textarea.focus();
-        }.bind(this));
+        this.$.editTextarea.putCursorAtEnd();
       }
       if (this.comment && this.comment.id) {
         this.$$('.cancel').hidden = !editing;
@@ -238,15 +244,19 @@
       }
     },
 
-    _computeLinkToComment: function(comment) {
+    _computeLinkToComment(comment) {
       return '#' + comment.line;
     },
 
-    _computeSaveDisabled: function(draft) {
+    _computeDeleteButtonClass(isAdmin, draft) {
+      return isAdmin && !draft ? 'showDeleteButtons' : '';
+    },
+
+    _computeSaveDisabled(draft) {
       return draft == null || draft.trim() == '';
     },
 
-    _handleTextareaKeydown: function(e) {
+    _handleTextareaKeydown(e) {
       switch (e.keyCode) {
         case 13: // 'enter'
           if (this._messageText.length !== 0 && (e.metaKey || e.ctrlKey)) {
@@ -266,11 +276,11 @@
       }
     },
 
-    _handleToggleCollapsed: function() {
+    _handleToggleCollapsed() {
       this.collapsed = !this.collapsed;
     },
 
-    _toggleCollapseClass: function(collapsed) {
+    _toggleCollapseClass(collapsed) {
       if (collapsed) {
         this.$.container.classList.add('collapsed');
       } else {
@@ -278,20 +288,20 @@
       }
     },
 
-    _commentMessageChanged: function(message) {
+    _commentMessageChanged(message) {
       this._messageText = message || '';
     },
 
-    _messageTextChanged: function(newValue, oldValue) {
+    _messageTextChanged(newValue, oldValue) {
       if (!this.comment || (this.comment && this.comment.id)) { return; }
 
       // Keep comment.message in sync so that gr-diff-comment-thread is aware
       // of the current message in the case that another comment is deleted.
       this.comment.message = this._messageText || '';
-      this.debounce('store', function() {
-        var message = this._messageText;
+      this.debounce('store', () => {
+        const message = this._messageText;
 
-        var commentLocation = {
+        const commentLocation = {
           changeNum: this.changeNum,
           patchNum: this._getPatchNum(),
           path: this.comment.path,
@@ -310,9 +320,9 @@
       }, STORAGE_DEBOUNCE_INTERVAL);
     },
 
-    _handleLinkTap: function(e) {
+    _handleLinkTap(e) {
       e.preventDefault();
-      var hash = this._computeLinkToComment(this.comment);
+      const hash = this._computeLinkToComment(this.comment);
       // Don't add the hash to the window history if it's already there.
       // Otherwise you mess up expected back button behavior.
       if (window.location.hash == hash) { return; }
@@ -321,52 +331,51 @@
       page.show(window.location.pathname + hash, null, false);
     },
 
-    _handleReply: function(e) {
+    _handleReply(e) {
       e.preventDefault();
       this.fire('create-reply-comment', this._getEventPayload(),
           {bubbles: false});
     },
 
-    _handleQuote: function(e) {
+    _handleQuote(e) {
       e.preventDefault();
       this.fire('create-reply-comment', this._getEventPayload({quote: true}),
           {bubbles: false});
     },
 
-    _handleFix: function(e) {
+    _handleFix(e) {
       e.preventDefault();
       this.fire('create-fix-comment', this._getEventPayload({quote: true}),
           {bubbles: false});
     },
 
-    _handleAck: function(e) {
+    _handleAck(e) {
       e.preventDefault();
       this.fire('create-ack-comment', this._getEventPayload(),
           {bubbles: false});
     },
 
-    _handleDone: function(e) {
+    _handleDone(e) {
       e.preventDefault();
       this.fire('create-done-comment', this._getEventPayload(),
           {bubbles: false});
     },
 
-    _handleEdit: function(e) {
+    _handleEdit(e) {
       e.preventDefault();
       this._messageText = this.comment.message;
       this.editing = true;
     },
 
-    _handleSave: function(e) {
+    _handleSave(e) {
       e.preventDefault();
       this.set('comment.__editing', false);
       this.save();
     },
 
-    _handleCancel: function(e) {
+    _handleCancel(e) {
       e.preventDefault();
-      if (!this.comment.message ||
-          this.comment.message.trim().length === 0) {
+      if (!this.comment.message || this.comment.message.trim().length === 0) {
         this._fireDiscard();
         return;
       }
@@ -374,12 +383,12 @@
       this.editing = false;
     },
 
-    _fireDiscard: function() {
+    _fireDiscard() {
       this.cancelDebouncer('fire-update');
       this.fire('comment-discard', this._getEventPayload());
     },
 
-    _handleDiscard: function(e) {
+    _handleDiscard(e) {
       e.preventDefault();
       if (!this.comment.__draft) {
         throw Error('Cannot discard a non-draft comment.');
@@ -394,32 +403,31 @@
         return;
       }
 
-      this._xhrPromise = this._deleteDraft(this.comment).then(
-          function(response) {
-            this.disabled = false;
-            if (!response.ok) { return response; }
+      this._xhrPromise = this._deleteDraft(this.comment).then(response => {
+        this.disabled = false;
+        if (!response.ok) { return response; }
 
-            this._fireDiscard();
-          }.bind(this)).catch(function(err) {
-            this.disabled = false;
-            throw err;
-          }.bind(this));
+        this._fireDiscard();
+      }).catch(err => {
+        this.disabled = false;
+        throw err;
+      });
     },
 
-    _saveDraft: function(draft) {
+    _saveDraft(draft) {
       return this.$.restAPI.saveDiffDraft(this.changeNum, this.patchNum, draft);
     },
 
-    _deleteDraft: function(draft) {
+    _deleteDraft(draft) {
       return this.$.restAPI.deleteDiffDraft(this.changeNum, this.patchNum,
           draft);
     },
 
-    _getPatchNum: function() {
+    _getPatchNum() {
       return this.isOnParent() ? 'PARENT' : this.patchNum;
     },
 
-    _loadLocalDraft: function(changeNum, patchNum, comment) {
+    _loadLocalDraft(changeNum, patchNum, comment) {
       // Only apply local drafts to comments that haven't been saved
       // remotely, and haven't been given a default message already.
       //
@@ -430,8 +438,8 @@
         return;
       }
 
-      var draft = this.$.storage.getDraftComment({
-        changeNum: changeNum,
+      const draft = this.$.storage.getDraftComment({
+        changeNum,
         patchNum: this._getPatchNum(),
         path: comment.path,
         line: comment.line,
@@ -443,21 +451,42 @@
       }
     },
 
-    _handleMouseEnter: function(e) {
+    _handleMouseEnter(e) {
       this.fire('comment-mouse-over', this._getEventPayload());
     },
 
-    _handleMouseLeave: function(e) {
+    _handleMouseLeave(e) {
       this.fire('comment-mouse-out', this._getEventPayload());
     },
 
-    _handleToggleResolved: function() {
+    _handleToggleResolved() {
       this.resolved = !this.resolved;
     },
 
-    _toggleResolved: function(resolved) {
+    _toggleResolved(resolved) {
       this.comment.unresolved = !resolved;
       this.fire('comment-update', this._getEventPayload());
     },
+
+    _handleCommentDelete() {
+      Polymer.dom(Gerrit.getRootElement()).appendChild(this.$.overlay);
+      this.async(() => {
+        this.$.overlay.open();
+      }, 1);
+    },
+
+    _handleCancelDeleteComment() {
+      Polymer.dom(Gerrit.getRootElement()).removeChild(this.$.overlay);
+      this.$.overlay.close();
+    },
+
+    _handleConfirmDeleteComment() {
+      this.$.restAPI.deleteComment(
+          this.changeNum, this.patchNum, this.comment.id,
+          this.$.confirmDeleteComment.message).then(newComment => {
+            this._handleCancelDeleteComment();
+            this.comment = newComment;
+          });
+    },
   });
 })();
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 919a64f..4a1df77 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
@@ -47,12 +47,12 @@
     return getComputedStyle(el).getPropertyValue('display') !== 'none';
   }
 
-  suite('gr-diff-comment tests', function() {
-    var element;
-    var sandbox;
-    setup(function() {
+  suite('gr-diff-comment tests', () => {
+    let element;
+    let sandbox;
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getAccount: function() { return Promise.resolve(null); },
+        getAccount() { return Promise.resolve(null); },
       });
       element = fixture('basic');
       element.comment = {
@@ -68,18 +68,18 @@
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('collapsible comments', function() {
+    test('collapsible comments', () => {
       // When a comment (not draft) is loaded, it should be collapsed
       assert.isTrue(element.collapsed);
       assert.isFalse(isVisible(element.$$('gr-formatted-text')),
           'gr-formatted-text is not visible');
       assert.isFalse(isVisible(element.$$('.actions')),
           'actions are not visible');
-      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+      assert.isFalse(isVisible(element.$$('gr-textarea')),
           'textarea is not visible');
 
       // The header middle content is only visible when comments are collapsed.
@@ -94,27 +94,26 @@
           'gr-formatted-text is visible');
       assert.isTrue(isVisible(element.$$('.actions')),
           'actions are visible');
-      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+      assert.isFalse(isVisible(element.$$('gr-textarea')),
           'textarea is not visible');
       assert.isFalse(isVisible(element.$$('.collapsedContent')),
           'header middle content is not visible');
     });
 
-    test('clicking on date link does not trigger nav', function() {
-      var showStub = sinon.stub(page, 'show');
-      var dateEl = element.$$('.date');
+    test('clicking on date link does not trigger nav', () => {
+      const showStub = sinon.stub(page, 'show');
+      const dateEl = element.$$('.date');
       assert.ok(dateEl);
       MockInteractions.tap(dateEl);
-      var dest = window.location.pathname + '#5';
+      const dest = window.location.pathname + '#5';
       assert(showStub.lastCall.calledWithExactly(dest, null, false),
           'Should navigate to ' + dest + ' without triggering nav');
       showStub.restore();
     });
 
-    test('message is not retrieved from storage when other editing is true',
-        function(done) {
-      var storageStub = sandbox.stub(element.$.storage, 'getDraftComment');
-      var loadSpy = sandbox.spy(element, '_loadLocalDraft');
+    test('message is not retrieved from storage when other edits', done => {
+      const storageStub = sandbox.stub(element.$.storage, 'getDraftComment');
+      const loadSpy = sandbox.spy(element, '_loadLocalDraft');
 
       element.changeNum = 1;
       element.patchNum = 1;
@@ -126,17 +125,16 @@
         line: 5,
         __otherEditing: true,
       };
-      flush(function() {
+      flush(() => {
         assert.isTrue(loadSpy.called);
         assert.isFalse(storageStub.called);
         done();
       });
     });
 
-    test('message is retrieved from storage when there is no other editing',
-        function(done) {
-      var storageStub = sandbox.stub(element.$.storage, 'getDraftComment');
-      var loadSpy = sandbox.spy(element, '_loadLocalDraft');
+    test('message is retrieved from storage when no other edits', done => {
+      const storageStub = sandbox.stub(element.$.storage, 'getDraftComment');
+      const loadSpy = sandbox.spy(element, '_loadLocalDraft');
 
       element.changeNum = 1;
       element.patchNum = 1;
@@ -147,14 +145,14 @@
         },
         line: 5,
       };
-      flush(function() {
+      flush(() => {
         assert.isTrue(loadSpy.called);
         assert.isTrue(storageStub.called);
         done();
       });
     });
 
-    test('_getPatchNum', function() {
+    test('_getPatchNum', () => {
       element.side = 'PARENT';
       element.patchNum = 1;
       assert.equal(element._getPatchNum(), 'PARENT');
@@ -162,13 +160,13 @@
       assert.equal(element._getPatchNum(), 1);
     });
 
-    test('comment expand and collapse', function() {
+    test('comment expand and collapse', () => {
       element.collapsed = true;
       assert.isFalse(isVisible(element.$$('gr-formatted-text')),
           'gr-formatted-text is not visible');
       assert.isFalse(isVisible(element.$$('.actions')),
           'actions are not visible');
-      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+      assert.isFalse(isVisible(element.$$('gr-textarea')),
           'textarea is not visible');
       assert.isTrue(isVisible(element.$$('.collapsedContent')),
           'header middle content is visible');
@@ -179,14 +177,14 @@
           'gr-formatted-text is visible');
       assert.isTrue(isVisible(element.$$('.actions')),
           'actions are visible');
-      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+      assert.isFalse(isVisible(element.$$('gr-textarea')),
           'textarea is not visible');
       assert.isFalse(isVisible(element.$$('.collapsedContent')),
           'header middle content is is not visible');
     });
 
-    suite('while editing', function() {
-      setup(function() {
+    suite('while editing', () => {
+      setup(() => {
         element.editing = true;
         element._messageText = 'test';
         sandbox.stub(element, '_handleCancel');
@@ -194,75 +192,108 @@
         flushAsynchronousOperations();
       });
 
-      suite('when text is empty', function() {
-        setup(function() {
+      suite('when text is empty', () => {
+        setup(() => {
           element._messageText = '';
         });
 
-        test('esc closes comment when text is empty', function() {
+        test('esc closes comment when text is empty', () => {
           MockInteractions.pressAndReleaseKeyOn(
               element.$.editTextarea, 27); // esc
           assert.isTrue(element._handleCancel.called);
         });
 
-        test('ctrl+enter does not save', function() {
+        test('ctrl+enter does not save', () => {
           MockInteractions.pressAndReleaseKeyOn(
               element.$.editTextarea, 13, 'ctrl'); // ctrl + enter
           assert.isFalse(element._handleSave.called);
         });
 
-        test('meta+enter does not save', function() {
+        test('meta+enter does not save', () => {
           MockInteractions.pressAndReleaseKeyOn(
               element.$.editTextarea, 13, 'meta'); // meta + enter
           assert.isFalse(element._handleSave.called);
         });
 
-        test('ctrl+s does not save', function() {
+        test('ctrl+s does not save', () => {
           MockInteractions.pressAndReleaseKeyOn(
               element.$.editTextarea, 83, 'ctrl'); // ctrl + s
           assert.isFalse(element._handleSave.called);
         });
       });
 
-      test('esc does not close comment that has content', function() {
+      test('esc does not close comment that has content', () => {
         MockInteractions.pressAndReleaseKeyOn(
             element.$.editTextarea, 27); // esc
         assert.isFalse(element._handleCancel.called);
       });
 
-      test('ctrl+enter saves', function() {
+      test('ctrl+enter saves', () => {
         MockInteractions.pressAndReleaseKeyOn(
             element.$.editTextarea, 13, 'ctrl'); // ctrl + enter
         assert.isTrue(element._handleSave.called);
       });
 
-      test('meta+enter saves', function() {
+      test('meta+enter saves', () => {
         MockInteractions.pressAndReleaseKeyOn(
             element.$.editTextarea, 13, 'meta'); // meta + enter
         assert.isTrue(element._handleSave.called);
       });
 
-      test('ctrl+s saves', function() {
+      test('ctrl+s saves', () => {
         MockInteractions.pressAndReleaseKeyOn(
             element.$.editTextarea, 83, 'ctrl'); // ctrl + s
         assert.isTrue(element._handleSave.called);
       });
     });
+    test('delete comment button for non-admins is hidden', () => {
+      element._isAdmin = false;
+      assert.isFalse(element.$$('.action.delete')
+          .classList.contains('showDeleteButtons'));
+    });
+
+    test('delete comment button for admins with draft is hidden', () => {
+      element._isAdmin = false;
+      element.draft = true;
+      assert.isFalse(element.$$('.action.delete')
+          .classList.contains('showDeleteButtons'));
+    });
+
+    test('delete comment', done => {
+      sandbox.stub(
+          element.$.restAPI, 'deleteComment').returns(Promise.resolve({}));
+      sandbox.spy(element.$.overlay, 'open');
+      element.changeNum = 42;
+      element.patchNum = 0xDEADBEEF;
+      element._isAdmin = true;
+      assert.isTrue(element.$$('.action.delete')
+          .classList.contains('showDeleteButtons'));
+      MockInteractions.tap(element.$$('.action.delete'));
+      flush(() => {
+        element.$.overlay.open.lastCall.returnValue.then(() => {
+          element.$.confirmDeleteComment.message = 'removal reason';
+          element._handleConfirmDeleteComment();
+          assert.isTrue(element.$.restAPI.deleteComment.calledWith(
+              42, 0xDEADBEEF, 'baf0414d_60047215', 'removal reason'));
+          done();
+        });
+      });
+    });
   });
 
-  suite('gr-diff-comment draft tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-diff-comment draft tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getAccount: function() { return Promise.resolve(null); },
-        saveDiffDraft: function() {
+        getAccount() { return Promise.resolve(null); },
+        saveDiffDraft() {
           return Promise.resolve({
             ok: true,
-            text: function() {
+            text() {
               return Promise.resolve(
-                ')]}\'\n{' +
+                  ')]}\'\n{' +
                   '"id": "baf0414d_40572e03",' +
                   '"path": "/path/to/file",' +
                   '"line": 5,' +
@@ -273,12 +304,12 @@
             },
           });
         },
-        removeChangeReviewer: function() {
+        removeChangeReviewer() {
           return Promise.resolve({ok: true});
         },
       });
       stub('gr-storage', {
-        getDraftComment: function() { return null; },
+        getDraftComment() { return null; },
       });
       element = fixture('draft');
       element.changeNum = 42;
@@ -295,11 +326,11 @@
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('button visibility states', function() {
+    test('button visibility states', () => {
       element.showActions = false;
       assert.isTrue(element.$$('.humanActions').hasAttribute('hidden'));
       assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
@@ -356,13 +387,13 @@
       assert.isFalse(element.$$('.robotActions').hasAttribute('hidden'));
     });
 
-    test('collapsible drafts', function() {
+    test('collapsible drafts', () => {
       assert.isTrue(element.collapsed);
       assert.isFalse(isVisible(element.$$('gr-formatted-text')),
           'gr-formatted-text is not visible');
       assert.isFalse(isVisible(element.$$('.actions')),
           'actions are not visible');
-      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+      assert.isFalse(isVisible(element.$$('gr-textarea')),
           'textarea is not visible');
       assert.isTrue(isVisible(element.$$('.collapsedContent')),
           'header middle content is visible');
@@ -373,7 +404,7 @@
           'gr-formatted-text is visible');
       assert.isTrue(isVisible(element.$$('.actions')),
           'actions are visible');
-      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+      assert.isFalse(isVisible(element.$$('gr-textarea')),
           'textarea is not visible');
       assert.isFalse(isVisible(element.$$('.collapsedContent')),
           'header middle content is is not visible');
@@ -386,7 +417,7 @@
           'gr-formatted-text is not visible');
       assert.isTrue(isVisible(element.$$('.actions')),
           'actions are visible');
-      assert.isTrue(isVisible(element.$$('iron-autogrow-textarea')),
+      assert.isTrue(isVisible(element.$$('gr-textarea')),
           'textarea is visible');
       assert.isFalse(isVisible(element.$$('.collapsedContent')),
           'header middle content is not visible');
@@ -399,7 +430,7 @@
           'gr-formatted-text is not visible');
       assert.isFalse(isVisible(element.$$('.actions')),
           'actions are not visible');
-      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+      assert.isFalse(isVisible(element.$$('gr-textarea')),
           'textarea is not visible');
       assert.isTrue(isVisible(element.$$('.collapsedContent')),
           'header middle content is visible');
@@ -411,32 +442,32 @@
           'gr-formatted-text is not visible');
       assert.isTrue(isVisible(element.$$('.actions')),
           'actions are visible');
-      assert.isTrue(isVisible(element.$$('iron-autogrow-textarea')),
+      assert.isTrue(isVisible(element.$$('gr-textarea')),
           'textarea is visible');
       assert.isFalse(isVisible(element.$$('.collapsedContent')),
           'header middle content is not visible');
     });
 
-    test('draft creation/cancelation', function(done) {
+    test('draft creation/cancelation', done => {
       assert.isFalse(element.editing);
       MockInteractions.tap(element.$$('.edit'));
       assert.isTrue(element.editing);
 
       element._messageText = '';
-      var eraseMessageDraftSpy = sandbox.spy(element, '_eraseDraftComment');
+      const eraseMessageDraftSpy = sandbox.spy(element, '_eraseDraftComment');
 
       // Save should be disabled on an empty message.
-      var disabled = element.$$('.save').hasAttribute('disabled');
+      let disabled = element.$$('.save').hasAttribute('disabled');
       assert.isTrue(disabled, 'save button should be disabled.');
       element._messageText = '     ';
       disabled = element.$$('.save').hasAttribute('disabled');
       assert.isTrue(disabled, 'save button should be disabled.');
 
-      var updateStub = sinon.stub();
+      const updateStub = sinon.stub();
       element.addEventListener('comment-update', updateStub);
 
-      var numDiscardEvents = 0;
-      element.addEventListener('comment-discard', function(e) {
+      let numDiscardEvents = 0;
+      element.addEventListener('comment-discard', e => {
         numDiscardEvents++;
         assert.isFalse(eraseMessageDraftSpy.called);
         if (numDiscardEvents === 2) {
@@ -450,32 +481,32 @@
       MockInteractions.pressAndReleaseKeyOn(element.$.editTextarea, 27); // esc
     });
 
-    test('draft discard removes message from storage', function(done) {
+    test('draft discard removes message from storage', done => {
       element._messageText = '';
-      var eraseMessageDraftSpy = sandbox.spy(element, '_eraseDraftComment');
+      const eraseMessageDraftSpy = sandbox.spy(element, '_eraseDraftComment');
 
-      var numDiscardEvents = 0;
-      element.addEventListener('comment-discard', function(e) {
+      element.addEventListener('comment-discard', e => {
         assert.isTrue(eraseMessageDraftSpy.called);
         done();
       });
       MockInteractions.tap(element.$$('.discard'));
     });
 
-    test('ctrl+s saves comment', function(done) {
-      var stub = sinon.stub(element, 'save', function() {
+    test('ctrl+s saves comment', done => {
+      const stub = sinon.stub(element, 'save', () => {
         assert.isTrue(stub.called);
         stub.restore();
         done();
       });
       element._messageText = 'is that the horse from horsing around??';
       MockInteractions.pressAndReleaseKeyOn(
-        element.$.editTextarea.textarea,
-        83, 'ctrl');  // 'ctrl + s'
+          element.$.editTextarea.$.textarea.textarea,
+          83, 'ctrl');  // 'ctrl + s'
     });
 
-    test('draft saving/editing', function(done) {
-      var fireStub = sinon.stub(element, 'fire');
+    test('draft saving/editing', done => {
+      const fireStub = sinon.stub(element, 'fire');
+      const cancelDebounce = sandbox.stub(element, 'cancelDebouncer');
 
       element.draft = true;
       MockInteractions.tap(element.$$('.edit'));
@@ -483,7 +514,7 @@
       element.flushDebouncer('fire-update');
       element.flushDebouncer('store');
       assert(fireStub.calledWith('comment-update'),
-             'comment-update should be sent');
+          'comment-update should be sent');
       assert.deepEqual(fireStub.lastCall.args, [
         'comment-update', {
           comment: {
@@ -504,9 +535,11 @@
       assert.isTrue(element.disabled,
           'Element should be disabled when creating draft.');
 
-      element._xhrPromise.then(function(draft) {
+      element._xhrPromise.then(draft => {
         assert(fireStub.calledWith('comment-save'),
-               'comment-save should be sent');
+            'comment-save should be sent');
+        assert(cancelDebounce.calledWith('store'));
+
         assert.deepEqual(fireStub.lastCall.args[1], {
           comment: {
             __commentSide: 'right',
@@ -522,10 +555,10 @@
           patchNum: 1,
         });
         assert.isFalse(element.disabled,
-                       'Element should be enabled when done creating draft.');
+            'Element should be enabled when done creating draft.');
         assert.equal(draft.message, 'saved!');
         assert.isFalse(element.editing);
-      }).then(function() {
+      }).then(() => {
         MockInteractions.tap(element.$$('.edit'));
         element._messageText = 'You’ll be delivering a package to Chapek 9, ' +
             'a world where humans are killed on sight.';
@@ -533,7 +566,7 @@
         assert.isTrue(element.disabled,
             'Element should be disabled when updating draft.');
 
-        element._xhrPromise.then(function(draft) {
+        element._xhrPromise.then(draft => {
           assert.isFalse(element.disabled,
               'Element should be enabled when done updating draft.');
           assert.equal(draft.message, 'saved!');
@@ -544,26 +577,26 @@
       });
     });
 
-    test('clicking on date link does not trigger nav', function() {
-      var showStub = sinon.stub(page, 'show');
-      var dateEl = element.$$('.date');
+    test('clicking on date link does not trigger nav', () => {
+      const showStub = sinon.stub(page, 'show');
+      const dateEl = element.$$('.date');
       assert.ok(dateEl);
       MockInteractions.tap(dateEl);
-      var dest = window.location.pathname + '#5';
+      const dest = window.location.pathname + '#5';
       assert(showStub.lastCall.calledWithExactly(dest, null, false),
           'Should navigate to ' + dest + ' without triggering nav');
       showStub.restore();
     });
 
-    test('proper event fires on resolve', function(done) {
-      element.addEventListener('comment-update', function(e) {
+    test('proper event fires on resolve', done => {
+      element.addEventListener('comment-update', e => {
         assert.isTrue(e.detail.comment.unresolved);
         done();
       });
       MockInteractions.tap(element.$$('.resolve input'));
     });
 
-    test('resolved comment state indicated by checkbox', function() {
+    test('resolved comment state indicated by checkbox', () => {
       element.comment = {unresolved: false};
       assert.isTrue(element.$$('.resolve input').checked);
       element.comment = {unresolved: true};
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 e40ccf3..e47db8f 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
@@ -14,23 +14,23 @@
 (function() {
   'use strict';
 
-  var DiffSides = {
+  const DiffSides = {
     LEFT: 'left',
     RIGHT: 'right',
   };
 
-  var DiffViewMode = {
+  const DiffViewMode = {
     SIDE_BY_SIDE: 'SIDE_BY_SIDE',
     UNIFIED: 'UNIFIED_DIFF',
   };
 
-  var ScrollBehavior = {
+  const ScrollBehavior = {
     KEEP_VISIBLE: 'keep-visible',
     NEVER: 'never',
   };
 
-  var LEFT_SIDE_CLASS = 'target-side-left';
-  var RIGHT_SIDE_CLASS = 'target-side-right';
+  const LEFT_SIDE_CLASS = 'target-side-left';
+  const RIGHT_SIDE_CLASS = 'target-side-right';
 
   Polymer({
     is: 'gr-diff-cursor',
@@ -54,9 +54,7 @@
        */
       diffs: {
         type: Array,
-        value: function() {
-          return [];
-        },
+        value() { return []; },
       },
 
       /**
@@ -87,30 +85,30 @@
       '_diffsChanged(diffs.splices)',
     ],
 
-    attached: function() {
+    attached() {
       // Catch when users are scrolling as the view loads.
       this.listen(window, 'scroll', '_handleWindowScroll');
     },
 
-    detached: function() {
+    detached() {
       this.unlisten(window, 'scroll', '_handleWindowScroll');
     },
 
-    moveLeft: function() {
+    moveLeft() {
       this.side = DiffSides.LEFT;
       if (this._isTargetBlank()) {
         this.moveUp();
       }
     },
 
-    moveRight: function() {
+    moveRight() {
       this.side = DiffSides.RIGHT;
       if (this._isTargetBlank()) {
         this.moveUp();
       }
     },
 
-    moveDown: function() {
+    moveDown() {
       if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
         this.$.cursorManager.next(this._rowHasSide.bind(this));
       } else {
@@ -118,7 +116,7 @@
       }
     },
 
-    moveUp: function() {
+    moveUp() {
       if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
         this.$.cursorManager.previous(this._rowHasSide.bind(this));
       } else {
@@ -126,31 +124,31 @@
       }
     },
 
-    moveToNextChunk: function() {
+    moveToNextChunk() {
       this.$.cursorManager.next(this._isFirstRowOfChunk.bind(this),
-          function(target) {
+          target => {
             return target.parentNode.scrollHeight;
           });
       this._fixSide();
     },
 
-    moveToPreviousChunk: function() {
+    moveToPreviousChunk() {
       this.$.cursorManager.previous(this._isFirstRowOfChunk.bind(this));
       this._fixSide();
     },
 
-    moveToNextCommentThread: function() {
+    moveToNextCommentThread() {
       this.$.cursorManager.next(this._rowHasThread.bind(this));
       this._fixSide();
     },
 
-    moveToPreviousCommentThread: function() {
+    moveToPreviousCommentThread() {
       this.$.cursorManager.previous(this._rowHasThread.bind(this));
       this._fixSide();
     },
 
-    moveToLineNumber: function(number, side) {
-      var row = this._findRowByNumber(number, side);
+    moveToLineNumber(number, side, opt_path) {
+      const row = this._findRowByNumberAndFile(number, side, opt_path);
       if (row) {
         this.side = side;
         this.$.cursorManager.setCursor(row);
@@ -161,8 +159,8 @@
      * Get the line number element targeted by the cursor row and side.
      * @return {DOMElement}
      */
-    getTargetLineElement: function() {
-      var lineElSelector = '.lineNum';
+    getTargetLineElement() {
+      let lineElSelector = '.lineNum';
 
       if (!this.diffRow) {
         return;
@@ -175,20 +173,20 @@
       return this.diffRow.querySelector(lineElSelector);
     },
 
-    getTargetDiffElement: function() {
+    getTargetDiffElement() {
       // Find the parent diff element of the cursor row.
-      for (var diff = this.diffRow; diff; diff = diff.parentElement) {
+      for (let diff = this.diffRow; diff; diff = diff.parentElement) {
         if (diff.tagName === 'GR-DIFF') { return diff; }
       }
       return null;
     },
 
-    moveToFirstChunk: function() {
+    moveToFirstChunk() {
       this.$.cursorManager.moveToStart();
       this.moveToNextChunk();
     },
 
-    reInitCursor: function() {
+    reInitCursor() {
       this._updateStops();
       if (this.initialLineNumber) {
         this.moveToLineNumber(this.initialLineNumber, this.side);
@@ -198,14 +196,14 @@
       }
     },
 
-    _handleWindowScroll: function() {
+    _handleWindowScroll() {
       if (this._listeningForScroll) {
         this._scrollBehavior = ScrollBehavior.NEVER;
         this._listeningForScroll = false;
       }
     },
 
-    handleDiffUpdate: function() {
+    handleDiffUpdate() {
       this._updateStops();
 
       if (!this.diffRow) {
@@ -215,7 +213,7 @@
       this._listeningForScroll = false;
     },
 
-    _handleDiffRenderStart: function() {
+    _handleDiffRenderStart() {
       this._listeningForScroll = true;
     },
 
@@ -225,12 +223,12 @@
      * Returns an empty string if an address is not available.
      * @return {String}
      */
-    getAddress: function() {
+    getAddress() {
       if (!this.diffRow) { return ''; }
 
       // Get the line-number cell targeted by the cursor. If the mode is unified
       // then prefer the revision cell if available.
-      var cell;
+      let cell;
       if (this._getViewMode() === DiffViewMode.UNIFIED) {
         cell = this.diffRow.querySelector('.lineNum.right');
         if (!cell) {
@@ -241,13 +239,13 @@
       }
       if (!cell) { return ''; }
 
-      var number = cell.getAttribute('data-value');
+      const number = cell.getAttribute('data-value');
       if (!number || number === 'FILE') { return ''; }
 
       return (cell.matches('.left') ? 'b' : '') + number;
     },
 
-    _getViewMode: function() {
+    _getViewMode() {
       if (!this.diffRow) {
         return null;
       }
@@ -259,20 +257,20 @@
       }
     },
 
-    _rowHasSide: function(row) {
-      var selector = (this.side === DiffSides.LEFT ? '.left' : '.right') +
+    _rowHasSide(row) {
+      const selector = (this.side === DiffSides.LEFT ? '.left' : '.right') +
           ' + .content';
       return !!row.querySelector(selector);
     },
 
-    _isFirstRowOfChunk: function(row) {
-      var parentClassList = row.parentNode.classList;
+    _isFirstRowOfChunk(row) {
+      const parentClassList = row.parentNode.classList;
       return parentClassList.contains('section') &&
           parentClassList.contains('delta') &&
           !row.previousSibling;
     },
 
-    _rowHasThread: function(row) {
+    _rowHasThread(row) {
       return row.querySelector('gr-diff-comment-thread');
     },
 
@@ -280,7 +278,7 @@
      * If we jumped to a row where there is no content on the current side then
      * switch to the alternate side.
      */
-    _fixSide: function() {
+    _fixSide() {
       if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE &&
           this._isTargetBlank()) {
         this.side = this.side === DiffSides.LEFT ?
@@ -288,24 +286,24 @@
       }
     },
 
-    _isTargetBlank: function() {
+    _isTargetBlank() {
       if (!this.diffRow) {
         return false;
       }
 
-      var actions = this._getActionsForRow();
+      const actions = this._getActionsForRow();
       return (this.side === DiffSides.LEFT && !actions.left) ||
           (this.side === DiffSides.RIGHT && !actions.right);
     },
 
-    _rowChanged: function(newRow, oldRow) {
+    _rowChanged(newRow, oldRow) {
       if (oldRow) {
         oldRow.classList.remove(LEFT_SIDE_CLASS, RIGHT_SIDE_CLASS);
       }
       this._updateSideClass();
     },
 
-    _updateSideClass: function() {
+    _updateSideClass() {
       if (!this.diffRow) {
         return;
       }
@@ -315,12 +313,12 @@
           this.diffRow);
     },
 
-    _isActionType: function(type) {
+    _isActionType(type) {
       return type !== 'blank' && type !== 'contextControl';
     },
 
-    _getActionsForRow: function() {
-      var actions = {left: false, right: false};
+    _getActionsForRow() {
+      const actions = {left: false, right: false};
       if (this.diffRow) {
         actions.left = this._isActionType(
             this.diffRow.getAttribute('left-type'));
@@ -330,14 +328,14 @@
       return actions;
     },
 
-    _getStops: function() {
+    _getStops() {
       return this.diffs.reduce(
-          function(stops, diff) {
+          (stops, diff) => {
             return stops.concat(diff.getCursorStops());
           }, []);
     },
 
-    _updateStops: function() {
+    _updateStops() {
       this.$.cursorManager.stops = this._getStops();
     },
 
@@ -346,14 +344,14 @@
      * removed from the cursor.
      * @private
      */
-    _diffsChanged: function(changeRecord) {
+    _diffsChanged(changeRecord) {
       if (!changeRecord) { return; }
 
       this._updateStops();
 
-      var splice;
-      var i;
-      for (var spliceIdx = 0;
+      let splice;
+      let i;
+      for (let spliceIdx = 0;
         changeRecord.indexSplices &&
             spliceIdx < changeRecord.indexSplices.length;
         spliceIdx++) {
@@ -371,15 +369,22 @@
             i++) {
           this.unlisten(splice.removed[i],
               'render-start', '_handleDiffRenderStart');
-          this.unlisten(splice.removed[i], 'render', 'handleDiffUpdate');
+          this.unlisten(splice.removed[i],
+              'render-content', 'handleDiffUpdate');
         }
       }
     },
 
-    _findRowByNumber: function(targetNumber, side) {
-      var stops = this.$.cursorManager.stops;
-      var selector;
-      for (var i = 0; i < stops.length; i++) {
+    _findRowByNumberAndFile(targetNumber, side, opt_path) {
+      let stops;
+      if (opt_path) {
+        const diff = this.diffs.filter(diff => diff.path === opt_path)[0];
+        stops = diff.getCursorStops();
+      } else {
+        stops = this.$.cursorManager.stops;
+      }
+      let selector;
+      for (let i = 0; i < stops.length; i++) {
         selector = '.lineNum.' + side + '[data-value="' + targetNumber + '"]';
         if (stops[i].querySelector(selector)) {
           return stops[i];
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 a77c617..645093e 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
@@ -38,17 +38,20 @@
 </test-fixture>
 
 <script>
-  suite('gr-diff-cursor tests', function() {
-    var cursorElement;
-    var diffElement;
-    var mockDiffResponse;
+  suite('gr-diff-cursor tests', () => {
+    let sandbox;
+    let cursorElement;
+    let diffElement;
+    let mockDiffResponse;
 
-    setup(function(done) {
+    setup(done => {
+      sandbox = sinon.sandbox.create();
+
       stub('gr-rest-api-interface', {
-        getLoggedIn: function() { return Promise.resolve(false); },
+        getLoggedIn() { return Promise.resolve(false); },
       });
 
-      var fixtureElems = fixture('basic');
+      const fixtureElems = fixture('basic');
       mockDiffResponse = fixtureElems[0];
       diffElement = fixtureElems[1];
       cursorElement = fixtureElems[2];
@@ -56,41 +59,43 @@
       // Register the diff with the cursor.
       cursorElement.push('diffs', diffElement);
 
-      diffElement.$.restAPI.getDiffPreferences().then(function(prefs) {
+      diffElement.$.restAPI.getDiffPreferences().then(prefs => {
         diffElement.prefs = prefs;
       });
 
-      sinon.stub(diffElement, '_getDiff', function() {
+      sandbox.stub(diffElement, '_getDiff', () => {
         return Promise.resolve(mockDiffResponse.diffResponse);
       });
 
-      sinon.stub(diffElement, '_getDiffComments', function() {
+      sandbox.stub(diffElement, '_getDiffComments', () => {
         return Promise.resolve({baseComments: [], comments: []});
       });
 
-      sinon.stub(diffElement, '_getDiffDrafts', function() {
+      sandbox.stub(diffElement, '_getDiffDrafts', () => {
         return Promise.resolve({baseComments: [], comments: []});
       });
 
-      sinon.stub(diffElement, '_getDiffRobotComments', function() {
+      sandbox.stub(diffElement, '_getDiffRobotComments', () => {
         return Promise.resolve({baseComments: [], comments: []});
       });
 
-      var setupDone = function() {
+      const setupDone = () => {
         cursorElement.moveToFirstChunk();
-        done();
         diffElement.removeEventListener('render', setupDone);
+        done();
       };
       diffElement.addEventListener('render', setupDone);
 
       diffElement.reload();
     });
 
-    test('diff cursor functionality (side-by-side)', function() {
+    teardown(() => sandbox.restore());
+
+    test('diff cursor functionality (side-by-side)', () => {
       // The cursor has been initialized to the first delta.
       assert.isOk(cursorElement.diffRow);
 
-      var firstDeltaRow = diffElement.$$('.section.delta .diff-row');
+      const firstDeltaRow = diffElement.$$('.section.delta .diff-row');
       assert.equal(cursorElement.diffRow, firstDeltaRow);
 
       cursorElement.moveDown();
@@ -104,7 +109,7 @@
       assert.equal(cursorElement.diffRow, firstDeltaRow);
     });
 
-    test('cursor scroll behavior', function() {
+    test('cursor scroll behavior', () => {
       cursorElement._handleDiffRenderStart();
       assert.equal(cursorElement._scrollBehavior, 'keep-visible');
 
@@ -115,11 +120,10 @@
       assert.equal(cursorElement._scrollBehavior, 'keep-visible');
     });
 
-    suite('unified diff', function() {
-
-      setup(function(done) {
+    suite('unified diff', () => {
+      setup(done => {
         // We must allow the diff to re-render after setting the viewMode.
-        var renderHandler = function() {
+        const renderHandler = function() {
           diffElement.removeEventListener('render', renderHandler);
           cursorElement.reInitCursor();
           done();
@@ -128,11 +132,11 @@
         diffElement.viewMode = 'UNIFIED_DIFF';
       });
 
-      test('diff cursor functionality (unified)', function() {
+      test('diff cursor functionality (unified)', () => {
         // The cursor has been initialized to the first delta.
         assert.isOk(cursorElement.diffRow);
 
-        var firstDeltaRow = diffElement.$$('.section.delta .diff-row');
+        let firstDeltaRow = diffElement.$$('.section.delta .diff-row');
         assert.equal(cursorElement.diffRow, firstDeltaRow);
 
         firstDeltaRow = diffElement.$$('.section.delta .diff-row');
@@ -150,19 +154,19 @@
       });
     });
 
-    test('cursor side functionality', function() {
+    test('cursor side functionality', () => {
       // The side only applies to side-by-side mode, which should be the default
       // mode.
       assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
 
-      var firstDeltaSection = diffElement.$$('.section.delta');
-      var firstDeltaRow = firstDeltaSection.querySelector('.diff-row');
+      const firstDeltaSection = diffElement.$$('.section.delta');
+      const firstDeltaRow = firstDeltaSection.querySelector('.diff-row');
 
       // Because the first delta in this diff is on the right, it should be set
       // to the right side.
       assert.equal(cursorElement.side, 'right');
       assert.equal(cursorElement.diffRow, firstDeltaRow);
-      var firstIndex = cursorElement.$.cursorManager.index;
+      const firstIndex = cursorElement.$.cursorManager.index;
 
       // Move the side to the left. Because this delta only has a right side, we
       // should be moved up to the previous line where there is content on the
@@ -186,16 +190,16 @@
           firstDeltaSection.nextSibling);
     });
 
-    test('chunk skip functionality', function() {
-      var chunks = Polymer.dom(diffElement.root).querySelectorAll(
+    test('chunk skip functionality', () => {
+      const chunks = Polymer.dom(diffElement.root).querySelectorAll(
           '.section.delta');
-      var indexOfChunk = function(chunk) {
+      const indexOfChunk = function(chunk) {
         return Array.prototype.indexOf.call(chunks, chunk);
       };
 
       // We should be initialized to the first chunk. Since this chunk only has
       // content on the right side, our side should be right.
-      var currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
+      let currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
       assert.equal(currentIndex, 0);
       assert.equal(cursorElement.side, 'right');
 
@@ -204,17 +208,17 @@
 
       // Since this chunk only has content on the left side. we should have been
       // automatically mvoed over.
-      var previousIndex = currentIndex;
+      const previousIndex = currentIndex;
       currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
       assert.equal(currentIndex, previousIndex + 1);
       assert.equal(cursorElement.side, 'left');
     });
 
-    test('initialLineNumber disabled', function(done) {
-      var moveToNumStub = sinon.stub(cursorElement, 'moveToLineNumber');
-      var moveToChunkStub = sinon.stub(cursorElement, 'moveToFirstChunk');
+    test('initialLineNumber disabled', done => {
+      const moveToNumStub = sandbox.stub(cursorElement, 'moveToLineNumber');
+      const moveToChunkStub = sandbox.stub(cursorElement, 'moveToFirstChunk');
 
-      diffElement.addEventListener('render', function() {
+      diffElement.addEventListener('render', () => {
         assert.isFalse(moveToNumStub.called);
         assert.isTrue(moveToChunkStub.called);
         done();
@@ -223,11 +227,11 @@
       diffElement.reload();
     });
 
-    test('initialLineNumber enabled', function(done) {
-      var moveToNumStub = sinon.stub(cursorElement, 'moveToLineNumber');
-      var moveToChunkStub = sinon.stub(cursorElement, 'moveToFirstChunk');
+    test('initialLineNumber enabled', done => {
+      const moveToNumStub = sandbox.stub(cursorElement, 'moveToLineNumber');
+      const moveToChunkStub = sandbox.stub(cursorElement, 'moveToFirstChunk');
 
-      diffElement.addEventListener('render', function() {
+      diffElement.addEventListener('render', () => {
         assert.isFalse(moveToChunkStub.called);
         assert.isTrue(moveToNumStub.called);
         assert.equal(moveToNumStub.lastCall.args[0], 10);
@@ -241,7 +245,7 @@
       diffElement.reload();
     });
 
-    test('getAddress', function() {
+    test('getAddress', () => {
       // It should initialize to the first chunk: line 5 of the revision.
       assert.equal(cursorElement.getAddress(), '5');
 
@@ -266,13 +270,22 @@
       assert.equal(cursorElement.getAddress(), '');
     });
 
-    test('_findRowByNumber', function() {
+    test('_findRowByNumberAndFile', () => {
       // Get the first ab row after the first chunk.
-      var row = Polymer.dom(diffElement.root).querySelectorAll('tr')[8];
+      const row = Polymer.dom(diffElement.root).querySelectorAll('tr')[8];
 
       // It should be line 8 on the right, but line 5 on the left.
-      assert.equal(cursorElement._findRowByNumber(8, 'right'), row);
-      assert.equal(cursorElement._findRowByNumber(5, 'left'), row);
+      assert.equal(cursorElement._findRowByNumberAndFile(8, 'right'), row);
+      assert.equal(cursorElement._findRowByNumberAndFile(5, 'left'), row);
+    });
+
+    test('expand context updates stops', done => {
+      sandbox.spy(cursorElement, 'handleDiffUpdate');
+      MockInteractions.tap(diffElement.$$('.showContext'));
+      flush(() => {
+        assert.isTrue(cursorElement.handleDiffUpdate.called);
+        done();
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js
index bb5b938..9afbf2b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js
@@ -18,12 +18,12 @@
   if (window.GrAnnotation) { return; }
 
   // TODO(wyatta): refactor this to be <MARK> rather than <HL>.
-  var ANNOTATION_TAG = 'HL';
+  const ANNOTATION_TAG = 'HL';
 
   // Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
-  var REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+  const REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
-  var GrAnnotation = {
+  const GrAnnotation = {
 
     /**
      * The DOM API textContent.length calculation is broken when the text
@@ -31,11 +31,11 @@
      * @param  {Text} A text node.
      * @return {Number} The length of the text.
      */
-    getLength: function(node) {
+    getLength(node) {
       return this.getStringLength(node.textContent);
     },
 
-    getStringLength: function(str) {
+    getStringLength(str) {
       return str.replace(REGEX_ASTRAL_SYMBOL, '_').length;
     },
 
@@ -44,14 +44,12 @@
      * element. If the element has child elements, the range is split and
      * applied as deeply as possible.
      */
-    annotateElement: function(parent, offset, length, cssClass) {
-      var nodes = [].slice.apply(parent.childNodes);
-      var node;
-      var nodeLength;
-      var subLength;
+    annotateElement(parent, offset, length, cssClass) {
+      const nodes = [].slice.apply(parent.childNodes);
+      let nodeLength;
+      let subLength;
 
-      for (var i = 0; i < nodes.length; i++) {
-        node = nodes[i];
+      for (const node of nodes) {
         nodeLength = this.getLength(node);
 
         // If the current node is completely before the offset.
@@ -85,8 +83,8 @@
      *
      * @return {!Element} Wrapped node.
      */
-    wrapInHighlight: function(node, cssClass) {
-      var hl;
+    wrapInHighlight(node, cssClass) {
+      let hl;
       if (node.tagName === ANNOTATION_TAG) {
         hl = node;
         hl.classList.add(cssClass);
@@ -108,7 +106,7 @@
      * @param {string} cssClass
      * @param {boolean=} opt_firstPart
      */
-    splitAndWrapInHighlight: function(node, offset, cssClass, opt_firstPart) {
+    splitAndWrapInHighlight(node, offset, cssClass, opt_firstPart) {
       if (this.getLength(node) === offset || offset === 0) {
         return this.wrapInHighlight(node, cssClass);
       } else {
@@ -130,14 +128,14 @@
      * @param {number} offset
      * @return {!Node} Trailing Node.
      */
-    splitNode: function(element, offset) {
+    splitNode(element, offset) {
       if (element instanceof Text) {
         return this.splitTextNode(element, offset);
       }
-      var tail = element.cloneNode(false);
+      const tail = element.cloneNode(false);
       element.parentElement.insertBefore(tail, element.nextSibling);
       // Skip nodes before offset.
-      var node = element.firstChild;
+      let node = element.firstChild;
       while (node &&
           this.getLength(node) <= offset ||
           this.getLength(node) === 0) {
@@ -163,17 +161,17 @@
      * @param {number} offset
      * @return {!Text} Trailing Text Node.
      */
-    splitTextNode: function(node, offset) {
+    splitTextNode(node, offset) {
       if (node.textContent.match(REGEX_ASTRAL_SYMBOL)) {
         // TODO (viktard): Polyfill Array.from for IE10.
-        var head = Array.from(node.textContent);
-        var tail = head.splice(offset);
-        var parent = node.parentNode;
+        const head = Array.from(node.textContent);
+        const tail = head.splice(offset);
+        const parent = node.parentNode;
 
         // Split the content of the original node.
         node.textContent = head.join('');
 
-        var tailNode = document.createTextNode(tail.join(''));
+        const tailNode = document.createTextNode(tail.join(''));
         if (parent) {
           parent.insertBefore(tailNode, node.nextSibling);
         }
@@ -183,8 +181,8 @@
       }
     },
 
-    _annotateText: function(node, offset, length, cssClass) {
-      var nodeLength = this.getLength(node);
+    _annotateText(node, offset, length, cssClass) {
+      const nodeLength = this.getLength(node);
 
       // There are four cases:
       //  1) Entire node is highlighted.
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html
index 0a03539..b25b4fb 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html
@@ -33,18 +33,18 @@
 </test-fixture>
 
 <script>
-  suite('annotation', function() {
-    var str;
-    var parent;
-    var textNode;
+  suite('annotation', () => {
+    let str;
+    let parent;
+    let textNode;
 
-    setup(function() {
+    setup(() => {
       parent = fixture('basic');
       textNode = parent.childNodes[0];
       str = textNode.textContent;
     });
 
-    test('_annotateText Case 1', function() {
+    test('_annotateText Case 1', () => {
       GrAnnotation._annotateText(textNode, 0, str.length, 'foobar');
 
       assert.equal(parent.childNodes.length, 1);
@@ -54,10 +54,10 @@
       assert.equal(parent.childNodes[0].childNodes[0].textContent, str);
     });
 
-    test('_annotateText Case 2', function() {
-      var length = 12;
-      var substr = str.substr(0, length);
-      var remainder = str.substr(length);
+    test('_annotateText Case 2', () => {
+      const length = 12;
+      const substr = str.substr(0, length);
+      const remainder = str.substr(length);
 
       GrAnnotation._annotateText(textNode, 0, length, 'foobar');
 
@@ -72,11 +72,11 @@
       assert.equal(parent.childNodes[1].textContent, remainder);
     });
 
-    test('_annotateText Case 3', function() {
-      var index = 12;
-      var length = str.length - index;
-      var remainder = str.substr(0, index);
-      var substr = str.substr(index);
+    test('_annotateText Case 3', () => {
+      const index = 12;
+      const length = str.length - index;
+      const remainder = str.substr(0, index);
+      const substr = str.substr(index);
 
       GrAnnotation._annotateText(textNode, index, length, 'foobar');
 
@@ -91,13 +91,13 @@
       assert.equal(parent.childNodes[1].childNodes[0].textContent, substr);
     });
 
-    test('_annotateText Case 4', function() {
-      var index = str.indexOf('dolor');
-      var length = 'dolor '.length;
+    test('_annotateText Case 4', () => {
+      const index = str.indexOf('dolor');
+      const length = 'dolor '.length;
 
-      var remainderPre = str.substr(0, index);
-      var substr = str.substr(index, length);
-      var remainderPost = str.substr(index + length);
+      const remainderPre = str.substr(0, index);
+      const substr = str.substr(index, length);
+      const remainderPost = str.substr(index + length);
 
       GrAnnotation._annotateText(textNode, index, length, 'foobar');
 
@@ -115,42 +115,42 @@
       assert.equal(parent.childNodes[2].textContent, remainderPost);
     });
 
-    test('_annotateElement design doc example', function() {
-      var layers = [
+    test('_annotateElement design doc example', () => {
+      const layers = [
         'amet, ',
         'inceptos ',
         'amet, ',
-        'et, suspendisse ince'
+        'et, suspendisse ince',
       ];
 
       // Apply the layers successively.
-      layers.forEach(function(layer, i) {
+      layers.forEach((layer, i) => {
         GrAnnotation.annotateElement(
-            parent, str.indexOf(layer), layer.length, 'layer-' + (i + 1));
+            parent, str.indexOf(layer), layer.length, `layer-${i + 1}`);
       });
 
       assert.equal(parent.textContent, str);
 
       // Layer 1:
-      var layer1 = parent.querySelectorAll('.layer-1');
+      const layer1 = parent.querySelectorAll('.layer-1');
       assert.equal(layer1.length, 1);
       assert.equal(layer1[0].textContent, layers[0]);
       assert.equal(layer1[0].parentElement, parent);
 
       // Layer 2:
-      var layer2 = parent.querySelectorAll('.layer-2');
+      const layer2 = parent.querySelectorAll('.layer-2');
       assert.equal(layer2.length, 1);
       assert.equal(layer2[0].textContent, layers[1]);
       assert.equal(layer2[0].parentElement, parent);
 
       // Layer 3:
-      var layer3 = parent.querySelectorAll('.layer-3');
+      const layer3 = parent.querySelectorAll('.layer-3');
       assert.equal(layer3.length, 1);
       assert.equal(layer3[0].textContent, layers[2]);
       assert.equal(layer3[0].parentElement, layer1[0]);
 
       // Layer 4:
-      var layer4 = parent.querySelectorAll('.layer-4');
+      const layer4 = parent.querySelectorAll('.layer-4');
       assert.equal(layer4.length, 3);
 
       assert.equal(layer4[0].textContent, 'et, ');
@@ -168,13 +168,13 @@
           layers[3]);
     });
 
-    test('splitTextNode', function() {
-      var helloString = 'hello';
-      var asciiString = 'ASCII';
-      var unicodeString = 'Unic💢de';
+    test('splitTextNode', () => {
+      const helloString = 'hello';
+      const asciiString = 'ASCII';
+      const unicodeString = 'Unic💢de';
 
-      var node;
-      var tail;
+      let node;
+      let tail;
 
       // Non-unicode path:
       node = document.createTextNode(helloString + asciiString);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
index e32d7d6..895f777 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
@@ -42,7 +42,7 @@
       return this._cachedDiffBuilder;
     },
 
-    _enableSelectionObserver: function(loggedIn, isAttached) {
+    _enableSelectionObserver(loggedIn, isAttached) {
       if (loggedIn && isAttached) {
         this.listen(document, 'selectionchange', '_handleSelectionChange');
       } else {
@@ -50,11 +50,11 @@
       }
     },
 
-    isRangeSelected: function() {
+    isRangeSelected() {
       return !!this.$$('gr-selection-action-box');
     },
 
-    _handleSelectionChange: function() {
+    _handleSelectionChange() {
       // Can't use up or down events to handle selection started and/or ended in
       // in comment threads or outside of diff.
       // Debounce removeActionBox to give it a chance to react to click/tap.
@@ -62,31 +62,31 @@
       this.debounce('selectionChange', this._handleSelection, 200);
     },
 
-    _handleCommentMouseOver: function(e) {
-      var comment = e.detail.comment;
+    _handleCommentMouseOver(e) {
+      const comment = e.detail.comment;
       if (!comment.range) { return; }
-      var lineEl = this.diffBuilder.getLineElByChild(e.target);
-      var side = this.diffBuilder.getSideByLineEl(lineEl);
-      var index = this._indexOfComment(side, comment);
+      const lineEl = this.diffBuilder.getLineElByChild(e.target);
+      const side = this.diffBuilder.getSideByLineEl(lineEl);
+      const index = this._indexOfComment(side, comment);
       if (index !== undefined) {
         this.set(['comments', side, index, '__hovering'], true);
       }
     },
 
-    _handleCommentMouseOut: function(e) {
-      var comment = e.detail.comment;
+    _handleCommentMouseOut(e) {
+      const comment = e.detail.comment;
       if (!comment.range) { return; }
-      var lineEl = this.diffBuilder.getLineElByChild(e.target);
-      var side = this.diffBuilder.getSideByLineEl(lineEl);
-      var index = this._indexOfComment(side, comment);
+      const lineEl = this.diffBuilder.getLineElByChild(e.target);
+      const side = this.diffBuilder.getSideByLineEl(lineEl);
+      const index = this._indexOfComment(side, comment);
       if (index !== undefined) {
         this.set(['comments', side, index, '__hovering'], false);
       }
     },
 
-    _indexOfComment: function(side, comment) {
-      var idProp = comment.id ? 'id' : '__draftID';
-      for (var i = 0; i < this.comments[side].length; i++) {
+    _indexOfComment(side, comment) {
+      const idProp = comment.id ? 'id' : '__draftID';
+      for (let i = 0; i < this.comments[side].length; i++) {
         if (comment[idProp] &&
             this.comments[side][i][idProp] === comment[idProp]) {
           return i;
@@ -94,8 +94,8 @@
       }
     },
 
-    _normalizeRange: function(domRange) {
-      var range = GrRangeNormalizer.normalize(domRange);
+    _normalizeRange(domRange) {
+      const range = GrRangeNormalizer.normalize(domRange);
       return this._fixTripleClickSelection({
         start: this._normalizeSelectionSide(
             range.startContainer, range.startOffset),
@@ -115,19 +115,19 @@
      * @param {!Range} domRange DOM Range object
      * @return {!Object} fixed normalized range
      */
-    _fixTripleClickSelection: function(range, domRange) {
+    _fixTripleClickSelection(range, domRange) {
       if (!range.start) {
         // Selection outside of current diff.
         return range;
       }
-      var start = range.start;
-      var end = range.end;
-      var endsAtOtherSideLineNum =
+      const start = range.start;
+      const end = range.end;
+      const endsAtOtherSideLineNum =
           domRange.endOffset === 0 &&
           domRange.endContainer.nodeName === 'TD' &&
           (domRange.endContainer.classList.contains('left') ||
               domRange.endContainer.classList.contains('right'));
-      var endsOnOtherSideStart = endsAtOtherSideLineNum ||
+      const endsOnOtherSideStart = endsAtOtherSideLineNum ||
           end &&
           end.column === 0 &&
           end.line === start.line &&
@@ -160,33 +160,33 @@
      *   column: Number
      * }}
      */
-    _normalizeSelectionSide: function(node, offset) {
-      var column;
+    _normalizeSelectionSide(node, offset) {
+      let column;
       if (!this.contains(node)) {
         return;
       }
-      var lineEl = this.diffBuilder.getLineElByChild(node);
+      const lineEl = this.diffBuilder.getLineElByChild(node);
       if (!lineEl) {
         return;
       }
-      var side = this.diffBuilder.getSideByLineEl(lineEl);
+      const side = this.diffBuilder.getSideByLineEl(lineEl);
       if (!side) {
         return;
       }
-      var line = this.diffBuilder.getLineNumberByChild(lineEl);
+      const line = this.diffBuilder.getLineNumberByChild(lineEl);
       if (!line) {
         return;
       }
-      var contentText = this.diffBuilder.getContentByLineEl(lineEl);
+      const contentText = this.diffBuilder.getContentByLineEl(lineEl);
       if (!contentText) {
         return;
       }
-      var contentTd = contentText.parentElement;
+      const contentTd = contentText.parentElement;
       if (!contentTd.contains(node)) {
         node = contentText;
         column = 0;
       } else {
-        var thread = contentTd.querySelector('gr-diff-comment-thread');
+        const thread = contentTd.querySelector('gr-diff-comment-thread');
         if (thread && thread.contains(node)) {
           column = this._getLength(contentText);
           node = contentText;
@@ -196,28 +196,28 @@
       }
 
       return {
-        node: node,
-        side: side,
-        line: line,
-        column: column,
+        node,
+        side,
+        line,
+        column,
       };
     },
 
-    _handleSelection: function() {
-      var selection = window.getSelection();
+    _handleSelection() {
+      const selection = window.getSelection();
       if (selection.rangeCount != 1) {
         return;
       }
-      var range = selection.getRangeAt(0);
+      const range = selection.getRangeAt(0);
       if (range.collapsed) {
         return;
       }
-      var normalizedRange = this._normalizeRange(range);
-      var start = normalizedRange.start;
+      const normalizedRange = this._normalizeRange(range);
+      const start = normalizedRange.start;
       if (!start) {
         return;
       }
-      var end = normalizedRange.end;
+      const end = normalizedRange.end;
       if (!end) {
         return;
       }
@@ -229,7 +229,7 @@
 
       // TODO (viktard): Drop empty first and last lines from selection.
 
-      var actionBox = document.createElement('gr-selection-action-box');
+      const actionBox = document.createElement('gr-selection-action-box');
       Polymer.dom(this.root).appendChild(actionBox);
       actionBox.range = {
         startLine: start.line,
@@ -251,22 +251,22 @@
       }
     },
 
-    _createComment: function(e) {
+    _createComment(e) {
       this._removeActionBox();
     },
 
-    _removeActionBoxDebounced: function() {
+    _removeActionBoxDebounced() {
       this.debounce('removeActionBox', this._removeActionBox, 10);
     },
 
-    _removeActionBox: function() {
-      var actionBox = this.$$('gr-selection-action-box');
+    _removeActionBox() {
+      const actionBox = this.$$('gr-selection-action-box');
       if (actionBox) {
         Polymer.dom(this.root).removeChild(actionBox);
       }
     },
 
-    _convertOffsetToColumn: function(el, offset) {
+    _convertOffsetToColumn(el, offset) {
       if (el instanceof Element && el.classList.contains('content')) {
         return offset;
       }
@@ -290,16 +290,16 @@
      * @param {function(Node):boolean} callback
      * @param {Object=} opt_flags If flags.left is true, traverse left.
      */
-    _traverseContentSiblings: function(startNode, callback, opt_flags) {
-      var travelLeft = opt_flags && opt_flags.left;
-      var node = startNode;
+    _traverseContentSiblings(startNode, callback, opt_flags) {
+      const travelLeft = opt_flags && opt_flags.left;
+      let node = startNode;
       while (node) {
         if (node instanceof Element &&
             node.tagName !== 'HL' &&
             node.tagName !== 'SPAN') {
           break;
         }
-        var nextNode = travelLeft ? node.previousSibling : node.nextSibling;
+        const nextNode = travelLeft ? node.previousSibling : node.nextSibling;
         if (callback(node)) {
           break;
         }
@@ -314,7 +314,7 @@
      * @param {!Node} node
      * @return {number}
      */
-    _getLength: function(node) {
+    _getLength(node) {
       if (node instanceof Element && node.classList.contains('content')) {
         return this._getLength(node.querySelector('.contentText'));
       } else {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
index 827437c..c1254bd 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
@@ -124,39 +124,39 @@
 </test-fixture>
 
 <script>
-  suite('gr-diff-highlight', function() {
-    var element;
-    var sandbox;
+  suite('gr-diff-highlight', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
       element = fixture('basic')[1];
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    suite('selectionchange event handling', function() {
-      var emulateSelection = function() {
+    suite('selectionchange event handling', () => {
+      const emulateSelection = function() {
         document.dispatchEvent(new CustomEvent('selectionchange'));
         element.flushDebouncer('selectionChange');
         element.flushDebouncer('removeActionBox');
       };
 
-      setup(function() {
+      setup(() => {
         sandbox.stub(element, '_handleSelection');
         sandbox.stub(element, '_removeActionBox');
       });
 
-      test('enabled if logged in', function() {
+      test('enabled if logged in', () => {
         element.loggedIn = true;
         emulateSelection();
         assert.isTrue(element._handleSelection.called);
         assert.isTrue(element._removeActionBox.called);
       });
 
-      test('ignored if logged out', function() {
+      test('ignored if logged out', () => {
         element.loggedIn = false;
         emulateSelection();
         assert.isFalse(element._handleSelection.called);
@@ -164,10 +164,10 @@
       });
     });
 
-    suite('comment events', function() {
-      var builder;
+    suite('comment events', () => {
+      let builder;
 
-      setup(function() {
+      setup(() => {
         builder = {
           getContentsByLineRange: sandbox.stub().returns([]),
           getLineElByChild: sandbox.stub().returns({}),
@@ -176,25 +176,25 @@
         element._cachedDiffBuilder = builder;
       });
 
-      test('comment-mouse-over from line comments is ignored', function() {
+      test('comment-mouse-over from line comments is ignored', () => {
         sandbox.stub(element, 'set');
         element.fire('comment-mouse-over', {comment: {}});
         assert.isFalse(element.set.called);
       });
 
-      test('comment-mouse-over from ranged comment causes set', function() {
+      test('comment-mouse-over from ranged comment causes set', () => {
         sandbox.stub(element, 'set');
         sandbox.stub(element, '_indexOfComment').returns(0);
         element.fire('comment-mouse-over', {comment: {range: {}}});
         assert.isTrue(element.set.called);
       });
 
-      test('comment-mouse-out from line comments is ignored', function() {
+      test('comment-mouse-out from line comments is ignored', () => {
         element.fire('comment-mouse-over', {comment: {}});
         assert.isFalse(builder.getContentsByLineRange.called);
       });
 
-      test('on create-comment action box is removed', function() {
+      test('on create-comment action box is removed', () => {
         sandbox.stub(element, '_removeActionBox');
         element.fire('create-comment', {
           comment: {
@@ -205,21 +205,21 @@
       });
     });
 
-    suite('selection', function() {
-      var diff;
-      var builder;
-      var contentStubs;
+    suite('selection', () => {
+      let diff;
+      let builder;
+      let contentStubs;
 
-      var stubContent = function(line, side, opt_child) {
-        var contentTd = diff.querySelector(
-            '.' + side + '.lineNum[data-value="' + line + '"] ~ .content');
-        var contentText = contentTd.querySelector('.contentText');
-        var lineEl = diff.querySelector(
-            '.' + side + '.lineNum[data-value="' + line + '"]');
+      const stubContent = (line, side, opt_child) => {
+        const contentTd = diff.querySelector(
+            `.${side}.lineNum[data-value="${line}"] ~ .content`);
+        const contentText = contentTd.querySelector('.contentText');
+        const lineEl = diff.querySelector(
+            `.${side}.lineNum[data-value="${line}"]`);
         contentStubs.push({
-          lineEl: lineEl,
-          contentTd: contentTd,
-          contentText: contentText,
+          lineEl,
+          contentTd,
+          contentText,
         });
         builder.getContentByLineEl.withArgs(lineEl).returns(contentText);
         builder.getLineNumberByChild.withArgs(lineEl).returns(line);
@@ -228,34 +228,29 @@
         return contentText;
       };
 
-      var emulateSelection = function(
-          startNode, startOffset, endNode, endOffset) {
-        var selection = window.getSelection();
-        var range = document.createRange();
+      const emulateSelection = (startNode, startOffset, endNode, endOffset) => {
+        const selection = window.getSelection();
+        const range = document.createRange();
         range.setStart(startNode, startOffset);
         range.setEnd(endNode, endOffset);
         selection.addRange(range);
         element._handleSelection();
       };
 
-      var getActionRange = function() {
-        return Polymer.dom(element.root).querySelector(
-            'gr-selection-action-box').range;
-      };
+      const getActionRange = () =>
+          Polymer.dom(element.root).querySelector(
+              'gr-selection-action-box').range;
 
-      var getActionSide = function() {
-        return Polymer.dom(element.root).querySelector(
-            'gr-selection-action-box').side;
-      };
+      const getActionSide = () =>
+          Polymer.dom(element.root).querySelector(
+              'gr-selection-action-box').side;
 
-      var getLineElByChild = function(node) {
-        var stubs = contentStubs.find(function(stub) {
-          return stub.contentTd.contains(node);
-        });
+      const getLineElByChild = node => {
+        const stubs = contentStubs.find(stub => stub.contentTd.contains(node));
         return stubs && stubs.lineEl;
       };
 
-      setup(function() {
+      setup(() => {
         contentStubs = [];
         stub('gr-selection-action-box', {
           placeAbove: sandbox.stub(),
@@ -264,20 +259,20 @@
         builder = {
           getContentByLine: sandbox.stub(),
           getContentByLineEl: sandbox.stub(),
-          getLineElByChild: getLineElByChild,
+          getLineElByChild,
           getLineNumberByChild: sandbox.stub(),
           getSideByLineEl: sandbox.stub(),
         };
         element._cachedDiffBuilder = builder;
       });
 
-      teardown(function() {
+      teardown(() => {
         contentStubs = null;
         window.getSelection().removeAllRanges();
       });
 
-      test('single line', function() {
-        var content = stubContent(138, 'left');
+      test('single line', () => {
+        const content = stubContent(138, 'left');
         emulateSelection(content.firstChild, 5, content.firstChild, 12);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
@@ -289,9 +284,9 @@
         assert.equal(getActionSide(), 'left');
       });
 
-      test('multiline', function() {
-        var startContent = stubContent(119, 'right');
-        var endContent = stubContent(120, 'right');
+      test('multiline', () => {
+        const startContent = stubContent(119, 'right');
+        const endContent = stubContent(120, 'right');
         emulateSelection(
             startContent.firstChild, 10, endContent.lastChild, 7);
         assert.isTrue(element.isRangeSelected());
@@ -304,9 +299,9 @@
         assert.equal(getActionSide(), 'right');
       });
 
-      test('multiline grow end highlight over tabs', function() {
-        var startContent = stubContent(119, 'right');
-        var endContent = stubContent(120, 'right');
+      test('multiline grow end highlight over tabs', () => {
+        const startContent = stubContent(119, 'right');
+        const endContent = stubContent(120, 'right');
         emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
@@ -318,16 +313,16 @@
         assert.equal(getActionSide(), 'right');
       });
 
-      test('collapsed', function() {
-        var content = stubContent(138, 'left');
+      test('collapsed', () => {
+        const content = stubContent(138, 'left');
         emulateSelection(content.firstChild, 5, content.firstChild, 5);
         assert.isOk(window.getSelection().getRangeAt(0).startContainer);
         assert.isFalse(element.isRangeSelected());
       });
 
-      test('starts inside hl', function() {
-        var content = stubContent(140, 'left');
-        var hl = content.querySelector('.foo');
+      test('starts inside hl', () => {
+        const content = stubContent(140, 'left');
+        const hl = content.querySelector('.foo');
         emulateSelection(hl.firstChild, 2, hl.nextSibling, 7);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
@@ -339,9 +334,9 @@
         assert.equal(getActionSide(), 'left');
       });
 
-      test('ends inside hl', function() {
-        var content = stubContent(140, 'left');
-        var hl = content.querySelector('.bar');
+      test('ends inside hl', () => {
+        const content = stubContent(140, 'left');
+        const hl = content.querySelector('.bar');
         emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
@@ -352,9 +347,9 @@
         });
       });
 
-      test('multiple hl', function() {
-        var content = stubContent(140, 'left');
-        var hl = content.querySelectorAll('hl')[4];
+      test('multiple hl', () => {
+        const content = stubContent(140, 'left');
+        const hl = content.querySelectorAll('hl')[4];
         emulateSelection(content.firstChild, 2, hl.firstChild, 2);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
@@ -366,34 +361,34 @@
         assert.equal(getActionSide(), 'left');
       });
 
-      test('starts outside of diff', function() {
-        var contentText = stubContent(140, 'left');
-        var contentTd = contentText.parentElement;
+      test('starts outside of diff', () => {
+        const contentText = stubContent(140, 'left');
+        const contentTd = contentText.parentElement;
 
         emulateSelection(contentTd.previousElementSibling, 0,
             contentText.firstChild, 2);
         assert.isFalse(element.isRangeSelected());
       });
 
-      test('ends outside of diff', function() {
-        var content = stubContent(140, 'left');
+      test('ends outside of diff', () => {
+        const content = stubContent(140, 'left');
         emulateSelection(content.nextElementSibling.firstChild, 2,
             content.firstChild, 2);
         assert.isFalse(element.isRangeSelected());
       });
 
-      test('starts and ends on different sides', function() {
-        var startContent = stubContent(140, 'left');
-        var endContent = stubContent(130, 'right');
+      test('starts and ends on different sides', () => {
+        const startContent = stubContent(140, 'left');
+        const endContent = stubContent(130, 'right');
         emulateSelection(startContent.firstChild, 2, endContent.firstChild, 2);
         assert.isFalse(element.isRangeSelected());
       });
 
-      test('starts in comment thread element', function() {
-        var startContent = stubContent(140, 'left');
-        var comment = startContent.parentElement.querySelector(
+      test('starts in comment thread element', () => {
+        const startContent = stubContent(140, 'left');
+        const comment = startContent.parentElement.querySelector(
             'gr-diff-comment-thread');
-        var endContent = stubContent(141, 'left');
+        const endContent = stubContent(141, 'left');
         emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
@@ -405,9 +400,9 @@
         assert.equal(getActionSide(), 'left');
       });
 
-      test('ends in comment thread element', function() {
-        var content = stubContent(140, 'left');
-        var comment = content.parentElement.querySelector(
+      test('ends in comment thread element', () => {
+        const content = stubContent(140, 'left');
+        const comment = content.parentElement.querySelector(
             'gr-diff-comment-thread');
         emulateSelection(content.firstChild, 4, comment.firstChild, 1);
         assert.isTrue(element.isRangeSelected());
@@ -420,27 +415,27 @@
         assert.equal(getActionSide(), 'left');
       });
 
-      test('starts in context element', function() {
-        var contextControl =
+      test('starts in context element', () => {
+        const contextControl =
             diff.querySelector('.contextControl').querySelector('gr-button');
-        var content = stubContent(146, 'right');
+        const content = stubContent(146, 'right');
         emulateSelection(contextControl, 0, content.firstChild, 7);
         // TODO (viktard): Select nearest line.
         assert.isFalse(element.isRangeSelected());
       });
 
-      test('ends in context element', function() {
-        var contextControl =
+      test('ends in context element', () => {
+        const contextControl =
             diff.querySelector('.contextControl').querySelector('gr-button');
-        var content = stubContent(141, 'left');
+        const content = stubContent(141, 'left');
         emulateSelection(content.firstChild, 2, contextControl, 1);
         // TODO (viktard): Select nearest line.
         assert.isFalse(element.isRangeSelected());
       });
 
-      test('selection containing context element', function() {
-        var startContent = stubContent(130, 'right');
-        var endContent = stubContent(146, 'right');
+      test('selection containing context element', () => {
+        const startContent = stubContent(130, 'right');
+        const endContent = stubContent(146, 'right');
         emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
@@ -452,8 +447,8 @@
         assert.equal(getActionSide(), 'right');
       });
 
-      test('ends at a tab', function() {
-        var content = stubContent(140, 'left');
+      test('ends at a tab', () => {
+        const content = stubContent(140, 'left');
         emulateSelection(
             content.firstChild, 1, content.querySelector('span'), 0);
         assert.isTrue(element.isRangeSelected());
@@ -466,8 +461,8 @@
         assert.equal(getActionSide(), 'left');
       });
 
-      test('starts at a tab', function() {
-        var content = stubContent(140, 'left');
+      test('starts at a tab', () => {
+        const content = stubContent(140, 'left');
         emulateSelection(
             content.querySelectorAll('hl')[3], 0,
             content.querySelectorAll('span')[1].nextSibling, 1);
@@ -481,21 +476,21 @@
         assert.equal(getActionSide(), 'left');
       });
 
-      test('properly accounts for syntax highlighting', function() {
-        var content = stubContent(140, 'left');
-        var spy = sinon.spy(element, '_normalizeRange');
+      test('properly accounts for syntax highlighting', () => {
+        const content = stubContent(140, 'left');
+        const spy = sinon.spy(element, '_normalizeRange');
         emulateSelection(
             content.querySelectorAll('hl')[3], 0,
             content.querySelectorAll('span')[1], 0);
-        var spyCall = spy.getCall(0);
-        var range = window.getSelection().getRangeAt(0);
+        const spyCall = spy.getCall(0);
+        const range = window.getSelection().getRangeAt(0);
         assert.notDeepEqual(spyCall.returnValue, range);
       });
 
-      test('GrRangeNormalizer._getTextOffset computes text offset', function() {
-        var content = stubContent(140, 'left');
-        var child = content.lastChild.lastChild;
-        var result = GrRangeNormalizer._getTextOffset(content, child);
+      test('GrRangeNormalizer._getTextOffset computes text offset', () => {
+        let content = stubContent(140, 'left');
+        let child = content.lastChild.lastChild;
+        let result = GrRangeNormalizer._getTextOffset(content, child);
         assert.equal(result, 75);
         content = stubContent(146, 'right');
         child = content.lastChild;
@@ -508,15 +503,15 @@
       // TODO (viktard): Only empty lines selected.
       // TODO (viktard): Unified mode.
 
-      suite('triple click', function() {
-        test('_fixTripleClickSelection', function() {
-          var fakeRange = {
+      suite('triple click', () => {
+        test('_fixTripleClickSelection', () => {
+          const fakeRange = {
             startContainer: '',
             startOffset: '',
             endContainer: '',
-            endOffset: ''
+            endOffset: '',
           };
-          var fixedRange = {};
+          const fixedRange = {};
           sandbox.stub(GrRangeNormalizer, 'normalize').returns(fakeRange);
           sandbox.stub(element, '_normalizeSelectionSide');
           sandbox.stub(element, '_fixTripleClickSelection').returns(fixedRange);
@@ -524,9 +519,9 @@
           assert.isTrue(element._fixTripleClickSelection.called);
         });
 
-        test('left pane', function() {
-          var startNode = stubContent(138, 'left');
-          var endNode =
+        test('left pane', () => {
+          const startNode = stubContent(138, 'left');
+          const endNode =
               stubContent(119, 'right').parentElement.previousElementSibling;
           builder.getLineNumberByChild.withArgs(endNode).returns(119);
           emulateSelection(startNode, 0, endNode, 0);
@@ -538,9 +533,9 @@
           });
         });
 
-        test('right pane', function() {
-          var startNode = stubContent(119, 'right');
-          var endNode =
+        test('right pane', () => {
+          const startNode = stubContent(119, 'right');
+          const endNode =
               stubContent(140, 'left').parentElement.previousElementSibling;
           emulateSelection(startNode, 0, endNode, 0);
           assert.deepEqual(getActionRange(), {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js
index e870169..0b0131e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js
@@ -18,9 +18,9 @@
   if (window.GrRangeNormalizer) { return; }
 
   // Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
-  var REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+  const REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
-  var GrRangeNormalizer = {
+  const GrRangeNormalizer = {
     /**
      * Remap DOM range to whole lines of a diff if necessary. If the start or
      * end containers are DOM elements that are singular pieces of syntax
@@ -31,23 +31,23 @@
      * @return {Object} A modified version of the range that correctly accounts
      *     for syntax highlighting.
      */
-    normalize: function(range) {
-      var startContainer = this._getContentTextParent(range.startContainer);
-      var startOffset = range.startOffset + this._getTextOffset(startContainer,
-          range.startContainer);
-      var endContainer = this._getContentTextParent(range.endContainer);
-      var endOffset = range.endOffset + this._getTextOffset(endContainer,
+    normalize(range) {
+      const startContainer = this._getContentTextParent(range.startContainer);
+      const startOffset = range.startOffset +
+          this._getTextOffset(startContainer, range.startContainer);
+      const endContainer = this._getContentTextParent(range.endContainer);
+      const endOffset = range.endOffset + this._getTextOffset(endContainer,
           range.endContainer);
       return {
-        startContainer: startContainer,
-        startOffset: startOffset,
-        endContainer: endContainer,
-        endOffset: endOffset,
+        startContainer,
+        startOffset,
+        endContainer,
+        endOffset,
       };
     },
 
-    _getContentTextParent: function(target) {
-      var element = target;
+    _getContentTextParent(target) {
+      let element = target;
       if (element.nodeName === '#text') {
         element = element.parentElement;
       }
@@ -69,18 +69,18 @@
      * @param {!Element} child The child element being searched for.
      * @return {number}
      */
-    _getTextOffset: function(node, child) {
-      var count = 0;
-      var stack = [node];
+    _getTextOffset(node, child) {
+      let count = 0;
+      let stack = [node];
       while (stack.length) {
-        var n = stack.pop();
+        const n = stack.pop();
         if (n === child) {
           break;
         }
         if (n.childNodes && n.childNodes.length !== 0) {
-          var arr = [];
-          for (var i = 0; i < n.childNodes.length; i++) {
-            arr.push(n.childNodes[i]);
+          const arr = [];
+          for (const childNode of n.childNodes) {
+            arr.push(childNode);
           }
           arr.reverse();
           stack = stack.concat(arr);
@@ -97,7 +97,7 @@
      * @param {Text} A text node.
      * @return {Number} The length of the text.
      */
-    _getLength: function(node) {
+    _getLength(node) {
       return node.textContent.replace(REGEX_ASTRAL_SYMBOL, '_').length;
     },
   };
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
index fe57c43..5a49daa 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
@@ -17,6 +17,8 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-storage/gr-storage.html">
 
 <dom-module id="gr-diff-preferences">
@@ -70,71 +72,79 @@
         color: #888;
       }
     </style>
-    <div class="header">
-      Diff View Preferences
-    </div>
-    <div class="mainContainer">
-      <div class="pref">
-        <label for="contextSelect">Context</label>
-        <select id="contextSelect" on-change="_handleContextSelectChange">
-          <option value="3">3 lines</option>
-          <option value="10">10 lines</option>
-          <option value="25">25 lines</option>
-          <option value="50">50 lines</option>
-          <option value="75">75 lines</option>
-          <option value="100">100 lines</option>
-          <option value="-1">Whole file</option>
-        </select>
+
+    <gr-overlay id="prefsOverlay" with-backdrop>
+      <div class="header">
+        Diff View Preferences
       </div>
-      <div class="pref">
-        <label for="lineWrappingInput">Fit to screen</label>
-        <input
-            is="iron-input"
-            type="checkbox"
-            id="lineWrappingInput"
-            on-tap="_handlelineWrappingTap">
+      <div class="mainContainer">
+        <div class="pref">
+          <label for="contextSelect">Context</label>
+          <select id="contextSelect" on-change="_handleContextSelectChange">
+            <option value="3">3 lines</option>
+            <option value="10">10 lines</option>
+            <option value="25">25 lines</option>
+            <option value="50">50 lines</option>
+            <option value="75">75 lines</option>
+            <option value="100">100 lines</option>
+            <option value="-1">Whole file</option>
+          </select>
+        </div>
+        <div class="pref">
+          <label for="lineWrappingInput">Fit to screen</label>
+          <input
+              is="iron-input"
+              type="checkbox"
+              id="lineWrappingInput"
+              on-tap="_handlelineWrappingTap">
+        </div>
+        <div class="pref" id="columnsPref"
+            hidden$="[[_newPrefs.line_wrapping]]">
+          <label for="columnsInput">Diff width</label>
+          <input is="iron-input" type="number" id="columnsInput"
+              prevent-invalid-input
+              allowed-pattern="[0-9]"
+              bind-value="{{_newPrefs.line_length}}">
+        </div>
+        <div class="pref">
+          <label for="tabSizeInput">Tab width</label>
+          <input is="iron-input" type="number" id="tabSizeInput"
+              prevent-invalid-input
+              allowed-pattern="[0-9]"
+              bind-value="{{_newPrefs.tab_size}}">
+        </div>
+        <div class="pref" hidden$="[[!_newPrefs.font_size]]">
+          <label for="fontSizeInput">Font size</label>
+          <input is="iron-input" type="number" id="fontSizeInput"
+                prevent-invalid-input
+                allowed-pattern="[0-9]"
+                bind-value="{{_newPrefs.font_size}}">
+        </div>
+        <div class="pref">
+          <label for="showTabsInput">Show tabs</label>
+          <input is="iron-input" type="checkbox" id="showTabsInput"
+              on-tap="_handleShowTabsTap">
+        </div>
+        <div class="pref">
+          <label for="showTrailingWhitespaceInput">
+            Show trailing whitespace</label>
+          <input is="iron-input" type="checkbox"
+              id="showTrailingWhitespaceInput"
+              on-tap="_handleShowTrailingWhitespaceTap">
+        </div>
+        <div class="pref">
+          <label for="syntaxHighlightInput">Syntax highlighting</label>
+          <input is="iron-input" type="checkbox" id="syntaxHighlightInput"
+              on-tap="_handleSyntaxHighlightTap">
+        </div>
       </div>
-      <div class="pref" id="columnsPref" hidden$="[[_newPrefs.line_wrapping]]">
-        <label for="columnsInput">Diff width</label>
-        <input is="iron-input" type="number" id="columnsInput"
-            prevent-invalid-input
-            allowed-pattern="[0-9]"
-            bind-value="{{_newPrefs.line_length}}">
+      <div class="actions">
+        <gr-button id="saveButton" primary on-tap="_handleSave">Save</gr-button>
+        <gr-button id="cancelButton" on-tap="_handleCancel">Cancel</gr-button>
       </div>
-      <div class="pref">
-        <label for="tabSizeInput">Tab width</label>
-        <input is="iron-input" type="number" id="tabSizeInput"
-            prevent-invalid-input
-            allowed-pattern="[0-9]"
-            bind-value="{{_newPrefs.tab_size}}">
-      </div>
-      <div class="pref" hidden$="[[!_newPrefs.font_size]]">
-        <label for="fontSizeInput">Font size</label>
-        <input is="iron-input" type="number" id="fontSizeInput"
-               prevent-invalid-input
-               allowed-pattern="[0-9]"
-               bind-value="{{_newPrefs.font_size}}">
-      </div>
-      <div class="pref">
-        <label for="showTabsInput">Show tabs</label>
-        <input is="iron-input" type="checkbox" id="showTabsInput"
-            on-tap="_handleShowTabsTap">
-      </div>
-      <div class="pref">
-        <label for="showTrailingWhitespaceInput">Show trailing whitespace</label>
-        <input is="iron-input" type="checkbox" id="showTrailingWhitespaceInput"
-            on-tap="_handleShowTrailingWhitespaceTap">
-      </div>
-      <div class="pref">
-        <label for="syntaxHighlightInput">Syntax highlighting</label>
-        <input is="iron-input" type="checkbox" id="syntaxHighlightInput"
-            on-tap="_handleSyntaxHighlightTap">
-      </div>
-    </div>
-    <div class="actions">
-      <gr-button id="saveButton" primary on-tap="_handleSave">Save</gr-button>
-      <gr-button id="cancelButton" on-tap="_handleCancel">Cancel</gr-button>
-    </div>
+    </overlay>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    <gr-storage id="storage"></gr-storage>
   </template>
   <script src="gr-diff-preferences.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js
index fd2a6f5..20833d0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js
@@ -17,18 +17,6 @@
   Polymer({
     is: 'gr-diff-preferences',
 
-    /**
-     * Fired when the user presses the save button.
-     *
-     * @event save
-     */
-
-    /**
-     * Fired when the user presses the cancel button.
-     *
-     * @event cancel
-     */
-
     properties: {
       prefs: {
         type: Object,
@@ -53,20 +41,19 @@
       '_localPrefsChanged(localPrefs.*)',
     ],
 
-    getFocusStops: function() {
+    getFocusStops() {
       return {
         start: this.$.contextSelect,
         end: this.$.cancelButton,
       };
     },
 
-    resetFocus: function() {
+    resetFocus() {
       this.$.contextSelect.focus();
     },
 
-    _prefsChanged: function(changeRecord) {
-      var prefs = changeRecord.base;
-      // TODO(andybons): This is not supported in IE. Implement a polyfill.
+    _prefsChanged(changeRecord) {
+      const prefs = changeRecord.base;
       // NOTE: Object.assign is NOT automatically a deep copy. If prefs adds
       // an object as a value, it must be marked enumerable.
       this._newPrefs = Object.assign({}, prefs);
@@ -77,43 +64,71 @@
       this.$.syntaxHighlightInput.checked = prefs.syntax_highlighting;
     },
 
-    _localPrefsChanged: function(changeRecord) {
-      var localPrefs = changeRecord.base || {};
-      // TODO(viktard): This is not supported in IE. Implement a polyfill.
+    _localPrefsChanged(changeRecord) {
+      const localPrefs = changeRecord.base || {};
       this._newLocalPrefs = Object.assign({}, localPrefs);
     },
 
-    _handleContextSelectChange: function(e) {
-      var selectEl = Polymer.dom(e).rootTarget;
+    _handleContextSelectChange(e) {
+      const selectEl = Polymer.dom(e).rootTarget;
       this.set('_newPrefs.context', parseInt(selectEl.value, 10));
     },
 
-    _handleShowTabsTap: function(e) {
+    _handleShowTabsTap(e) {
       this.set('_newPrefs.show_tabs', Polymer.dom(e).rootTarget.checked);
     },
 
-    _handleShowTrailingWhitespaceTap: function(e) {
+    _handleShowTrailingWhitespaceTap(e) {
       this.set('_newPrefs.show_whitespace_errors',
           Polymer.dom(e).rootTarget.checked);
     },
 
-    _handleSyntaxHighlightTap: function(e) {
+    _handleSyntaxHighlightTap(e) {
       this.set('_newPrefs.syntax_highlighting',
           Polymer.dom(e).rootTarget.checked);
     },
 
-    _handlelineWrappingTap: function(e) {
+    _handlelineWrappingTap(e) {
       this.set('_newPrefs.line_wrapping', Polymer.dom(e).rootTarget.checked);
     },
 
-    _handleSave: function() {
+    _handleSave(e) {
+      e.stopPropagation();
       this.prefs = this._newPrefs;
       this.localPrefs = this._newLocalPrefs;
-      this.fire('save', null, {bubbles: false});
+      const el = Polymer.dom(e).rootTarget;
+      el.disabled = true;
+      this.$.storage.savePreferences(this._localPrefs);
+      this._saveDiffPreferences().then(response => {
+        el.disabled = false;
+        if (!response.ok) { return response; }
+
+        this.$.prefsOverlay.close();
+      }).catch(err => {
+        el.disabled = false;
+      });
     },
 
-    _handleCancel: function() {
-      this.fire('cancel', null, {bubbles: false});
+    _handleCancel(e) {
+      e.stopPropagation();
+      this.$.prefsOverlay.close();
+    },
+
+    _handlePrefsTap(e) {
+      e.preventDefault();
+      this._openPrefs();
+    },
+
+    open() {
+      this.$.prefsOverlay.open().then(() => {
+        const focusStops = this.getFocusStops();
+        this.$.prefsOverlay.setFocusStops(focusStops);
+        this.resetFocus();
+      });
+    },
+
+    _saveDiffPreferences() {
+      return this.$.restAPI.saveDiffPreferences(this.prefs);
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html
index 06f617a..d201edf 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html
@@ -33,14 +33,20 @@
 </test-fixture>
 
 <script>
-  suite('gr-diff-preferences tests', function() {
-    var element;
+  suite('gr-diff-preferences tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
+      sandbox = sinon.sandbox.create();
       element = fixture('basic');
     });
 
-    test('model changes', function() {
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('model changes', () => {
       element.prefs = {
         context: 10,
         font_size: 12,
@@ -72,7 +78,7 @@
       assert.isFalse(element._newPrefs.syntax_highlighting);
     });
 
-    test('clicking fit to screen hides line length input', function() {
+    test('clicking fit to screen hides line length input', () => {
       element.prefs = {line_wrapping: false};
 
       assert.isFalse(element.$.columnsPref.hidden);
@@ -84,26 +90,33 @@
       assert.isFalse(element.$.columnsPref.hidden);
     });
 
-    test('clicking save button calls _handleSave function', function() {
-      var savePrefs = sinon.stub(element, '_handleSave');
+    test('clicking save button calls _handleSave function', () => {
+      const savePrefs = sinon.stub(element, '_handleSave');
       MockInteractions.tap(element.$.saveButton);
       flushAsynchronousOperations();
       assert(savePrefs.calledOnce);
       savePrefs.restore();
     });
 
-    test('events', function(done) {
-      var savePromise = new Promise(function(resolve) {
-        element.addEventListener('save', function() { resolve(); });
-      });
-      var cancelPromise = new Promise(function(resolve) {
-        element.addEventListener('cancel', function() { resolve(); });
-      });
-      Promise.all([savePromise, cancelPromise]).then(function() {
-        done();
-      });
+    test('save button', () => {
+      element.prefs = {
+        font_size: '11',
+      };
+      element._newPrefs = {
+        font_size: '12',
+      };
+      const saveStub = sandbox.stub(element.$.restAPI, 'saveDiffPreferences',
+          () => { return Promise.resolve(); });
+
       MockInteractions.tap(element.$$('gr-button[primary]'));
+      assert.deepEqual(element.prefs, element._newPrefs);
+      assert.deepEqual(saveStub.lastCall.args[0], element._newPrefs);
+    });
+
+    test('cancel button', () => {
+      const closeStub = sandbox.stub(element.$.prefsOverlay, 'close');
       MockInteractions.tap(element.$$('gr-button:not([primary])'));
+      assert.isTrue(closeStub.called);
     });
   });
 </script>
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 95ff5b7..986aa37 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
@@ -14,20 +14,20 @@
 (function() {
   'use strict';
 
-  var WHOLE_FILE = -1;
+  const WHOLE_FILE = -1;
 
-  var DiffSide = {
+  const DiffSide = {
     LEFT: 'left',
     RIGHT: 'right',
   };
 
-  var DiffGroupType = {
+  const DiffGroupType = {
     ADDED: 'b',
     BOTH: 'ab',
     REMOVED: 'a',
   };
 
-  var DiffHighlights = {
+  const DiffHighlights = {
     ADDED: 'edit_b',
     REMOVED: 'edit_a',
   };
@@ -36,11 +36,11 @@
    * The maximum size for an addition or removal chunk before it is broken down
    * into a series of chunks that are this size at most.
    *
-   * Note: The value of 70 is chosen so that it is larger than the default
+   * Note: The value of 120 is chosen so that it is larger than the default
    * _asyncThreshold of 64, but feel free to tune this constant to your
    * performance needs.
    */
-  var MAX_GROUP_SIZE = 70;
+  const MAX_GROUP_SIZE = 120;
 
   Polymer({
     is: 'gr-diff-processor',
@@ -66,7 +66,7 @@
        */
       keyLocations: {
         type: Object,
-        value: function() { return {left: {}, right: {}}; },
+        value() { return {left: {}, right: {}}; },
       },
 
       /**
@@ -81,18 +81,18 @@
       _isScrolling: Boolean,
     },
 
-    attached: function() {
+    attached() {
       this.listen(window, 'scroll', '_handleWindowScroll');
     },
 
-    detached: function() {
+    detached() {
       this.cancel();
       this.unlisten(window, 'scroll', '_handleWindowScroll');
     },
 
-    _handleWindowScroll: function() {
+    _handleWindowScroll() {
       this._isScrolling = true;
-      this.debounce('resetIsScrolling', function() {
+      this.debounce('resetIsScrolling', () => {
         this._isScrolling = false;
       }, 50);
     },
@@ -103,21 +103,23 @@
      * @return {Promise} A promise that resolves when the diff is completely
      *     processed.
      */
-    process: function(content) {
-      return new Promise(function(resolve) {
-        this.groups = [];
-        this.push('groups', this._makeFileComments());
+    process(content, isImageDiff) {
+      this.groups = [];
+      this.push('groups', this._makeFileComments());
 
-        var state = {
+      // If image diff, only render the file lines.
+      if (isImageDiff) { return Promise.resolve(); }
+
+      return new Promise(resolve => {
+        const state = {
           lineNums: {left: 0, right: 0},
           sectionIndex: 0,
         };
 
         content = this._splitCommonGroupsWithComments(content);
 
-        var currentBatch = 0;
-        var nextStep = function() {
-
+        let currentBatch = 0;
+        const nextStep = () => {
           if (this._isScrolling) {
             this.async(nextStep, 100);
             return;
@@ -130,11 +132,11 @@
           }
 
           // Process the next section and incorporate the result.
-          var result = this._processNext(state, content);
-          result.groups.forEach(function(group) {
+          const result = this._processNext(state, content);
+          for (const group of result.groups) {
             this.push('groups', group);
             currentBatch += group.lines.length;
-          }, this);
+          }
           state.lineNums.left += result.lineDelta.left;
           state.lineNums.right += result.lineDelta.right;
 
@@ -149,13 +151,13 @@
         };
 
         nextStep.call(this);
-      }.bind(this));
+      });
     },
 
     /**
      * Cancel any jobs that are running.
      */
-    cancel: function() {
+    cancel() {
       if (this._nextStepHandle !== undefined) {
         this.cancelAsync(this._nextStepHandle);
         this._nextStepHandle = undefined;
@@ -165,29 +167,29 @@
     /**
      * Process the next section of the diff.
      */
-    _processNext: function(state, content) {
-      var section = content[state.sectionIndex];
+    _processNext(state, content) {
+      const section = content[state.sectionIndex];
 
-      var rows = {
+      const rows = {
         both: section[DiffGroupType.BOTH] || null,
         added: section[DiffGroupType.ADDED] || null,
         removed: section[DiffGroupType.REMOVED] || null,
       };
 
-      var highlights = {
+      const highlights = {
         added: section[DiffHighlights.ADDED] || null,
         removed: section[DiffHighlights.REMOVED] || null,
       };
 
       if (rows.both) { // If it's a shared section.
-        var sectionEnd = null;
+        let sectionEnd = null;
         if (state.sectionIndex === 0) {
           sectionEnd = 'first';
         } else if (state.sectionIndex === content.length - 1) {
           sectionEnd = 'last';
         }
 
-        var sharedGroups = this._sharedGroupsFromRows(
+        const sharedGroups = this._sharedGroupsFromRows(
             rows.both,
             content.length > 1 ? this.context : WHOLE_FILE,
             state.lineNums.left,
@@ -202,8 +204,7 @@
           groups: sharedGroups,
         };
       } else { // Otherwise it's a delta section.
-
-        var deltaGroup = this._deltaGroupFromRows(
+        const deltaGroup = this._deltaGroupFromRows(
             rows.added,
             rows.removed,
             state.lineNums.left,
@@ -232,14 +233,14 @@
      *     'last' and null respectively.
      * @return {Array<GrDiffGroup>}
      */
-    _sharedGroupsFromRows: function(rows, context, startLineNumLeft,
+    _sharedGroupsFromRows(rows, context, startLineNumLeft,
         startLineNumRight, opt_sectionEnd) {
-      var result = [];
-      var lines = [];
-      var line;
+      const result = [];
+      const lines = [];
+      let line;
 
       // Map each row to a GrDiffLine.
-      for (var i = 0; i < rows.length; i++) {
+      for (let i = 0; i < rows.length; i++) {
         line = new GrDiffLine(GrDiffLine.Type.BOTH);
         line.text = rows[i];
         line.beforeNumber = ++startLineNumLeft;
@@ -250,7 +251,7 @@
       // Find the hidden range based on the user's context preference. If this
       // is the first or the last section of the diff, make sure the collapsed
       // part of the section extends to the edge of the file.
-      var hiddenRange = [context, rows.length - context];
+      const hiddenRange = [context, rows.length - context];
       if (opt_sectionEnd === 'first') {
         hiddenRange[0] = 0;
       } else if (opt_sectionEnd === 'last') {
@@ -259,15 +260,15 @@
 
       // If there is a range to hide.
       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]);
+        const linesBeforeCtx = lines.slice(0, hiddenRange[0]);
+        const hiddenLines = lines.slice(hiddenRange[0], hiddenRange[1]);
+        const linesAfterCtx = lines.slice(hiddenRange[1]);
 
         if (linesBeforeCtx.length > 0) {
           result.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesBeforeCtx));
         }
 
-        var ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL);
+        const ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL);
         ctxLine.contextGroup =
             new GrDiffGroup(GrDiffGroup.Type.BOTH, hiddenLines);
         result.push(new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL,
@@ -292,9 +293,9 @@
      * @param {Number} startLineNumRight
      * @return {GrDiffGroup}
      */
-    _deltaGroupFromRows: function(rowsAdded, rowsRemoved, startLineNumLeft,
+    _deltaGroupFromRows(rowsAdded, rowsRemoved, startLineNumLeft,
         startLineNumRight, highlights) {
-      var lines = [];
+      let lines = [];
       if (rowsRemoved) {
         lines = lines.concat(this._deltaLinesFromRows(GrDiffLine.Type.REMOVE,
             rowsRemoved, startLineNumLeft, highlights.removed));
@@ -309,7 +310,7 @@
     /**
      * @return {Array<GrDiffLine>}
      */
-    _deltaLinesFromRows: function(lineType, rows, startLineNum,
+    _deltaLinesFromRows(lineType, rows, startLineNum,
         opt_highlights) {
       // Normalize highlights if they have been passed.
       if (opt_highlights) {
@@ -317,9 +318,9 @@
             opt_highlights);
       }
 
-      var lines = [];
-      var line;
-      for (var i = 0; i < rows.length; i++) {
+      const lines = [];
+      let line;
+      for (let i = 0; i < rows.length; i++) {
         line = new GrDiffLine(lineType);
         line.text = rows[i];
         if (lineType === GrDiffLine.Type.ADD) {
@@ -328,16 +329,15 @@
           line.beforeNumber = ++startLineNum;
         }
         if (opt_highlights) {
-          line.highlights = opt_highlights.filter(
-              function(hl) { return hl.contentIndex === i; });
+          line.highlights = opt_highlights.filter(hl => hl.contentIndex === i);
         }
         lines.push(line);
       }
       return lines;
     },
 
-    _makeFileComments: function() {
-      var line = new GrDiffLine(GrDiffLine.Type.BOTH);
+    _makeFileComments() {
+      const line = new GrDiffLine(GrDiffLine.Type.BOTH);
       line.beforeNumber = GrDiffLine.FILE;
       line.afterNumber = GrDiffLine.FILE;
       return new GrDiffGroup(GrDiffGroup.Type.BOTH, [line]);
@@ -350,30 +350,32 @@
      * @param {Object} content The diff content object.
      * @return {Object} A new diff content object with regions split up.
      */
-    _splitCommonGroupsWithComments: function(content) {
-      var result = [];
-      var leftLineNum = 0;
-      var rightLineNum = 0;
+    _splitCommonGroupsWithComments(content) {
+      const result = [];
+      let leftLineNum = 0;
+      let rightLineNum = 0;
 
       // If the context is set to "whole file", then break down the shared
       // chunks so they can be rendered incrementally. Note: this is not enabled
       // for any other context preference because manipulating the chunks in
       // this way violates assumptions by the context grouper logic.
       if (this.context === -1) {
-        var newContent = [];
-        content.forEach(function(group) {
-          if (group.ab) {
-            newContent.push.apply(newContent, this._breakdownGroup(group));
+        const newContent = [];
+        for (const group of content) {
+          if (group.ab && group.ab.length > MAX_GROUP_SIZE * 2) {
+            // Split large shared groups in two, where the first is the maximum
+            // group size.
+            newContent.push({ab: group.ab.slice(0, MAX_GROUP_SIZE)});
+            newContent.push({ab: group.ab.slice(MAX_GROUP_SIZE)});
           } else {
             newContent.push(group);
           }
-        }.bind(this));
+        }
         content = newContent;
       }
 
       // For each section in the diff.
-      for (var i = 0; i < content.length; i++) {
-
+      for (let i = 0; i < content.length; i++) {
         // If it isn't a common group, append it as-is and update line numbers.
         if (!content[i].ab) {
           if (content[i].a) {
@@ -383,25 +385,24 @@
             rightLineNum += content[i].b.length;
           }
 
-          this._breakdownGroup(content[i]).forEach(function(group) {
+          for (const group of this._breakdownGroup(content[i])) {
             result.push(group);
-          });
+          }
 
           continue;
         }
 
-        var chunk = content[i].ab;
-        var currentChunk = {ab: []};
+        const chunk = content[i].ab;
+        let currentChunk = {ab: []};
 
         // For each line in the common group.
-        for (var j = 0; j < chunk.length; j++) {
+        for (const subChunk of chunk) {
           leftLineNum++;
           rightLineNum++;
 
           // If this line should not be collapsed.
           if (this.keyLocations[DiffSide.LEFT][leftLineNum] ||
               this.keyLocations[DiffSide.RIGHT][rightLineNum]) {
-
             // If any lines have been accumulated into the chunk leading up to
             // this non-collapse line, then add them as a chunk and start a new
             // one.
@@ -411,10 +412,10 @@
             }
 
             // Add the non-collapse line as its own chunk.
-            result.push({ab: [chunk[j]]});
+            result.push({ab: [subChunk]});
           } else {
             // Append the current line to the current chunk.
-            currentChunk.ab.push(chunk[j]);
+            currentChunk.ab.push(subChunk);
           }
         }
 
@@ -444,14 +445,13 @@
      * - endIndex: (optional) Where the highlight should end. If omitted, the
      *   highlight is meant to be a continuation onto the next line.
      */
-    _normalizeIntralineHighlights: function(content, highlights) {
-      var contentIndex = 0;
-      var idx = 0;
-      var normalized = [];
-      for (var i = 0; i < highlights.length; i++) {
-        var line = content[contentIndex] + '\n';
-        var hl = highlights[i];
-        var j = 0;
+    _normalizeIntralineHighlights(content, highlights) {
+      let contentIndex = 0;
+      let idx = 0;
+      const normalized = [];
+      for (const hl of highlights) {
+        let line = content[contentIndex] + '\n';
+        let j = 0;
         while (j < hl[0]) {
           if (idx === line.length) {
             idx = 0;
@@ -461,8 +461,8 @@
           idx++;
           j++;
         }
-        var lineHighlight = {
-          contentIndex: contentIndex,
+        let lineHighlight = {
+          contentIndex,
           startIndex: idx,
         };
 
@@ -473,7 +473,7 @@
             line = content[++contentIndex] + '\n';
             normalized.push(lineHighlight);
             lineHighlight = {
-              contentIndex: contentIndex,
+              contentIndex,
               startIndex: idx,
             };
             continue;
@@ -494,8 +494,8 @@
      * @param {!Object} A raw chunk from a diff response.
      * @return {!Array<!Array<!Object>>}
      */
-    _breakdownGroup: function(group) {
-      var key = null;
+    _breakdownGroup(group) {
+      let key = null;
       if (group.a && !group.b) {
         key = 'a';
       } else if (group.b && !group.a) {
@@ -507,11 +507,11 @@
       if (!key) { return [group]; }
 
       return this._breakdown(group[key], MAX_GROUP_SIZE)
-        .map(function(subgroupLines) {
-          var subGroup = {};
-          subGroup[key] = subgroupLines;
-          return subGroup;
-        });
+          .map(subgroupLines => {
+            const subGroup = {};
+            subGroup[key] = subgroupLines;
+            return subGroup;
+          });
     },
 
     /**
@@ -522,12 +522,12 @@
      * @return {!Array<!Array<T>>}
      * @template T
      */
-    _breakdown: function(array, size) {
+    _breakdown(array, size) {
       if (!array.length) { return []; }
       if (array.length < size) { return [array]; }
 
-      var head = array.slice(0, array.length - size);
-      var tail = array.slice(array.length - size);
+      const head = array.slice(0, array.length - size);
+      const tail = array.slice(array.length - size);
 
       return this._breakdown(head, size).concat([tail]);
     },
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 5429f52..4785351 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
@@ -33,40 +33,40 @@
 </test-fixture>
 
 <script>
-  suite('gr-diff-processor tests', function() {
-    var WHOLE_FILE = -1;
-    var loremIpsum = 'Lorem ipsum dolor sit amet, ei nonumes vituperata ius. ' +
+  suite('gr-diff-processor tests', () => {
+    const WHOLE_FILE = -1;
+    const loremIpsum =
+        'Lorem ipsum dolor sit amet, ei nonumes vituperata ius. ' +
         'Duo  animal omnesque fabellas et. Id has phaedrum dignissim ' +
         'deterruisset, pro ei petentium comprehensam, ut vis solum dicta. ' +
         'Eos cu aliquam labores qualisque, usu postea inermis te, et solum ' +
         'fugit assum per.';
 
-    var element;
-    var sandbox;
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    suite('not logged in', function() {
-
-      setup(function() {
+    suite('not logged in', () => {
+      setup(() => {
         element = fixture('basic');
 
         element.context = 4;
       });
 
-      test('process loaded content', function(done) {
-        var content = [
+      test('process loaded content', done => {
+        const content = [
           {
             ab: [
               '<!DOCTYPE html>',
               '<meta charset="utf-8">',
-            ]
+            ],
           },
           {
             a: [
@@ -82,16 +82,16 @@
               'Leela: This is the only place the ship can’t hear us, so ',
               'everyone pretend to shower.',
               'Fry: Same as every day. Got it.',
-            ]
+            ],
           },
         ];
 
-        element.process(content).then(function() {
-          var groups = element.groups;
+        element.process(content).then(() => {
+          const groups = element.groups;
 
           assert.equal(groups.length, 4);
 
-          var group = groups[0];
+          let group = groups[0];
           assert.equal(group.type, GrDiffGroup.Type.BOTH);
           assert.equal(group.lines.length, 1);
           assert.equal(group.lines[0].text, '');
@@ -144,27 +144,27 @@
         });
       });
 
-      test('insert context groups', function(done) {
-        var content = [
+      test('insert context groups', done => {
+        const content = [
           {ab: []},
           {a: ['all work and no play make andybons a dull boy']},
           {ab: []},
           {b: ['elgoog elgoog elgoog']},
           {ab: []},
         ];
-        for (var i = 0; i < 100; i++) {
+        for (let i = 0; i < 100; i++) {
           content[0].ab.push('all work and no play make jack a dull boy');
           content[4].ab.push('all work and no play make jill a dull girl');
         }
-        for (var i = 0; i < 5; i++) {
+        for (let i = 0; i < 5; i++) {
           content[2].ab.push('no tv and no beer make homer go crazy');
         }
 
-        var context = 10;
+        const context = 10;
         element.context = context;
 
-        element.process(content).then(function() {
-          var groups = element.groups;
+        element.process(content).then(() => {
+          const groups = element.groups;
 
           assert.equal(groups[0].type, GrDiffGroup.Type.BOTH);
           assert.equal(groups[0].lines.length, 1);
@@ -175,15 +175,15 @@
           assert.equal(groups[1].type, GrDiffGroup.Type.CONTEXT_CONTROL);
           assert.instanceOf(groups[1].lines[0].contextGroup, GrDiffGroup);
           assert.equal(groups[1].lines[0].contextGroup.lines.length, 90);
-          groups[1].lines[0].contextGroup.lines.forEach(function(l) {
+          for (const l of groups[1].lines[0].contextGroup.lines) {
             assert.equal(l.text, content[0].ab[0]);
-          });
+          }
 
           assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
           assert.equal(groups[2].lines.length, context);
-          groups[2].lines.forEach(function(l) {
+          for (const l of groups[2].lines) {
             assert.equal(l.text, content[0].ab[0]);
-          });
+          }
 
           assert.equal(groups[3].type, GrDiffGroup.Type.DELTA);
           assert.equal(groups[3].lines.length, 1);
@@ -193,9 +193,9 @@
 
           assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
           assert.equal(groups[4].lines.length, 5);
-          groups[4].lines.forEach(function(l) {
+          for (const l of groups[4].lines) {
             assert.equal(l.text, content[2].ab[0]);
-          });
+          }
 
           assert.equal(groups[5].type, GrDiffGroup.Type.DELTA);
           assert.equal(groups[5].lines.length, 1);
@@ -204,36 +204,36 @@
 
           assert.equal(groups[6].type, GrDiffGroup.Type.BOTH);
           assert.equal(groups[6].lines.length, context);
-          groups[6].lines.forEach(function(l) {
+          for (const l of groups[6].lines) {
             assert.equal(l.text, content[4].ab[0]);
-          });
+          }
 
           assert.equal(groups[7].type, GrDiffGroup.Type.CONTEXT_CONTROL);
           assert.instanceOf(groups[7].lines[0].contextGroup, GrDiffGroup);
           assert.equal(groups[7].lines[0].contextGroup.lines.length, 90);
-          groups[7].lines[0].contextGroup.lines.forEach(function(l) {
+          for (const l of groups[7].lines[0].contextGroup.lines) {
             assert.equal(l.text, content[4].ab[0]);
-          });
+          }
 
           done();
         });
       });
 
-      test('insert context groups', function(done) {
-        var content = [
+      test('insert context groups', done => {
+        const content = [
           {a: ['all work and no play make andybons a dull boy']},
           {ab: []},
           {b: ['elgoog elgoog elgoog']},
         ];
-        for (var i = 0; i < 50; i++) {
+        for (let i = 0; i < 50; i++) {
           content[1].ab.push('no tv and no beer make homer go crazy');
         }
 
-        var context = 10;
+        const context = 10;
         element.context = context;
 
-        element.process(content).then(function() {
-          var groups = element.groups;
+        element.process(content).then(() => {
+          const groups = element.groups;
 
           assert.equal(groups[0].type, GrDiffGroup.Type.BOTH);
           assert.equal(groups[0].lines.length, 1);
@@ -249,22 +249,22 @@
 
           assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
           assert.equal(groups[2].lines.length, context);
-          groups[2].lines.forEach(function(l) {
+          for (const l of groups[2].lines) {
             assert.equal(l.text, content[1].ab[0]);
-          });
+          }
 
           assert.equal(groups[3].type, GrDiffGroup.Type.CONTEXT_CONTROL);
           assert.instanceOf(groups[3].lines[0].contextGroup, GrDiffGroup);
           assert.equal(groups[3].lines[0].contextGroup.lines.length, 30);
-          groups[3].lines[0].contextGroup.lines.forEach(function(l) {
+          for (const l of groups[3].lines[0].contextGroup.lines) {
             assert.equal(l.text, content[1].ab[0]);
-          });
+          }
 
           assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
           assert.equal(groups[4].lines.length, context);
-          groups[4].lines.forEach(function(l) {
+          for (const l of groups[4].lines) {
             assert.equal(l.text, content[1].ab[0]);
-          });
+          }
 
           assert.equal(groups[5].type, GrDiffGroup.Type.DELTA);
           assert.equal(groups[5].lines.length, 1);
@@ -275,14 +275,14 @@
         });
       });
 
-      test('break up common diff chunks', function() {
+      test('break up common diff chunks', () => {
         element.keyLocations = {
           left: {1: true},
           right: {10: true},
         };
-        var lineNums = {left: 0, right: 0};
+        const lineNums = {left: 0, right: 0};
 
-        var content = [
+        const content = [
           {
             ab: [
               'Copyright (C) 2015 The Android Open Source Project',
@@ -300,10 +300,11 @@
               'either express or implied. See the License for the specific ',
               'language governing permissions and limitations under the ' +
                   'License.',
-            ]
-          }
+            ],
+          },
         ];
-        var result = element._splitCommonGroupsWithComments(content, lineNums);
+        const result =
+            element._splitCommonGroupsWithComments(content, lineNums);
         assert.deepEqual(result, [
           {
             ab: ['Copyright (C) 2015 The Android Open Source Project'],
@@ -319,11 +320,11 @@
               'http://www.apache.org/licenses/LICENSE-2.0',
               '',
               'Unless required by applicable law or agreed to in writing, ',
-            ]
+            ],
           },
           {
             ab: [
-                'software distributed under the License is distributed on an '],
+              'software distributed under the License is distributed on an '],
           },
           {
             ab: [
@@ -331,47 +332,50 @@
               'either express or implied. See the License for the specific ',
               'language governing permissions and limitations under the ' +
                   'License.',
-            ]
-          }
+            ],
+          },
         ]);
       });
 
-      test('breaks-down shared chunks w/ whole-file', function() {
-        var lineNums = {left: 0, right: 0};
-        var content = [{
-          ab: _.times(75, function() { return '' + Math.random(); }),
+      test('breaks-down shared chunks w/ whole-file', () => {
+        const size = 120 * 2 + 5;
+        const lineNums = {left: 0, right: 0};
+        const content = [{
+          ab: _.times(size, () => { return `${Math.random()}`; }),
         }];
         element.context = -1;
-        var result = element._splitCommonGroupsWithComments(content, lineNums);
+        const result =
+            element._splitCommonGroupsWithComments(content, lineNums);
         assert.equal(result.length, 2);
-        assert.deepEqual(result[0].ab, content[0].ab.slice(0, 5));
-        assert.deepEqual(result[1].ab, content[0].ab.slice(5));
+        assert.deepEqual(result[0].ab, content[0].ab.slice(0, 120));
+        assert.deepEqual(result[1].ab, content[0].ab.slice(120));
       });
 
-      test('does not break-down shared chunks w/ context', function() {
-        var lineNums = {left: 0, right: 0};
-        var content = [{
-          ab: _.times(75, function() { return '' + Math.random(); }),
+      test('does not break-down shared chunks w/ context', () => {
+        const lineNums = {left: 0, right: 0};
+        const content = [{
+          ab: _.times(75, () => { return `${Math.random()}`; }),
         }];
         element.context = 4;
-        var result = element._splitCommonGroupsWithComments(content, lineNums);
+        const result =
+            element._splitCommonGroupsWithComments(content, lineNums);
         assert.deepEqual(result, content);
       });
 
-      test('intraline normalization', function() {
+      test('intraline normalization', () => {
         // The content and highlights are in the format returned by the Gerrit
         // REST API.
-        var content = [
+        let content = [
           '      <section class="summary">',
           '        <gr-linked-text content="' +
               '[[_computeCurrentRevisionMessage(change)]]"></gr-linked-text>',
           '      </section>',
         ];
-        var highlights = [
-          [31, 34], [42, 26]
+        let highlights = [
+          [31, 34], [42, 26],
         ];
 
-        var results = element._normalizeIntralineHighlights(content,
+        let results = element._normalizeIntralineHighlights(content,
             highlights);
         assert.deepEqual(results, [
           {
@@ -391,7 +395,7 @@
             contentIndex: 2,
             startIndex: 0,
             endIndex: 6,
-          }
+          },
         ]);
 
         content = [
@@ -438,18 +442,18 @@
             contentIndex: 5,
             startIndex: 12,
             endIndex: 41,
-          }
+          },
         ]);
       });
 
-      test('scrolling pauses rendering', function() {
-        var contentRow = {
+      test('scrolling pauses rendering', () => {
+        const contentRow = {
           ab: [
             '<!DOCTYPE html>',
             '<meta charset="utf-8">',
-          ]
+          ],
         };
-        var content = _.times(200, _.constant(contentRow));
+        const content = _.times(200, _.constant(contentRow));
         sandbox.stub(element, 'async');
         element._isScrolling = true;
         element.process(content);
@@ -459,17 +463,34 @@
         assert.equal(element.groups.length, 33);
       });
 
-      suite('gr-diff-processor helpers', function() {
-        var rows;
+      test('image diffs', () => {
+        const contentRow = {
+          ab: [
+            '<!DOCTYPE html>',
+            '<meta charset="utf-8">',
+          ],
+        };
+        const content = _.times(200, _.constant(contentRow));
+        sandbox.stub(element, 'async');
+        element.process(content, true);
+        assert.equal(element.groups.length, 1);
 
-        setup(function() {
+        // Image diffs don't process content, just the 'FILE' line.
+        assert.equal(element.groups[0].lines.length, 1);
+      });
+
+
+      suite('gr-diff-processor helpers', () => {
+        let rows;
+
+        setup(() => {
           rows = loremIpsum.split(' ');
         });
 
-        test('_sharedGroupsFromRows WHOLE_FILE', function() {
-          var context = WHOLE_FILE;
-          var lineNumbers = {left: 10, right: 100};
-          var result = element._sharedGroupsFromRows(
+        test('_sharedGroupsFromRows WHOLE_FILE', () => {
+          const context = WHOLE_FILE;
+          const lineNumbers = {left: 10, right: 100};
+          const result = element._sharedGroupsFromRows(
               rows, context, lineNumbers.left, lineNumbers.right, null);
 
           // Results in one, uncollapsed group with all rows.
@@ -487,11 +508,11 @@
               lineNumbers.right + rows.length);
         });
 
-        test('_sharedGroupsFromRows context', function() {
-          var context = 10;
-          var result = element._sharedGroupsFromRows(
+        test('_sharedGroupsFromRows context', () => {
+          const context = 10;
+          const result = element._sharedGroupsFromRows(
               rows, context, 10, 100, null);
-          var expectedCollapseSize = rows.length - 2 * context;
+          const expectedCollapseSize = rows.length - 2 * context;
 
           assert.equal(result.length, 3, 'Results in three groups');
 
@@ -506,11 +527,11 @@
               expectedCollapseSize);
         });
 
-        test('_sharedGroupsFromRows first', function() {
-          var context = 10;
-          var result = element._sharedGroupsFromRows(
+        test('_sharedGroupsFromRows first', () => {
+          const context = 10;
+          const result = element._sharedGroupsFromRows(
               rows, context, 10, 100, 'first');
-          var expectedCollapseSize = rows.length - context;
+          const expectedCollapseSize = rows.length - context;
 
           assert.equal(result.length, 2, 'Results in two groups');
 
@@ -523,11 +544,11 @@
               expectedCollapseSize);
         });
 
-        test('_sharedGroupsFromRows few-rows', function() {
+        test('_sharedGroupsFromRows few-rows', () => {
           // Only ten rows.
           rows = rows.slice(0, 10);
-          var context = 10;
-          var result = element._sharedGroupsFromRows(
+          const context = 10;
+          const result = element._sharedGroupsFromRows(
               rows, context, 10, 100, 'first');
 
           // Results in one uncollapsed group with all rows.
@@ -535,10 +556,10 @@
           assert.equal(result[0].lines.length, rows.length);
         });
 
-        test('_sharedGroupsFromRows no single line collapse', function() {
+        test('_sharedGroupsFromRows no single line collapse', () => {
           rows = rows.slice(0, 7);
-          var context = 3;
-          var result = element._sharedGroupsFromRows(
+          const context = 3;
+          const result = element._sharedGroupsFromRows(
               rows, context, 10, 100);
 
           // Results in one uncollapsed group with all rows.
@@ -546,9 +567,9 @@
           assert.equal(result[0].lines.length, rows.length);
         });
 
-        test('_deltaLinesFromRows', function() {
-          var startLineNum = 10;
-          var result = element._deltaLinesFromRows(GrDiffLine.Type.ADD, rows,
+        test('_deltaLinesFromRows', () => {
+          const startLineNum = 10;
+          let result = element._deltaLinesFromRows(GrDiffLine.Type.ADD, rows,
               startLineNum);
 
           assert.equal(result.length, rows.length);
@@ -572,54 +593,54 @@
         });
       });
 
-      suite('_breakdown*', function() {
-        test('_breakdownGroup breaks down additions', function() {
+      suite('_breakdown*', () => {
+        test('_breakdownGroup breaks down additions', () => {
           sandbox.spy(element, '_breakdown');
-          var chunk = {b: ['blah', 'blah', 'blah']};
-          var result = element._breakdownGroup(chunk);
+          const chunk = {b: ['blah', 'blah', 'blah']};
+          const result = element._breakdownGroup(chunk);
           assert.deepEqual(result, [chunk]);
           assert.isTrue(element._breakdown.called);
         });
 
-        test('_breakdown common case', function() {
-          var array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
+        test('_breakdown common case', () => {
+          const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
               .split(' ');
-          var size = 3;
+          const size = 3;
 
-          var result = element._breakdown(array, size);
+          const result = element._breakdown(array, size);
 
-          result.forEach(function(subResult) {
+          for (const subResult of result) {
             assert.isAtMost(subResult.length, size);
-          });
-          var flattened = result
-              .reduce(function(a, b) { return a.concat(b); }, []);
+          }
+          const flattened = result
+              .reduce((a, b) => { return a.concat(b); }, []);
           assert.deepEqual(flattened, array);
         });
 
-        test('_breakdown smaller than size', function() {
-          var array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
+        test('_breakdown smaller than size', () => {
+          const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
               .split(' ');
-          var size = 10;
-          var expected = [array];
+          const size = 10;
+          const expected = [array];
 
-          var result = element._breakdown(array, size);
+          const result = element._breakdown(array, size);
 
           assert.deepEqual(result, expected);
         });
 
-        test('_breakdown empty', function() {
-          var array = [];
-          var size = 10;
-          var expected = [];
+        test('_breakdown empty', () => {
+          const array = [];
+          const size = 10;
+          const expected = [];
 
-          var result = element._breakdown(array, size);
+          const result = element._breakdown(array, size);
 
           assert.deepEqual(result, expected);
         });
       });
     });
 
-    test('detaching cancels', function() {
+    test('detaching cancels', () => {
       element = fixture('basic');
       sandbox.stub(element, 'cancel');
       element.detached();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
index fa1aeb2..ecddba2 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
@@ -18,13 +18,13 @@
    * Possible CSS classes indicating the state of selection. Dynamically added/
    * removed based on where the user clicks within the diff.
    */
-  var SelectionClass = {
+  const SelectionClass = {
     COMMENT: 'selected-comment',
     LEFT: 'selected-left',
     RIGHT: 'selected-right',
   };
 
-  var getNewCache = function() { return {left: null, right: null}; };
+  const getNewCache = () => { return {left: null, right: null}; };
 
   Polymer({
     is: 'gr-diff-selection',
@@ -43,11 +43,11 @@
     ],
 
     listeners: {
-      'copy': '_handleCopy',
-      'down': '_handleDown',
+      copy: '_handleCopy',
+      down: '_handleDown',
     },
 
-    attached: function() {
+    attached() {
       this.classList.add(SelectionClass.RIGHT);
     },
 
@@ -59,19 +59,19 @@
       return this._cachedDiffBuilder;
     },
 
-    _diffChanged: function() {
+    _diffChanged() {
       this._linesCache = getNewCache();
     },
 
-    _handleDown: function(e) {
-      var lineEl = this.diffBuilder.getLineElByChild(e.target);
+    _handleDown(e) {
+      const lineEl = this.diffBuilder.getLineElByChild(e.target);
       if (!lineEl) {
         return;
       }
-      var commentSelected =
+      const commentSelected =
           this._elementDescendedFromClass(e.target, 'gr-diff-comment');
-      var side = this.diffBuilder.getSideByLineEl(lineEl);
-      var targetClasses = [];
+      const side = this.diffBuilder.getSideByLineEl(lineEl);
+      const targetClasses = [];
       targetClasses.push(side === 'left' ?
           SelectionClass.LEFT :
           SelectionClass.RIGHT);
@@ -80,23 +80,23 @@
         targetClasses.push(SelectionClass.COMMENT);
       }
       // Remove any selection classes that do not belong.
-      for (var key in SelectionClass) {
+      for (const key in SelectionClass) {
         if (SelectionClass.hasOwnProperty(key)) {
-          var className = SelectionClass[key];
-          if (targetClasses.indexOf(className) === -1) {
+          const className = SelectionClass[key];
+          if (!targetClasses.includes(className)) {
             this.classList.remove(SelectionClass[key]);
           }
         }
       }
       // Add new selection classes iff they are not already present.
-      for (var i = 0; i < targetClasses.length; i++) {
-        if (!this.classList.contains(targetClasses[i])) {
-          this.classList.add(targetClasses[i]);
+      for (const _class of targetClasses) {
+        if (!this.classList.contains(_class)) {
+          this.classList.add(_class);
         }
       }
     },
 
-    _getCopyEventTarget: function(e) {
+    _getCopyEventTarget(e) {
       return Polymer.dom(e).rootTarget;
     },
 
@@ -108,7 +108,7 @@
      * @param {!string} className
      * @return {boolean}
      */
-    _elementDescendedFromClass: function(element, className) {
+    _elementDescendedFromClass(element, className) {
       while (!element.classList.contains(className)) {
         if (!element.parentElement ||
             element === this.diffBuilder.diffElement) {
@@ -119,20 +119,20 @@
       return true;
     },
 
-    _handleCopy: function(e) {
-      var commentSelected = false;
-      var target = this._getCopyEventTarget(e);
+    _handleCopy(e) {
+      let commentSelected = false;
+      const target = this._getCopyEventTarget(e);
       if (target.type === 'textarea') { return; }
       if (!this._elementDescendedFromClass(target, 'diff-row')) { return; }
       if (this.classList.contains(SelectionClass.COMMENT)) {
         commentSelected = true;
       }
-      var lineEl = this.diffBuilder.getLineElByChild(target);
+      const lineEl = this.diffBuilder.getLineElByChild(target);
       if (!lineEl) {
         return;
       }
-      var side = this.diffBuilder.getSideByLineEl(lineEl);
-      var text = this._getSelectedText(side, commentSelected);
+      const side = this.diffBuilder.getSideByLineEl(lineEl);
+      const text = this._getSelectedText(side, commentSelected);
       if (text) {
         e.clipboardData.setData('Text', text);
         e.preventDefault();
@@ -148,19 +148,20 @@
      * @param {boolean} Whether or not a comment is selected.
      * @return {string} The selected text.
      */
-    _getSelectedText: function(side, commentSelected) {
-      var sel = window.getSelection();
+    _getSelectedText(side, commentSelected) {
+      const sel = window.getSelection();
       if (sel.rangeCount != 1) {
         return; // No multi-select support yet.
       }
       if (commentSelected) {
         return this._getCommentLines(sel, side);
       }
-      var range = GrRangeNormalizer.normalize(sel.getRangeAt(0));
-      var startLineEl = this.diffBuilder.getLineElByChild(range.startContainer);
-      var endLineEl = this.diffBuilder.getLineElByChild(range.endContainer);
-      var startLineNum = parseInt(startLineEl.getAttribute('data-value'), 10);
-      var endLineNum = parseInt(endLineEl.getAttribute('data-value'), 10);
+      const range = GrRangeNormalizer.normalize(sel.getRangeAt(0));
+      const startLineEl =
+          this.diffBuilder.getLineElByChild(range.startContainer);
+      const endLineEl = this.diffBuilder.getLineElByChild(range.endContainer);
+      const startLineNum = parseInt(startLineEl.getAttribute('data-value'), 10);
+      const endLineNum = parseInt(endLineEl.getAttribute('data-value'), 10);
 
       return this._getRangeFromDiff(startLineNum, range.startOffset, endLineNum,
           range.endOffset, side);
@@ -176,9 +177,9 @@
      * @param {!string} side The side that is currently selected.
      * @return {string} The selected diff text.
      */
-    _getRangeFromDiff: function(startLineNum, startOffset, endLineNum,
-        endOffset, side) {
-      var lines = this._getDiffLines(side).slice(startLineNum - 1, endLineNum);
+    _getRangeFromDiff(startLineNum, startOffset, endLineNum, endOffset, side) {
+      const lines =
+          this._getDiffLines(side).slice(startLineNum - 1, endLineNum);
       if (lines.length) {
         lines[lines.length - 1] = lines[lines.length - 1]
             .substring(0, endOffset);
@@ -193,17 +194,13 @@
      * @param {!string} side The side that is currently selected.
      * @return {Array.string} An array of strings indexed by line number.
      */
-    _getDiffLines: function(side) {
+    _getDiffLines(side) {
       if (this._linesCache[side]) {
         return this._linesCache[side];
       }
-      var lines = [];
-      var chunk;
-      var key = side === 'left' ? 'a' : 'b';
-      for (var chunkIndex = 0;
-          chunkIndex < this.diff.content.length;
-          chunkIndex++) {
-        chunk = this.diff.content[chunkIndex];
+      let lines = [];
+      const key = side === 'left' ? 'a' : 'b';
+      for (const chunk of this.diff.content) {
         if (chunk.ab) {
           lines = lines.concat(chunk.ab);
         } else if (chunk[key]) {
@@ -222,16 +219,16 @@
      * @param {!string} side The side that is currently selected.
      * @return {string} The selected comment text.
      */
-    _getCommentLines: function(sel, side) {
-      var range = GrRangeNormalizer.normalize(sel.getRangeAt(0));
-      var content = [];
+    _getCommentLines(sel, side) {
+      const range = GrRangeNormalizer.normalize(sel.getRangeAt(0));
+      const content = [];
       // Query the diffElement for comments.
-      var messages = this.diffBuilder.diffElement.querySelectorAll(
-          '.side-by-side [data-side="' + side +
-          '"] .message *, .unified .message *');
+      const messages = this.diffBuilder.diffElement.querySelectorAll(
+          `.side-by-side [data-side="${side
+          }"] .message *, .unified .message *`);
 
-      for (var i = 0; i < messages.length; i++) {
-        var el = messages[i];
+      for (let i = 0; i < messages.length; i++) {
+        const el = messages[i];
         // Check if the comment element exists inside the selection.
         if (sel.containsNode(el, true)) {
           // Padded elements require newlines for accurate spacing.
@@ -262,10 +259,10 @@
      * @param {Range} range The normalized selection range.
      * @return {string} The text within the selection.
      */
-    _getTextContentForRange: function(domNode, sel, range) {
+    _getTextContentForRange(domNode, sel, range) {
       if (!sel.containsNode(domNode, true)) { return ''; }
 
-      var text = '';
+      let text = '';
       if (domNode instanceof Text) {
         text = domNode.textContent;
         if (domNode === range.endContainer) {
@@ -275,9 +272,8 @@
           text = text.substring(range.startOffset);
         }
       } else {
-        for (var i = 0; i < domNode.childNodes.length; i++) {
-          text += this._getTextContentForRange(domNode.childNodes[i],
-              sel, range);
+        for (const childNode of domNode.childNodes) {
+          text += this._getTextContentForRange(childNode, sel, range);
         }
       }
       return text;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
index 3eeba90..39555b4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
@@ -101,13 +101,13 @@
 </test-fixture>
 
 <script>
-  suite('gr-diff-selection', function() {
-    var element;
-    var sandbox;
+  suite('gr-diff-selection', () => {
+    let element;
+    let sandbox;
 
-    var emulateCopyOn = function(target) {
-      var fakeEvent = {
-        target: target,
+    const emulateCopyOn = function(target) {
+      const fakeEvent = {
+        target,
         preventDefault: sandbox.stub(),
         clipboardData: {
           setData: sandbox.stub(),
@@ -118,7 +118,7 @@
       return fakeEvent;
     };
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
       sandbox.stub(element, '_getCopyEventTarget');
@@ -145,11 +145,11 @@
       };
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('applies selected-left on left side click', function() {
+    test('applies selected-left on left side click', () => {
       element.classList.add('selected-right');
       element._cachedDiffBuilder.getSideByLineEl.returns('left');
       MockInteractions.down(element);
@@ -160,7 +160,7 @@
           'removes selected-right');
     });
 
-    test('applies selected-right on right side click', function() {
+    test('applies selected-right on right side click', () => {
       element.classList.add('selected-left');
       element._cachedDiffBuilder.getSideByLineEl.returns('right');
       MockInteractions.down(element);
@@ -170,41 +170,41 @@
           element.classList.contains('selected-left'), 'removes selected-left');
     });
 
-    test('ignores copy for non-content Element', function() {
+    test('ignores copy for non-content Element', () => {
       sandbox.stub(element, '_getSelectedText');
       emulateCopyOn(element.querySelector('.not-diff-row'));
       assert.isFalse(element._getSelectedText.called);
     });
 
-    test('asks for text for left side Elements', function() {
+    test('asks for text for left side Elements', () => {
       element._cachedDiffBuilder.getSideByLineEl.returns('left');
       sandbox.stub(element, '_getSelectedText');
       emulateCopyOn(element.querySelector('div.contentText'));
       assert.deepEqual(['left', false], element._getSelectedText.lastCall.args);
     });
 
-    test('reacts to copy for content Elements', function() {
+    test('reacts to copy for content Elements', () => {
       sandbox.stub(element, '_getSelectedText');
       emulateCopyOn(element.querySelector('div.contentText'));
       assert.isTrue(element._getSelectedText.called);
     });
 
-    test('copy event is prevented for content Elements', function() {
+    test('copy event is prevented for content Elements', () => {
       sandbox.stub(element, '_getSelectedText');
       element._cachedDiffBuilder.getSideByLineEl.returns('left');
       element._getSelectedText.returns('test');
-      var event = emulateCopyOn(element.querySelector('div.contentText'));
+      const event = emulateCopyOn(element.querySelector('div.contentText'));
       assert.isTrue(event.preventDefault.called);
     });
 
-    test('inserts text into clipboard on copy', function() {
+    test('inserts text into clipboard on copy', () => {
       sandbox.stub(element, '_getSelectedText').returns('the text');
-      var event = emulateCopyOn(element.querySelector('div.contentText'));
+      const event = emulateCopyOn(element.querySelector('div.contentText'));
       assert.deepEqual(
           ['Text', 'the text'], event.clipboardData.setData.lastCall.args);
     });
 
-    test('copies content correctly', function() {
+    test('copies content correctly', () => {
       // Fetch the line number.
       element._cachedDiffBuilder.getLineElByChild = function(child) {
         while (!child.classList.contains('content') && child.parentElement) {
@@ -216,9 +216,9 @@
       element.classList.add('selected-left');
       element.classList.remove('selected-right');
 
-      var selection = window.getSelection();
+      const selection = window.getSelection();
       selection.removeAllRanges();
-      var range = document.createRange();
+      const range = document.createRange();
       range.setStart(element.querySelector('div.contentText').firstChild, 3);
       range.setEnd(
           element.querySelectorAll('div.contentText')[4].firstChild, 2);
@@ -226,13 +226,13 @@
       assert.equal(element._getSelectedText('left'), 'ba\nzin\nga');
     });
 
-    test('copies comments', function() {
+    test('copies comments', () => {
       element.classList.add('selected-left');
       element.classList.add('selected-comment');
       element.classList.remove('selected-right');
-      var selection = window.getSelection();
+      const selection = window.getSelection();
       selection.removeAllRanges();
-      var range = document.createRange();
+      const range = document.createRange();
       range.setStart(
           element.querySelector('.gr-formatted-text *').firstChild, 3);
       range.setEnd(
@@ -242,14 +242,14 @@
           element._getSelectedText('left', true));
     });
 
-    test('respects astral chars in comments', function() {
+    test('respects astral chars in comments', () => {
       element.classList.add('selected-left');
       element.classList.add('selected-comment');
       element.classList.remove('selected-right');
-      var selection = window.getSelection();
+      const selection = window.getSelection();
       selection.removeAllRanges();
-      var range = document.createRange();
-      var nodes = element.querySelectorAll('.gr-formatted-text *');
+      const range = document.createRange();
+      const nodes = element.querySelectorAll('.gr-formatted-text *');
       range.setStart(nodes[2].childNodes[2], 13);
       range.setEnd(nodes[2].childNodes[2], 23);
       selection.addRange(range);
@@ -257,15 +257,15 @@
           element._getSelectedText('left', true));
     });
 
-    test('defers to default behavior for textarea', function() {
+    test('defers to default behavior for textarea', () => {
       element.classList.add('selected-left');
       element.classList.remove('selected-right');
-      var selectedTextSpy = sandbox.spy(element, '_getSelectedText');
+      const selectedTextSpy = sandbox.spy(element, '_getSelectedText');
       emulateCopyOn(element.querySelector('textarea'));
       assert.isFalse(selectedTextSpy.called);
     });
 
-    test('regression test for 4794', function() {
+    test('regression test for 4794', () => {
       element._cachedDiffBuilder.getLineElByChild = function(child) {
         while (!child.classList.contains('content') && child.parentElement) {
           child = child.parentElement;
@@ -276,9 +276,9 @@
       element.classList.add('selected-right');
       element.classList.remove('selected-left');
 
-      var selection = window.getSelection();
+      const selection = window.getSelection();
       selection.removeAllRanges();
-      var range = document.createRange();
+      const range = document.createRange();
       range.setStart(
           element.querySelectorAll('div.contentText')[1].firstChild, 4);
       range.setEnd(
@@ -287,12 +287,12 @@
       assert.equal(element._getSelectedText('right'), ' other');
     });
 
-    suite('_getTextContentForRange', function() {
-      var selection;
-      var range;
-      var nodes;
+    suite('_getTextContentForRange', () => {
+      let selection;
+      let range;
+      let nodes;
 
-      setup(function() {
+      setup(() => {
         element.classList.add('selected-left');
         element.classList.add('selected-comment');
         element.classList.remove('selected-right');
@@ -302,7 +302,7 @@
         nodes = element.querySelectorAll('.gr-formatted-text *');
       });
 
-      test('multi level element contained in range', function() {
+      test('multi level element contained in range', () => {
         range.setStart(nodes[2].childNodes[0], 1);
         range.setEnd(nodes[2].childNodes[2], 7);
         selection.addRange(range);
@@ -311,7 +311,7 @@
       });
 
 
-      test('multi level element as startContainer of range', function() {
+      test('multi level element as startContainer of range', () => {
         range.setStart(nodes[2].childNodes[1], 0);
         range.setEnd(nodes[2].childNodes[2], 7);
         selection.addRange(range);
@@ -319,7 +319,7 @@
             'a differ');
       });
 
-      test('startContainer === endContainer', function() {
+      test('startContainer === endContainer', () => {
         range.setStart(nodes[0].firstChild, 2);
         range.setEnd(nodes[0].firstChild, 12);
         selection.addRange(range);
@@ -328,7 +328,7 @@
       });
     });
 
-    test('cache is reset when diff changes', function() {
+    test('cache is reset when diff changes', () => {
       element._linesCache = {left: 'test', right: 'test'};
       element.diff = {};
       flushAsynchronousOperations();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
index 1204170..76d2a69 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
@@ -21,7 +21,6 @@
 <link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-select/gr-select.html">
 <link rel="import" href="../gr-diff/gr-diff.html">
@@ -265,7 +264,8 @@
             <option value="SIDE_BY_SIDE">Side By Side</option>
             <option value="UNIFIED_DIFF">Unified</option>
           </select>
-          <span hidden$="[[_computePrefsButtonHidden(_prefs, _loggedIn)]]">
+          <span id="diffPrefsContainer"
+              hidden$="[[_computePrefsButtonHidden(_prefs, _loggedIn)]]" hidden>
             <span class="preferences desktop">
               <span
                   hidden$="[[_computeModeSelectHidden(_isImageDiff)]]">/</span>
@@ -276,26 +276,20 @@
           </span>
         </div>
       </div>
-      <gr-overlay id="prefsOverlay" with-backdrop>
-        <gr-diff-preferences
-            id="diffPreferences"
-            prefs="{{_prefs}}"
-            local-prefs="{{_localPrefs}}"
-            on-save="_handlePrefsSave"
-            on-cancel="_handlePrefsCancel"></gr-diff-preferences>
-      </gr-overlay>
+      <gr-diff-preferences
+          id="diffPreferences"
+          prefs="{{_prefs}}"
+          local-prefs="{{_localPrefs}}"></gr-diff-preferences>
       <div class="fileNav mobile">
         <a class="mobileNavLink"
-           href$="[[_computeNavLinkURL(_path, _fileList, -1, 1)]]"><</a>
+           href$="[[_computeNavLinkURL(_path, _fileList, -1, 1)]]">&lt;</a>
         <div class="fullFileName mobile">[[_computeFileDisplayName(_path)]]
         </div>
         <a class="mobileNavLink"
-            href$="[[_computeNavLinkURL(_path, _fileList, 1, 1)]]">></a>
+            href$="[[_computeNavLinkURL(_path, _fileList, 1, 1)]]">&gt;</a>
       </div>
       <gr-diff
           id="diff"
-          project="[[_change.project]]"
-          commit="[[_change.current_revision]]"
           is-image-diff="{{_isImageDiff}}"
           files-weblinks="{{_filesWeblinks}}"
           change-num="[[_changeNum]]"
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 d1ac842..95e813a 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
@@ -14,17 +14,17 @@
 (function() {
   'use strict';
 
-  var COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
-  var MERGE_LIST_PATH = '/MERGE_LIST';
+  const COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
+  const MERGE_LIST_PATH = '/MERGE_LIST';
 
-  var COMMENT_SAVE = 'Try again when all comments have saved.';
+  const COMMENT_SAVE = 'Try again when all comments have saved.';
 
-  var DiffSides = {
+  const DiffSides = {
     LEFT: 'left',
     RIGHT: 'right',
   };
 
-  var HASH_PATTERN = /^[ab]?\d+$/;
+  const HASH_PATTERN = /^[ab]?\d+$/;
 
   Polymer({
     is: 'gr-diff-view',
@@ -51,12 +51,12 @@
       },
       keyEventTarget: {
         type: Object,
-        value: function() { return document.body; },
+        value() { return document.body; },
       },
       changeViewState: {
         type: Object,
         notify: true,
-        value: function() { return {}; },
+        value() { return {}; },
       },
 
       _patchRange: Object,
@@ -65,7 +65,7 @@
       _diff: Object,
       _fileList: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       _path: {
         type: String,
@@ -134,18 +134,18 @@
       ',': '_handleCommaKey',
     },
 
-    attached: function() {
-      this._getLoggedIn().then(function(loggedIn) {
+    attached() {
+      this._getLoggedIn().then(loggedIn => {
         this._loggedIn = loggedIn;
         if (loggedIn) {
           this._setReviewed(true);
         }
-      }.bind(this));
+      });
       if (this.changeViewState.diffMode === null) {
         // If screen size is small, always default to unified view.
-        this.$.restAPI.getPreferences().then(function(prefs) {
+        this.$.restAPI.getPreferences().then(prefs => {
           this.set('changeViewState.diffMode', prefs.default_diff_view);
-        }.bind(this));
+        });
       }
 
       if (this._path) {
@@ -156,63 +156,63 @@
       this.$.cursor.push('diffs', this.$.diff);
     },
 
-    _getLoggedIn: function() {
+    _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
     },
 
-    _getProjectConfig: function(project) {
+    _getProjectConfig(project) {
       return this.$.restAPI.getProjectConfig(project).then(
-          function(config) {
+          config => {
             this._projectConfig = config;
-          }.bind(this));
+          });
     },
 
-    _getChangeDetail: function(changeNum) {
+    _getChangeDetail(changeNum) {
       return this.$.restAPI.getDiffChangeDetail(changeNum).then(
-          function(change) {
+          change => {
             this._change = change;
-          }.bind(this));
+          });
     },
 
-    _getFiles: function(changeNum, patchRangeRecord) {
-      var patchRange = patchRangeRecord.base;
+    _getFiles(changeNum, patchRangeRecord) {
+      const patchRange = patchRangeRecord.base;
       return this.$.restAPI.getChangeFilePathsAsSpeciallySortedArray(
-          changeNum, patchRange).then(function(files) {
+          changeNum, patchRange).then(files => {
             this._fileList = files;
-          }.bind(this));
+          });
     },
 
-    _getDiffPreferences: function() {
+    _getDiffPreferences() {
       return this.$.restAPI.getDiffPreferences();
     },
 
-    _getPreferences: function() {
+    _getPreferences() {
       return this.$.restAPI.getPreferences();
     },
 
-    _getWindowWidth: function() {
+    _getWindowWidth() {
       return window.innerWidth;
     },
 
-    _handleReviewedChange: function(e) {
+    _handleReviewedChange(e) {
       this._setReviewed(Polymer.dom(e).rootTarget.checked);
     },
 
-    _setReviewed: function(reviewed) {
+    _setReviewed(reviewed) {
       this.$.reviewed.checked = reviewed;
-      this._saveReviewedState(reviewed).catch(function(err) {
+      this._saveReviewedState(reviewed).catch(err => {
         alert('Couldn’t change file review status. Check the console ' +
             'and contact the PolyGerrit team for assistance.');
         throw err;
-      }.bind(this));
+      });
     },
 
-    _saveReviewedState: function(reviewed) {
+    _saveReviewedState(reviewed) {
       return this.$.restAPI.saveFileReviewed(this._changeNum,
           this._patchRange.patchNum, this._path, reviewed);
     },
 
-    _handleEscKey: function(e) {
+    _handleEscKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -220,21 +220,21 @@
       this.$.diff.displayLine = false;
     },
 
-    _handleShiftLeftKey: function(e) {
+    _handleShiftLeftKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
       this.$.cursor.moveLeft();
     },
 
-    _handleShiftRightKey: function(e) {
+    _handleShiftRightKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
       this.$.cursor.moveRight();
     },
 
-    _handleUpKey: function(e) {
+    _handleUpKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       if (e.detail.keyboardEvent.shiftKey &&
           e.detail.keyboardEvent.keyCode === 75) { // 'K'
@@ -248,7 +248,7 @@
       this.$.cursor.moveUp();
     },
 
-    _handleDownKey: function(e) {
+    _handleDownKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       if (e.detail.keyboardEvent.shiftKey &&
           e.detail.keyboardEvent.keyCode === 74) { // 'J'
@@ -262,49 +262,51 @@
       this.$.cursor.moveDown();
     },
 
-    _moveToPreviousFileWithComment: function() {
+    _moveToPreviousFileWithComment() {
       if (this._commentSkips && this._commentSkips.previous) {
         page.show(this._getDiffURL(this._changeNum, this._patchRange,
             this._commentSkips.previous));
       }
     },
 
-    _moveToNextFileWithComment: function() {
+    _moveToNextFileWithComment() {
       if (this._commentSkips && this._commentSkips.next) {
         page.show(this._getDiffURL(this._changeNum, this._patchRange,
             this._commentSkips.next));
       }
     },
 
-    _handleCKey: function(e) {
+    _handleCKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       if (this.$.diff.isRangeSelected()) { return; }
       if (this.modifierPressed(e)) { return; }
 
       e.preventDefault();
-      var line = this.$.cursor.getTargetLineElement();
+      const line = this.$.cursor.getTargetLineElement();
       if (line) {
         this.$.diff.addDraftAtLine(line);
       }
     },
 
-    _handleLeftBracketKey: function(e) {
+    _handleLeftBracketKey(e) {
+      // Check for meta key to avoid overriding native chrome shortcut.
       if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
+          this.getKeyboardEvent(e).metaKey) { return; }
 
       e.preventDefault();
       this._navToFile(this._path, this._fileList, -1);
     },
 
-    _handleRightBracketKey: function(e) {
+    _handleRightBracketKey(e) {
+      // Check for meta key to avoid overriding native chrome shortcut.
       if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
+          this.getKeyboardEvent(e).metaKey) { return; }
 
       e.preventDefault();
       this._navToFile(this._path, this._fileList, 1);
     },
 
-    _handleNKey: function(e) {
+    _handleNKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
@@ -316,7 +318,7 @@
       }
     },
 
-    _handlePKey: function(e) {
+    _handlePKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
@@ -328,7 +330,7 @@
       }
     },
 
-    _handleAKey: function(e) {
+    _handleAKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       if (e.detail.keyboardEvent.shiftKey) { // Hide left diff.
@@ -351,7 +353,7 @@
       this._navToChangeView();
     },
 
-    _handleUKey: function(e) {
+    _handleUKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -359,15 +361,15 @@
       this._navToChangeView();
     },
 
-    _handleCommaKey: function(e) {
+    _handleCommaKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
       e.preventDefault();
-      this._openPrefs();
+      this.$.diffPreferences.open();
     },
 
-    _navToChangeView: function() {
+    _navToChangeView() {
       if (!this._changeNum || !this._patchRange.patchNum) { return; }
 
       page.show(this._getChangePath(
@@ -376,29 +378,20 @@
           this._change && this._change.revisions));
     },
 
-    _computeUpURL: function(changeNum, patchRange, change, changeRevisions) {
+    _computeUpURL(changeNum, patchRange, change, changeRevisions) {
       return this._getChangePath(
           changeNum,
           patchRange,
           change && changeRevisions);
     },
 
-    _navToFile: function(path, fileList, direction) {
-      var url = this._computeNavLinkURL(path, fileList, direction);
+    _navToFile(path, fileList, direction) {
+      const url = this._computeNavLinkURL(path, fileList, direction);
       if (!url) { return; }
 
       page.show(this._computeNavLinkURL(path, fileList, direction));
     },
 
-    _openPrefs: function() {
-      this.$.prefsOverlay.open().then(function() {
-        var diffPreferences = this.$.diffPreferences;
-        var focusStops = diffPreferences.getFocusStops();
-        this.$.prefsOverlay.setFocusStops(focusStops);
-        this.$.diffPreferences.resetFocus();
-      }.bind(this));
-    },
-
     /**
      * @param {?string} path The path of the current file being shown.
      * @param {Array.<string>} fileList The list of files in this change and
@@ -410,12 +403,14 @@
      * @return {?string} The next URL when proceeding in the specified
      *     direction.
      */
-    _computeNavLinkURL: function(path, fileList, direction, opt_noUp) {
+    _computeNavLinkURL(path, fileList, direction, opt_noUp) {
       if (!path || fileList.length === 0) { return null; }
 
-      var idx = fileList.indexOf(path);
+      let idx = fileList.indexOf(path);
       if (idx === -1) {
-        var file = direction > 0 ? fileList[0] : fileList[fileList.length - 1];
+        const file = direction > 0 ?
+            fileList[0] :
+            fileList[fileList.length - 1];
         return this._getDiffURL(this._changeNum, this._patchRange, file);
       }
 
@@ -432,7 +427,7 @@
       return this._getDiffURL(this._changeNum, this._patchRange, fileList[idx]);
     },
 
-    _paramsChanged: function(value) {
+    _paramsChanged(value) {
       if (value.view != this.tagName.toLowerCase()) { return; }
 
       this._loadHash(location.hash);
@@ -454,33 +449,33 @@
         return;
       }
 
-      var promises = [];
+      const promises = [];
 
       this._localPrefs = this.$.storage.getPreferences();
-      promises.push(this._getDiffPreferences().then(function(prefs) {
+      promises.push(this._getDiffPreferences().then(prefs => {
         this._prefs = prefs;
-      }.bind(this)));
+      }));
 
-      promises.push(this._getPreferences().then(function(prefs) {
+      promises.push(this._getPreferences().then(prefs => {
         this._userPrefs = prefs;
-      }.bind(this)));
+      }));
 
       promises.push(this._getChangeDetail(this._changeNum));
 
-      Promise.all(promises).then(function() {
+      Promise.all(promises).then(() => {
         this._loading = false;
         this.$.diff.reload();
-      }.bind(this));
+      });
 
-      this._loadCommentMap().then(function(commentMap) {
+      this._loadCommentMap().then(commentMap => {
         this._commentMap = commentMap;
-      }.bind(this));
+      });
     },
 
     /**
      * If the URL hash is a diff address then configure the diff cursor.
      */
-    _loadHash: function(hash) {
+    _loadHash(hash) {
       hash = hash.replace(/^#/, '');
       if (!HASH_PATTERN.test(hash)) { return; }
       if (hash[0] === 'a' || hash[0] === 'b') {
@@ -492,7 +487,7 @@
       this.$.cursor.initialLineNumber = parseInt(hash, 10);
     },
 
-    _pathChanged: function(path) {
+    _pathChanged(path) {
       if (this._fileList.length == 0) { return; }
 
       this.set('changeViewState.selectedFileIndex',
@@ -503,17 +498,17 @@
       }
     },
 
-    _getDiffURL: function(changeNum, patchRange, path) {
+    _getDiffURL(changeNum, patchRange, path) {
       return this.getBaseUrl() + '/c/' + changeNum + '/' +
           this._patchRangeStr(patchRange) + '/' + this.encodeURL(path, true);
     },
 
-    _computeDiffURL: function(changeNum, patchRangeRecord, path) {
+    _computeDiffURL(changeNum, patchRangeRecord, path) {
       return this._getDiffURL(changeNum, patchRangeRecord.base, path);
     },
 
-    _patchRangeStr: function(patchRange) {
-      var patchStr = patchRange.patchNum;
+    _patchRangeStr(patchRange) {
+      let patchStr = patchRange.patchNum;
       if (patchRange.basePatchNum != null &&
           patchRange.basePatchNum != 'PARENT') {
         patchStr = patchRange.basePatchNum + '..' + patchRange.patchNum;
@@ -521,25 +516,25 @@
       return patchStr;
     },
 
-    _computeAvailablePatches: function(revisions) {
-      var patchNums = [];
-      for (var rev in revisions) {
-        patchNums.push(revisions[rev]._number);
+    _computeAvailablePatches(revisions) {
+      const patchNums = [];
+      for (const rev of Object.values(revisions)) {
+        patchNums.push(rev._number);
       }
-      return patchNums.sort(function(a, b) { return a - b; });
+      return patchNums.sort((a, b) => { return a - b; });
     },
 
-    _getChangePath: function(changeNum, patchRange, revisions) {
-      var base = this.getBaseUrl() + '/c/' + changeNum + '/';
+    _getChangePath(changeNum, patchRange, revisions) {
+      const base = this.getBaseUrl() + '/c/' + changeNum + '/';
 
       // The change may not have loaded yet, making revisions unavailable.
       if (!revisions) {
         return base + this._patchRangeStr(patchRange);
       }
 
-      var latestPatchNum = -1;
-      for (var rev in revisions) {
-        latestPatchNum = Math.max(latestPatchNum, revisions[rev]._number);
+      let latestPatchNum = -1;
+      for (const rev of Object.values(revisions)) {
+        latestPatchNum = Math.max(latestPatchNum, rev._number);
       }
       if (patchRange.basePatchNum !== 'PARENT' ||
           parseInt(patchRange.patchNum, 10) !== latestPatchNum) {
@@ -549,11 +544,11 @@
       return base;
     },
 
-    _computeChangePath: function(changeNum, patchRangeRecord, revisions) {
+    _computeChangePath(changeNum, patchRangeRecord, revisions) {
       return this._getChangePath(changeNum, patchRangeRecord.base, revisions);
     },
 
-    _computeFileDisplayName: function(path) {
+    _computeFileDisplayName(path) {
       if (path === COMMIT_MESSAGE_PATH) {
         return 'Commit message';
       } else if (path === MERGE_LIST_PATH) {
@@ -562,20 +557,20 @@
       return path;
     },
 
-    _computeTruncatedFileDisplayName: function(path) {
+    _computeTruncatedFileDisplayName(path) {
       return util.truncatePath(this._computeFileDisplayName(path));
     },
 
-    _computeFileSelected: function(path, currentPath) {
+    _computeFileSelected(path, currentPath) {
       return path == currentPath;
     },
 
-    _computePrefsButtonHidden: function(prefs, loggedIn) {
+    _computePrefsButtonHidden(prefs, loggedIn) {
       return !loggedIn || !prefs;
     },
 
-    _computeKeyNav: function(path, selectedPath, fileList) {
-      var selectedIndex = fileList.indexOf(selectedPath);
+    _computeKeyNav(path, selectedPath, fileList) {
+      const selectedIndex = fileList.indexOf(selectedPath);
       if (fileList.indexOf(path) == selectedIndex - 1) {
         return '[';
       }
@@ -585,46 +580,41 @@
       return '';
     },
 
-    _handleFileTap: function(e) {
-      this.$.dropdown.close();
+    _handleFileTap(e) {
+      // async is needed so that that the click event is fired before the
+      // dropdown closes (This was a bug for touch devices).
+      this.async(() => {
+        this.$.dropdown.close();
+      }, 1);
     },
 
-    _handleMobileSelectChange: function(e) {
-      var path = Polymer.dom(e).rootTarget.value;
+    _handleMobileSelectChange(e) {
+      const path = Polymer.dom(e).rootTarget.value;
       page.show(this._getDiffURL(this._changeNum, this._patchRange, path));
     },
 
-    _showDropdownTapHandler: function(e) {
+    _showDropdownTapHandler(e) {
       this.$.dropdown.open();
     },
 
-    _handlePrefsTap: function(e) {
+    _handlePrefsTap(e) {
       e.preventDefault();
-      this._openPrefs();
+      this.$.diffPreferences.open();
     },
 
-    _handlePrefsSave: function(e) {
+    _handlePrefsSave(e) {
       e.stopPropagation();
-      var el = Polymer.dom(e).rootTarget;
+      const el = Polymer.dom(e).rootTarget;
       el.disabled = true;
       this.$.storage.savePreferences(this._localPrefs);
-      this._saveDiffPreferences().then(function(response) {
+      this._saveDiffPreferences().then(response => {
         el.disabled = false;
         if (!response.ok) { return response; }
 
         this.$.prefsOverlay.close();
-      }.bind(this)).catch(function(err) {
+      }).catch(err => {
         el.disabled = false;
-      }.bind(this));
-    },
-
-    _saveDiffPreferences: function() {
-      return this.$.restAPI.saveDiffPreferences(this._prefs);
-    },
-
-    _handlePrefsCancel: function(e) {
-      e.stopPropagation();
-      this.$.prefsOverlay.close();
+      });
     },
 
     /**
@@ -641,7 +631,7 @@
      *
      * @return {String}
      */
-    _getDiffViewMode: function() {
+    _getDiffViewMode() {
       if (this.changeViewState.diffMode) {
         return this.changeViewState.diffMode;
       } else if (this._userPrefs) {
@@ -652,17 +642,17 @@
       }
     },
 
-    _computeModeSelectHidden: function() {
+    _computeModeSelectHidden() {
       return this._isImageDiff;
     },
 
-    _onLineSelected: function(e, detail) {
+    _onLineSelected(e, detail) {
       this.$.cursor.moveToLineNumber(detail.number, detail.side);
       history.replaceState(null, null, '#' + this.$.cursor.getAddress());
     },
 
-    _computeDownloadLink: function(changeNum, patchRange, path) {
-      var url = this.changeBaseURL(changeNum, patchRange.patchNum);
+    _computeDownloadLink(changeNum, patchRange, path) {
+      let url = this.changeBaseURL(changeNum, patchRange.patchNum);
       url += '/patch?zip&path=' + encodeURIComponent(path);
       return url;
     },
@@ -673,9 +663,9 @@
      * current patch range.
      * @return {Promise} A promise that yields a comment map object.
      */
-    _loadCommentMap: function() {
-      function filterByRange(comment) {
-        var patchNum = comment.patch_set + '';
+    _loadCommentMap() {
+      const filterByRange = comment => {
+        const patchNum = comment.patch_set + '';
         return patchNum === this._patchRange.patchNum ||
             patchNum === this._patchRange.basePatchNum;
       };
@@ -684,34 +674,34 @@
         this.$.restAPI.getDiffComments(this._changeNum),
         this._getDiffDrafts(),
         this.$.restAPI.getDiffRobotComments(this._changeNum),
-      ]).then(function(results) {
-        var commentMap = {};
-        results.forEach(function(response) {
-          for (var path in response) {
+      ]).then(results => {
+        const commentMap = {};
+        for (const response of results) {
+          for (const path in response) {
             if (response.hasOwnProperty(path) &&
-                response[path].filter(filterByRange.bind(this)).length) {
+                response[path].filter(filterByRange).length) {
               commentMap[path] = true;
             }
           }
-        }.bind(this));
+        }
         return commentMap;
-      }.bind(this));
+      });
     },
 
-    _getDiffDrafts: function() {
-      return this._getLoggedIn().then(function(loggedIn) {
+    _getDiffDrafts() {
+      return this._getLoggedIn().then(loggedIn => {
         if (!loggedIn) { return Promise.resolve({}); }
         return this.$.restAPI.getDiffDrafts(this._changeNum);
-      }.bind(this));
+      });
     },
 
-    _computeCommentSkips: function(commentMap, fileList, path) {
-      var skips = {previous: null, next: null};
+    _computeCommentSkips(commentMap, fileList, path) {
+      const skips = {previous: null, next: null};
       if (!fileList.length) { return skips; }
-      var pathIndex = fileList.indexOf(path);
+      const pathIndex = fileList.indexOf(path);
 
       // Scan backward for the previous file.
-      for (var i = pathIndex - 1; i >= 0; i--) {
+      for (let i = pathIndex - 1; i >= 0; i--) {
         if (commentMap[fileList[i]]) {
           skips.previous = fileList[i];
           break;
@@ -719,7 +709,7 @@
       }
 
       // Scan forward for the next file.
-      for (i = pathIndex + 1; i < fileList.length; i++) {
+      for (let i = pathIndex + 1; i < fileList.length; i++) {
         if (commentMap[fileList[i]]) {
           skips.next = fileList[i];
           break;
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 d1bcd60..625af35 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
@@ -41,34 +41,34 @@
 </test-fixture>
 
 <script>
-  suite('gr-diff-view tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-diff-view tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
 
       stub('gr-rest-api-interface', {
-        getLoggedIn: function() { return Promise.resolve(false); },
-        getProjectConfig: function() { return Promise.resolve({}); },
-        getDiffChangeDetail: function() { return Promise.resolve(null); },
-        getChangeFiles: function() { return Promise.resolve({}); },
-        saveFileReviewed: function() { return Promise.resolve(); },
+        getLoggedIn() { return Promise.resolve(false); },
+        getProjectConfig() { return Promise.resolve({}); },
+        getDiffChangeDetail() { return Promise.resolve(null); },
+        getChangeFiles() { return Promise.resolve({}); },
+        saveFileReviewed() { return Promise.resolve(); },
       });
       element = fixture('basic');
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('toggle left diff with a hotkey', function() {
-      var toggleLeftDiffStub = sandbox.stub(element.$.diff, 'toggleLeftDiff');
+    test('toggle left diff with a hotkey', () => {
+      const toggleLeftDiffStub = sandbox.stub(element.$.diff, 'toggleLeftDiff');
       MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
       assert.isTrue(toggleLeftDiffStub.calledOnce);
     });
 
-    test('keyboard shortcuts', function() {
+    test('keyboard shortcuts', () => {
       element._changeNum = '42';
       element._patchRange = {
         basePatchNum: 'PARENT',
@@ -83,7 +83,7 @@
       element._path = 'glados.txt';
       element.changeViewState.selectedFileIndex = 1;
 
-      var showStub = sandbox.stub(page, 'show');
+      const showStub = sandbox.stub(page, 'show');
       MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
       assert(showStub.lastCall.calledWithExactly('/c/42/'),
           'Should navigate to /c/42/');
@@ -111,13 +111,14 @@
           'Should navigate to /c/42/');
       assert.equal(element.changeViewState.selectedFileIndex, 0);
 
-      var showPrefsStub = sandbox.stub(element.$.prefsOverlay, 'open',
-          function() { return Promise.resolve({}); });
+      const showPrefsStub =
+          sandbox.stub(element.$.diffPreferences.$.prefsOverlay, 'open',
+              () => Promise.resolve());
 
       MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
       assert(showPrefsStub.calledOnce);
 
-      var scrollStub = sandbox.stub(element.$.cursor, 'moveToNextChunk');
+      let scrollStub = sandbox.stub(element.$.cursor, 'moveToNextChunk');
       MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
       assert(scrollStub.calledOnce);
 
@@ -134,7 +135,7 @@
       MockInteractions.pressAndReleaseKeyOn(element, 80, 'shift', 'p');
       assert(scrollStub.calledOnce);
 
-      var computeContainerClassStub = sandbox.stub(element.$.diff,
+      const computeContainerClassStub = sandbox.stub(element.$.diff,
           '_computeContainerClass');
       MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
       assert(computeContainerClassStub.lastCall.calledWithExactly(
@@ -145,23 +146,7 @@
           false, 'SIDE_BY_SIDE', false));
     });
 
-    test('saving diff preferences', function() {
-      var savePrefs = sandbox.stub(element, '_handlePrefsSave');
-      var cancelPrefs = sandbox.stub(element, '_handlePrefsCancel');
-      element.$.diffPreferences._handleSave();
-      assert(savePrefs.calledOnce);
-      assert(cancelPrefs.notCalled);
-    });
-
-    test('cancelling diff preferences', function() {
-      var savePrefs = sandbox.stub(element, '_handlePrefsSave');
-      var cancelPrefs = sandbox.stub(element, '_handlePrefsCancel');
-      element.$.diffPreferences._handleCancel();
-      assert(cancelPrefs.calledOnce);
-      assert(savePrefs.notCalled);
-    });
-
-    test('keyboard shortcuts with patch range', function() {
+    test('keyboard shortcuts with patch range', () => {
       element._changeNum = '42';
       element._patchRange = {
         basePatchNum: '5',
@@ -175,7 +160,7 @@
       element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
       element._path = 'glados.txt';
 
-      var showStub = sandbox.stub(page, 'show');
+      const showStub = sandbox.stub(page, 'show');
 
       MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
       assert.isTrue(showStub.notCalled, 'The `a` keyboard shortcut should ' +
@@ -214,7 +199,7 @@
           'Should navigate to /c/42/5..10');
     });
 
-    test('keyboard shortcuts with old patch number', function() {
+    test('keyboard shortcuts with old patch number', () => {
       element._changeNum = '42';
       element._patchRange = {
         basePatchNum: 'PARENT',
@@ -229,7 +214,7 @@
       element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
       element._path = 'glados.txt';
 
-      var showStub = sandbox.stub(page, 'show');
+      const showStub = sandbox.stub(page, 'show');
 
       MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
       assert.isTrue(showStub.notCalled, 'The `a` keyboard shortcut should ' +
@@ -268,7 +253,39 @@
           'Should navigate to /c/42/1');
     });
 
-    test('go up to change via kb without change loaded', function() {
+    test('Diff preferences hidden when no prefs or logged out', () => {
+      element._loggedIn = false;
+      flushAsynchronousOperations();
+      assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+      element._loggedIn = true;
+      flushAsynchronousOperations();
+      assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+      element._loggedIn = false;
+      element._prefs = {font_size: '12'};
+      flushAsynchronousOperations();
+      assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+      element._loggedIn = true;
+      flushAsynchronousOperations();
+      assert.isFalse(element.$.diffPrefsContainer.hidden);
+    });
+
+    test('prefsButton opens gr-diff-preferences', () => {
+      const handlePrefsTapSpy = sandbox.spy(element, '_handlePrefsTap');
+      const overlayOpenStub = sandbox.stub(element.$.diffPreferences,
+          'open');
+      const prefsButton =
+          Polymer.dom(element.root).querySelector('.prefsButton');
+
+      MockInteractions.tap(prefsButton);
+
+      assert.isTrue(handlePrefsTapSpy.called);
+      assert.isTrue(overlayOpenStub.called);
+    });
+
+    test('go up to change via kb without change loaded', () => {
       element._changeNum = '42';
       element._patchRange = {
         basePatchNum: 'PARENT',
@@ -278,7 +295,7 @@
       element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
       element._path = 'glados.txt';
 
-      var showStub = sandbox.stub(page, 'show');
+      const showStub = sandbox.stub(page, 'show');
 
       MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
       assert.isTrue(showStub.notCalled, 'The `a` keyboard shortcut should ' +
@@ -317,7 +334,7 @@
           'Should navigate to /c/42/1');
     });
 
-    test('jump to file dropdown', function() {
+    test('jump to file dropdown', () => {
       element._changeNum = '42';
       element._patchRange = {
         basePatchNum: 'PARENT',
@@ -326,7 +343,7 @@
       element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
       element._path = 'glados.txt';
       flushAsynchronousOperations();
-      var linkEls =
+      const linkEls =
           Polymer.dom(element.root).querySelectorAll('.dropdown-content > a');
       assert.equal(linkEls.length, 3);
       assert.isFalse(linkEls[0].hasAttribute('selected'));
@@ -347,7 +364,7 @@
           'Merge list');
     });
 
-    test('jump to file dropdown with patch range', function() {
+    test('jump to file dropdown with patch range', () => {
       element._changeNum = '42';
       element._patchRange = {
         basePatchNum: '5',
@@ -356,7 +373,7 @@
       element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
       element._path = 'glados.txt';
       flushAsynchronousOperations();
-      var linkEls =
+      const linkEls =
           Polymer.dom(element.root).querySelectorAll('.dropdown-content > a');
       assert.equal(linkEls.length, 3);
       assert.isFalse(linkEls[0].hasAttribute('selected'));
@@ -370,7 +387,7 @@
       assert.equal(linkEls[2].getAttribute('href'), '/c/42/5..10/wheatley.md');
     });
 
-    test('prev/up/next links', function() {
+    test('prev/up/next links', () => {
       element._changeNum = '42';
       element._patchRange = {
         basePatchNum: 'PARENT',
@@ -384,7 +401,7 @@
       element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
       element._path = 'glados.txt';
       flushAsynchronousOperations();
-      var linkEls = Polymer.dom(element.root).querySelectorAll('.navLink');
+      const linkEls = Polymer.dom(element.root).querySelectorAll('.navLink');
       assert.equal(linkEls.length, 3);
       assert.equal(linkEls[0].getAttribute('href'), '/c/42/10/chell.go');
       assert.equal(linkEls[1].getAttribute('href'), '/c/42/');
@@ -406,7 +423,7 @@
       assert.equal(linkEls[2].getAttribute('href'), '/c/42/10/chell.go');
     });
 
-    test('download link', function() {
+    test('download link', () => {
       element._changeNum = '42';
       element._patchRange = {
         basePatchNum: 'PARENT',
@@ -419,7 +436,7 @@
           '/changes/42/revisions/10/patch?zip&path=glados.txt');
     });
 
-    test('prev/up/next links with patch range', function() {
+    test('prev/up/next links with patch range', () => {
       element._changeNum = '42';
       element._patchRange = {
         basePatchNum: '5',
@@ -434,7 +451,7 @@
       element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
       element._path = 'glados.txt';
       flushAsynchronousOperations();
-      var linkEls = Polymer.dom(element.root).querySelectorAll('.navLink');
+      const linkEls = Polymer.dom(element.root).querySelectorAll('.navLink');
       assert.equal(linkEls.length, 3);
       assert.equal(linkEls[0].getAttribute('href'), '/c/42/5..10/chell.go');
       assert.equal(linkEls[1].getAttribute('href'), '/c/42/5..10');
@@ -451,7 +468,7 @@
       assert.equal(linkEls[2].getAttribute('href'), '/c/42/5..10/glados.txt');
     });
 
-    test('file review status', function(done) {
+    test('file review status', done => {
       element._loggedIn = true;
       element._changeNum = '42';
       element._patchRange = {
@@ -460,11 +477,11 @@
       };
       element._fileList = ['/COMMIT_MSG'];
       element._path = '/COMMIT_MSG';
-      var saveReviewedStub = sandbox.stub(element, '_saveReviewedState',
-          function() { return Promise.resolve(); });
+      const saveReviewedStub = sandbox.stub(element, '_saveReviewedState',
+          () => Promise.resolve());
 
-      flush(function() {
-        var commitMsg = Polymer.dom(element.root).querySelector(
+      flush(() => {
+        const commitMsg = Polymer.dom(element.root).querySelector(
             'input[type="checkbox"]');
 
         assert.isTrue(commitMsg.checked);
@@ -480,9 +497,9 @@
       });
     });
 
-    test('diff mode selector correctly toggles the diff', function() {
-      var select = element.$.modeSelect;
-      var diffDisplay = element.$.diff;
+    test('diff mode selector correctly toggles the diff', () => {
+      const select = element.$.modeSelect;
+      const diffDisplay = element.$.diff;
       element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
 
       // The mode selected in the view state reflects the selected option.
@@ -493,7 +510,7 @@
       assert.equal(select.value, diffDisplay.viewMode);
 
       // We will simulate a user change of the selected mode.
-      var newMode = 'UNIFIED_DIFF';
+      const newMode = 'UNIFIED_DIFF';
       // Set the actual value of the select, and simulate the change event.
       select.value = newMode;
       element.fire('change', {}, {node: select});
@@ -504,17 +521,16 @@
       assert.equal(element._getDiffViewMode(), diffDisplay.viewMode);
     });
 
-    test('diff mode selector initializes from preferences', function() {
-      var resolvePrefs;
-      var prefsPromise = new Promise(function(resolve) {
+    test('diff mode selector initializes from preferences', () => {
+      let resolvePrefs;
+      const prefsPromise = new Promise(resolve => {
         resolvePrefs = resolve;
       });
-      var getPreferencesStub = sandbox.stub(element.$.restAPI, 'getPreferences',
-          function() { return prefsPromise; });
+      sandbox.stub(element.$.restAPI, 'getPreferences', () => prefsPromise);
 
       // Attach a new gr-diff-view so we can intercept the preferences fetch.
-      var view = document.createElement('gr-diff-view');
-      var select = view.$.modeSelect;
+      const view = document.createElement('gr-diff-view');
+      const select = view.$.modeSelect;
       fixture('blank').appendChild(view);
       flushAsynchronousOperations();
 
@@ -527,7 +543,7 @@
       assert.equal(select.value, 'SIDE_BY_SIDE');
     });
 
-    test('_loadHash', function() {
+    test('_loadHash', () => {
       assert.isNotOk(element.$.cursor.initialLineNumber);
 
       // Ignores invalid hashes:
@@ -550,32 +566,31 @@
       assert.equal(element.$.cursor.side, 'left');
     });
 
-    test('_shortenPath with long path should add ellipsis', function() {
-      var path =
-          'level1/level2/level3/level4/file.js';
-      var shortenedPath = util.truncatePath(path);
+    test('_shortenPath with long path should add ellipsis', () => {
+      let path = 'level1/level2/level3/level4/file.js';
+      let shortenedPath = util.truncatePath(path);
       // The expected path is truncated with an ellipsis.
-      var expectedPath = '\u2026/file.js';
+      const expectedPath = '\u2026/file.js';
       assert.equal(shortenedPath, expectedPath);
 
-      var path = 'level2/file.js';
-      var shortenedPath = util.truncatePath(path);
+      path = 'level2/file.js';
+      shortenedPath = util.truncatePath(path);
       assert.equal(shortenedPath, expectedPath);
     });
 
-    test('_shortenPath with short path should not add ellipsis', function() {
-      var path = 'file.js';
-      var expectedPath = 'file.js';
-      var shortenedPath = util.truncatePath(path);
+    test('_shortenPath with short path should not add ellipsis', () => {
+      const path = 'file.js';
+      const expectedPath = 'file.js';
+      const shortenedPath = util.truncatePath(path);
       assert.equal(shortenedPath, expectedPath);
     });
 
-    test('_onLineSelected', function() {
-      var replaceStateStub = sandbox.stub(history, 'replaceState');
-      var moveStub = sandbox.stub(element.$.cursor, 'moveToLineNumber');
+    test('_onLineSelected', () => {
+      const replaceStateStub = sandbox.stub(history, 'replaceState');
+      const moveStub = sandbox.stub(element.$.cursor, 'moveToLineNumber');
 
-      var e = {};
-      var detail = {number: 123, side: 'right'};
+      const e = {};
+      const detail = {number: 123, side: 'right'};
 
       element._onLineSelected(e, detail);
 
@@ -586,15 +601,15 @@
       assert.isTrue(replaceStateStub.called);
     });
 
-    test('_getDiffURL encodes special characters', function() {
-      var changeNum = 123;
-      var patchRange = {basePatchNum: 123, patchNum: 456};
-      var path = 'c++/cpp.cpp';
+    test('_getDiffURL encodes special characters', () => {
+      const changeNum = 123;
+      const patchRange = {basePatchNum: 123, patchNum: 456};
+      const path = 'c++/cpp.cpp';
       assert.equal(element._getDiffURL(changeNum, patchRange, path),
           '/c/123/123..456/c%252B%252B/cpp.cpp');
     });
 
-    test('_getDiffViewMode', function() {
+    test('_getDiffViewMode', () => {
       // No user prefs or change view state set.
       assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
 
@@ -607,22 +622,22 @@
       assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
     });
 
-    suite('_loadCommentMap', function() {
-      test('empty', function(done) {
+    suite('_loadCommentMap', () => {
+      test('empty', done => {
         stub('gr-rest-api-interface', {
-          getDiffRobotComments: function() { return Promise.resolve({}); },
-          getDiffComments: function() { return Promise.resolve({}); },
+          getDiffRobotComments() { return Promise.resolve({}); },
+          getDiffComments() { return Promise.resolve({}); },
         });
-        element._loadCommentMap().then(function(map) {
+        element._loadCommentMap().then(map => {
           assert.equal(Object.keys(map).length, 0);
           done();
         });
       });
 
-      test('paths in patch range', function(done) {
+      test('paths in patch range', done => {
         stub('gr-rest-api-interface', {
-          getDiffRobotComments: function() { return Promise.resolve({}); },
-          getDiffComments: function() {
+          getDiffRobotComments() { return Promise.resolve({}); },
+          getDiffComments() {
             return Promise.resolve({
               'path/to/file/one.cpp': [{patch_set: 3, message: 'lorem'}],
               'path-to/file/two.py': [{patch_set: 5, message: 'ipsum'}],
@@ -634,17 +649,17 @@
           basePatchNum: '3',
           patchNum: '5',
         };
-        element._loadCommentMap().then(function(map) {
+        element._loadCommentMap().then(map => {
           assert.deepEqual(Object.keys(map),
               ['path/to/file/one.cpp', 'path-to/file/two.py']);
           done();
         });
       });
 
-      test('empty for paths outside patch range', function(done) {
+      test('empty for paths outside patch range', done => {
         stub('gr-rest-api-interface', {
-          getDiffRobotComments: function() { return Promise.resolve({}); },
-          getDiffComments: function() {
+          getDiffRobotComments() { return Promise.resolve({}); },
+          getDiffComments() {
             return Promise.resolve({
               'path/to/file/one.cpp': [{patch_set: 'PARENT', message: 'lorem'}],
               'path-to/file/two.py': [{patch_set: 2, message: 'ipsum'}],
@@ -656,35 +671,35 @@
           basePatchNum: '3',
           patchNum: '5',
         };
-        element._loadCommentMap().then(function(map) {
+        element._loadCommentMap().then(map => {
           assert.equal(Object.keys(map).length, 0);
           done();
         });
       });
     });
 
-    suite('_computeCommentSkips', function() {
-      test('empty file list', function() {
-        var commentMap = {
+    suite('_computeCommentSkips', () => {
+      test('empty file list', () => {
+        const commentMap = {
           'path/one.jpg': true,
           'path/three.wav': true,
         };
-        var path = 'path/two.m4v';
-        var fileList = [];
-        var result = element._computeCommentSkips(commentMap, fileList, path);
+        const path = 'path/two.m4v';
+        const fileList = [];
+        const result = element._computeCommentSkips(commentMap, fileList, path);
         assert.isNull(result.previous);
         assert.isNull(result.next);
       });
 
-      test('finds skips', function() {
-        var fileList = ['path/one.jpg', 'path/two.m4v', 'path/three.wav'];
-        var path = fileList[1];
-        var commentMap = {};
+      test('finds skips', () => {
+        const fileList = ['path/one.jpg', 'path/two.m4v', 'path/three.wav'];
+        let path = fileList[1];
+        const commentMap = {};
         commentMap[fileList[0]] = true;
         commentMap[fileList[1]] = false;
         commentMap[fileList[2]] = true;
 
-        var result = element._computeCommentSkips(commentMap, fileList, path);
+        let result = element._computeCommentSkips(commentMap, fileList, path);
         assert.equal(result.previous, fileList[0]);
         assert.equal(result.next, fileList[2]);
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js
index 2dc495a..5483857 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js
@@ -44,7 +44,7 @@
   GrDiffGroup.prototype.addLine = function(line) {
     this.lines.push(line);
 
-    var notDelta = (this.type === GrDiffGroup.Type.BOTH ||
+    const notDelta = (this.type === GrDiffGroup.Type.BOTH ||
         this.type === GrDiffGroup.Type.CONTEXT_CONTROL);
     if (notDelta && (line.type === GrDiffLine.Type.ADD ||
         line.type === GrDiffLine.Type.REMOVE)) {
@@ -62,7 +62,7 @@
   GrDiffGroup.prototype.getSideBySidePairs = function() {
     if (this.type === GrDiffGroup.Type.BOTH ||
         this.type === GrDiffGroup.Type.CONTEXT_CONTROL) {
-      return this.lines.map(function(line) {
+      return this.lines.map(line => {
         return {
           left: line,
           right: line,
@@ -70,9 +70,9 @@
       });
     }
 
-    var pairs = [];
-    var i = 0;
-    var j = 0;
+    const pairs = [];
+    let i = 0;
+    let j = 0;
     while (i < this.removes.length || j < this.adds.length) {
       pairs.push({
         left: this.removes[i] || GrDiffLine.BLANK_LINE,
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html
index 563825e..90e5d06 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html
@@ -23,13 +23,12 @@
 <script src="gr-diff-group.js"></script>
 
 <script>
-  suite('gr-diff-group tests', function() {
-
-    test('delta line pairs', function() {
-      var group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
-      var l1 = new GrDiffLine(GrDiffLine.Type.ADD);
-      var l2 = new GrDiffLine(GrDiffLine.Type.ADD);
-      var l3 = new GrDiffLine(GrDiffLine.Type.REMOVE);
+  suite('gr-diff-group tests', () => {
+    test('delta line pairs', () => {
+      let group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+      const l1 = new GrDiffLine(GrDiffLine.Type.ADD);
+      const l2 = new GrDiffLine(GrDiffLine.Type.ADD);
+      const l3 = new GrDiffLine(GrDiffLine.Type.REMOVE);
       l1.afterNumber = 128;
       l2.afterNumber = 129;
       l3.beforeNumber = 64;
@@ -44,7 +43,7 @@
         right: {start: 128, end: 129},
       });
 
-      var pairs = group.getSideBySidePairs();
+      let pairs = group.getSideBySidePairs();
       assert.deepEqual(pairs, [
         {left: l3, right: l1},
         {left: GrDiffLine.BLANK_LINE, right: l2},
@@ -62,20 +61,20 @@
       ]);
     });
 
-    test('group/header line pairs', function() {
-      var l1 = new GrDiffLine(GrDiffLine.Type.BOTH);
+    test('group/header line pairs', () => {
+      const l1 = new GrDiffLine(GrDiffLine.Type.BOTH);
       l1.beforeNumber = 64;
       l1.afterNumber = 128;
 
-      var l2 = new GrDiffLine(GrDiffLine.Type.BOTH);
+      const l2 = new GrDiffLine(GrDiffLine.Type.BOTH);
       l2.beforeNumber = 65;
       l2.afterNumber = 129;
 
-      var l3 = new GrDiffLine(GrDiffLine.Type.BOTH);
+      const l3 = new GrDiffLine(GrDiffLine.Type.BOTH);
       l3.beforeNumber = 66;
       l3.afterNumber = 130;
 
-      var group = new GrDiffGroup(GrDiffGroup.Type.BOTH, [l1, l2, l3]);
+      let group = new GrDiffGroup(GrDiffGroup.Type.BOTH, [l1, l2, l3]);
 
       assert.deepEqual(group.lines, [l1, l2, l3]);
       assert.deepEqual(group.adds, []);
@@ -86,7 +85,7 @@
         right: {start: 128, end: 130},
       });
 
-      var pairs = group.getSideBySidePairs();
+      let pairs = group.getSideBySidePairs();
       assert.deepEqual(pairs, [
         {left: l1, right: l1},
         {left: l2, right: l2},
@@ -106,12 +105,12 @@
       ]);
     });
 
-    test('adding delta lines to non-delta group', function() {
-      var l1 = new GrDiffLine(GrDiffLine.Type.ADD);
-      var l2 = new GrDiffLine(GrDiffLine.Type.REMOVE);
-      var l3 = new GrDiffLine(GrDiffLine.Type.BOTH);
+    test('adding delta lines to non-delta group', () => {
+      const l1 = new GrDiffLine(GrDiffLine.Type.ADD);
+      const l2 = new GrDiffLine(GrDiffLine.Type.REMOVE);
+      const l3 = new GrDiffLine(GrDiffLine.Type.BOTH);
 
-      var group = new GrDiffGroup(GrDiffGroup.Type.BOTH);
+      let group = new GrDiffGroup(GrDiffGroup.Type.BOTH);
       assert.throws(group.addLine.bind(group, l1));
       assert.throws(group.addLine.bind(group, l2));
       assert.doesNotThrow(group.addLine.bind(group, l3));
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
index 2a5913c..8db0c4f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
@@ -43,5 +43,4 @@
   GrDiffLine.BLANK_LINE = new GrDiffLine(GrDiffLine.Type.BLANK);
 
   window.GrDiffLine = GrDiffLine;
-
 })(window);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
index 54e9c6e..dbbe14c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -23,6 +23,8 @@
 <link rel="import" href="../gr-diff-selection/gr-diff-selection.html">
 <link rel="import" href="../gr-syntax-themes/gr-theme-default.html">
 
+<script src="../../../scripts/hiddenscroll.js"></script>
+
 <dom-module id="gr-diff">
   <template>
     <style>
@@ -31,7 +33,6 @@
         --dark-remove-highlight-color: rgba(255, 0, 0, 0.15);
         --light-add-highlight-color: #efe;
         --dark-add-highlight-color: rgba(0, 255, 0, 0.15);
-
       }
       :host.no-left .sideBySide ::content .left,
       :host.no-left .sideBySide ::content .left + td,
@@ -47,6 +48,9 @@
         overflow-x: auto;
         will-change: transform;
       }
+      .diffContainer.hiddenscroll {
+        padding-bottom: .8em;
+      }
       table {
         border-collapse: collapse;
         border-right: 1px solid #ddd;
@@ -106,7 +110,7 @@
         display: inline-block;
         color: #666;
         content: attr(data-value);
-        padding: 0 .75em;
+        padding: 0 .5em;
         text-align: right;
         width: 100%;
       }
@@ -173,8 +177,22 @@
         border-radius: .4em;
         background-color: #FF9AD2;
       }
+      #diffHeader {
+        background-color: #F9F9F9;
+        color: #2A00FF;
+        font-family: var(--monospace-font-family);
+        font-size: var(--font-size, 12px);
+        padding: 0.5em 0 0.5em 4em;
+      }
     </style>
     <style include="gr-theme-default"></style>
+    <div id="diffHeader" hidden$="[[_computeDiffHeaderHidden(_diffHeaderItems)]]">
+      <template
+          is="dom-repeat"
+          items="[[_diffHeaderItems]]">
+        <div>[[item]]</div>
+      </template>
+    </div>
     <div class$="[[_computeContainerClass(_loggedIn, viewMode, displayLine)]]"
         on-tap="_handleTap">
       <gr-diff-selection diff="[[_diff]]">
@@ -186,6 +204,7 @@
               id="diffBuilder"
               comments="[[_comments]]"
               diff="[[_diff]]"
+              diff-path="[[path]]"
               view-mode="[[viewMode]]"
               line-wrapping="[[lineWrapping]]"
               is-image-diff="[[isImageDiff]]"
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 05a7f72..fbff941 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -14,12 +14,12 @@
 (function() {
   'use strict';
 
-  var DiffViewMode = {
+  const DiffViewMode = {
     SIDE_BY_SIDE: 'SIDE_BY_SIDE',
     UNIFIED: 'UNIFIED_DIFF',
   };
 
-  var DiffSide = {
+  const DiffSide = {
     LEFT: 'left',
     RIGHT: 'right',
   };
@@ -32,6 +32,12 @@
      * @event line-selected
      */
 
+    /**
+     * Fired if being logged in is required.
+     *
+     * @event show-auth-required
+     */
+
     properties: {
       changeNum: String,
       noAutoRender: {
@@ -48,8 +54,6 @@
         type: Object,
         observer: '_projectConfigChanged',
       },
-      project: String,
-      commit: String,
       displayLine: {
         type: Boolean,
         value: false,
@@ -61,13 +65,14 @@
       },
       filesWeblinks: {
         type: Object,
-        value: function() { return {}; },
+        value() { return {}; },
         notify: true,
       },
       hidden: {
         type: Boolean,
         reflectToAttribute: true,
       },
+      noRenderOnPrefsChange: Boolean,
       _loggedIn: {
         type: Boolean,
         value: false,
@@ -83,6 +88,11 @@
         observer: '_viewModeObserver',
       },
       _diff: Object,
+      _diffHeaderItems: {
+        type: Array,
+        value: [],
+        computed: '_computeDiffHeaderItems(_diff.*)',
+      },
       _diffTableClass: {
         type: String,
         value: '',
@@ -100,42 +110,41 @@
       'create-comment': '_handleCreateComment',
     },
 
-    attached: function() {
-      this._getLoggedIn().then(function(loggedIn) {
+    attached() {
+      this._getLoggedIn().then(loggedIn => {
         this._loggedIn = loggedIn;
-      }.bind(this));
-
+      });
     },
 
-    ready: function() {
+    ready() {
       if (this._canRender()) {
         this.reload();
       }
     },
 
-    reload: function() {
+    reload() {
       this._clearDiffContent();
 
-      var promises = [];
+      const promises = [];
 
-      promises.push(this._getDiff().then(function(diff) {
+      promises.push(this._getDiff().then(diff => {
         this._diff = diff;
         return this._loadDiffAssets();
-      }.bind(this)));
+      }));
 
-      promises.push(this._getDiffCommentsAndDrafts().then(function(comments) {
+      promises.push(this._getDiffCommentsAndDrafts().then(comments => {
         this._comments = comments;
-      }.bind(this)));
+      }));
 
-      return Promise.all(promises).then(function() {
+      return Promise.all(promises).then(() => {
         if (this.prefs) {
           return this._renderDiffTable();
         }
         return Promise.resolve();
-      }.bind(this));
+      });
     },
 
-    getCursorStops: function() {
+    getCursorStops() {
       if (this.hidden && this.noAutoRender) {
         return [];
       }
@@ -143,43 +152,46 @@
       return Polymer.dom(this.root).querySelectorAll('.diff-row');
     },
 
-    addDraftAtLine: function(el) {
+    addDraftAtLine(el) {
       this._selectLine(el);
-      this._getLoggedIn().then(function(loggedIn) {
-        if (!loggedIn) { return; }
+      this._getLoggedIn().then(loggedIn => {
+        if (!loggedIn) {
+          this.fire('show-auth-required');
+          return;
+        }
 
-        var value = el.getAttribute('data-value');
+        const value = el.getAttribute('data-value');
         if (value === GrDiffLine.FILE) {
           this._addDraft(el);
           return;
         }
-        var lineNum = parseInt(value, 10);
+        const lineNum = parseInt(value, 10);
         if (isNaN(lineNum)) {
           throw Error('Invalid line number: ' + value);
         }
         this._addDraft(el, lineNum);
-      }.bind(this));
+      });
     },
 
-    isRangeSelected: function() {
+    isRangeSelected() {
       return this.$.highlights.isRangeSelected();
     },
 
-    toggleLeftDiff: function() {
+    toggleLeftDiff() {
       this.toggleClass('no-left');
     },
 
-    _canRender: function() {
+    _canRender() {
       return this.changeNum && this.patchRange && this.path &&
           !this.noAutoRender;
     },
 
-    _getCommentThreads: function() {
+    _getCommentThreads() {
       return Polymer.dom(this.root).querySelectorAll('gr-diff-comment-thread');
     },
 
-    _computeContainerClass: function(loggedIn, viewMode, displayLine) {
-      var classes = ['diffContainer'];
+    _computeContainerClass(loggedIn, viewMode, displayLine) {
+      const classes = ['diffContainer'];
       switch (viewMode) {
         case DiffViewMode.UNIFIED:
           classes.push('unified');
@@ -190,6 +202,9 @@
         default:
           throw Error('Invalid view mode: ', viewMode);
       }
+      if (Gerrit.hiddenscroll) {
+        classes.push('hiddenscroll');
+      }
       if (loggedIn) {
         classes.push('canComment');
       }
@@ -199,8 +214,8 @@
       return classes.join(' ');
     },
 
-    _handleTap: function(e) {
-      var el = Polymer.dom(e).rootTarget;
+    _handleTap(e) {
+      const el = Polymer.dom(e).rootTarget;
 
       if (el.classList.contains('showContext')) {
         this.$.diffBuilder.showContext(e.detail.groups, e.detail.section);
@@ -209,58 +224,60 @@
       } else if (el.tagName === 'HL' ||
           el.classList.contains('content') ||
           el.classList.contains('contentText')) {
-        var target = this.$.diffBuilder.getLineElByChild(el);
+        const target = this.$.diffBuilder.getLineElByChild(el);
         if (target) { this._selectLine(target); }
       }
     },
 
-    _selectLine: function(el) {
+    _selectLine(el) {
       this.fire('line-selected', {
         side: el.classList.contains('left') ? DiffSide.LEFT : DiffSide.RIGHT,
         number: el.getAttribute('data-value'),
+        path: this.path,
       });
     },
 
-    _handleCreateComment: function(e) {
-      var range = e.detail.range;
-      var diffSide = e.detail.side;
-      var line = range.endLine;
-      var lineEl = this.$.diffBuilder.getLineElByNumber(line, diffSide);
-      var contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
-      var contentEl = contentText.parentElement;
-      var patchNum = this._getPatchNumByLineAndContent(lineEl, contentEl);
-      var isOnParent =
+    _handleCreateComment(e) {
+      const range = e.detail.range;
+      const diffSide = e.detail.side;
+      const line = range.endLine;
+      const lineEl = this.$.diffBuilder.getLineElByNumber(line, diffSide);
+      const contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
+      const contentEl = contentText.parentElement;
+      const patchNum = this._getPatchNumByLineAndContent(lineEl, contentEl);
+      const isOnParent =
           this._getIsParentCommentByLineAndContent(lineEl, contentEl);
-      var threadEl = this._getOrCreateThreadAtLineRange(contentEl, patchNum,
+      const threadEl = this._getOrCreateThreadAtLineRange(contentEl, patchNum,
           diffSide, isOnParent, range);
 
       threadEl.addOrEditDraft(line, range);
     },
 
-    _addDraft: function(lineEl, opt_lineNum) {
-      var contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
-      var contentEl = contentText.parentElement;
-      var patchNum = this._getPatchNumByLineAndContent(lineEl, contentEl);
-      var commentSide = this._getCommentSideByLineAndContent(lineEl, contentEl);
-      var isOnParent =
+    _addDraft(lineEl, opt_lineNum) {
+      const contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
+      const contentEl = contentText.parentElement;
+      const patchNum = this._getPatchNumByLineAndContent(lineEl, contentEl);
+      const commentSide =
+          this._getCommentSideByLineAndContent(lineEl, contentEl);
+      const isOnParent =
           this._getIsParentCommentByLineAndContent(lineEl, contentEl);
-      var threadEl = this._getOrCreateThreadAtLineRange(contentEl, patchNum,
+      const threadEl = this._getOrCreateThreadAtLineRange(contentEl, patchNum,
           commentSide, isOnParent);
 
       threadEl.addOrEditDraft(opt_lineNum);
     },
 
-    _getThreadForRange: function(threadGroupEl, rangeToCheck) {
+    _getThreadForRange(threadGroupEl, rangeToCheck) {
       return threadGroupEl.getThreadForRange(rangeToCheck);
     },
 
-    _getThreadGroupForLine: function(contentEl) {
+    _getThreadGroupForLine(contentEl) {
       return contentEl.querySelector('gr-diff-comment-thread-group');
     },
 
-    _getOrCreateThreadAtLineRange:
-        function(contentEl, patchNum, commentSide, isOnParent, range) {
-      var rangeToCheck = range ?
+    _getOrCreateThreadAtLineRange(contentEl, patchNum, commentSide,
+        isOnParent, range) {
+      const rangeToCheck = range ?
           'range-' +
           range.startLine + '-' +
           range.startChar + '-' +
@@ -269,15 +286,15 @@
           commentSide : 'line-' + commentSide;
 
       // Check if thread group exists.
-      var threadGroupEl = this._getThreadGroupForLine(contentEl);
+      let threadGroupEl = this._getThreadGroupForLine(contentEl);
       if (!threadGroupEl) {
         threadGroupEl = this.$.diffBuilder.createCommentThreadGroup(
-          this.changeNum, patchNum, this.path, isOnParent,
-          this.projectConfig);
+            this.changeNum, patchNum, this.path, isOnParent,
+            this.projectConfig);
         contentEl.appendChild(threadGroupEl);
       }
 
-      var threadEl = this._getThreadForRange(threadGroupEl, rangeToCheck);
+      let threadEl = this._getThreadForRange(threadGroupEl, rangeToCheck);
 
       if (!threadEl) {
         threadGroupEl.addNewThread(rangeToCheck, commentSide);
@@ -288,8 +305,8 @@
       return threadEl;
     },
 
-    _getPatchNumByLineAndContent: function(lineEl, contentEl) {
-      var patchNum = this.patchRange.patchNum;
+    _getPatchNumByLineAndContent(lineEl, contentEl) {
+      let patchNum = this.patchRange.patchNum;
       if ((lineEl.classList.contains(DiffSide.LEFT) ||
           contentEl.classList.contains('remove')) &&
           this.patchRange.basePatchNum !== 'PARENT') {
@@ -298,8 +315,8 @@
       return patchNum;
     },
 
-    _getIsParentCommentByLineAndContent: function(lineEl, contentEl) {
-      var isOnParent = false;
+    _getIsParentCommentByLineAndContent(lineEl, contentEl) {
+      let isOnParent = false;
       if ((lineEl.classList.contains(DiffSide.LEFT) ||
           contentEl.classList.contains('remove')) &&
           this.patchRange.basePatchNum === 'PARENT') {
@@ -308,8 +325,8 @@
       return isOnParent;
     },
 
-    _getCommentSideByLineAndContent: function(lineEl, contentEl) {
-      var side = 'right';
+    _getCommentSideByLineAndContent(lineEl, contentEl) {
+      let side = 'right';
       if (lineEl.classList.contains(DiffSide.LEFT) ||
           contentEl.classList.contains('remove')) {
         side = 'left';
@@ -317,32 +334,32 @@
       return side;
     },
 
-    _handleThreadDiscard: function(e) {
-      var el = Polymer.dom(e).rootTarget;
+    _handleThreadDiscard(e) {
+      const el = Polymer.dom(e).rootTarget;
       el.parentNode.removeThread(el.locationRange);
     },
 
-    _handleCommentDiscard: function(e) {
-      var comment = e.detail.comment;
+    _handleCommentDiscard(e) {
+      const comment = e.detail.comment;
       this._removeComment(comment, e.detail.patchNum);
     },
 
-    _removeComment: function(comment, opt_patchNum) {
-      var side = comment.__commentSide;
+    _removeComment(comment) {
+      const side = comment.__commentSide;
       this._removeCommentFromSide(comment, side);
     },
 
-    _handleCommentSave: function(e) {
-      var comment = e.detail.comment;
-      var side = e.detail.comment.__commentSide;
-      var idx = this._findDraftIndex(comment, side);
+    _handleCommentSave(e) {
+      const comment = e.detail.comment;
+      const side = e.detail.comment.__commentSide;
+      const idx = this._findDraftIndex(comment, side);
       this.set(['_comments', side, idx], comment);
     },
 
-    _handleCommentUpdate: function(e) {
-      var comment = e.detail.comment;
-      var side = e.detail.comment.__commentSide;
-      var idx = this._findCommentIndex(comment, side);
+    _handleCommentUpdate(e) {
+      const comment = e.detail.comment;
+      const side = e.detail.comment.__commentSide;
+      let idx = this._findCommentIndex(comment, side);
       if (idx === -1) {
         idx = this._findDraftIndex(comment, side);
       }
@@ -353,8 +370,8 @@
       }
     },
 
-    _removeCommentFromSide: function(comment, side) {
-      var idx = this._findCommentIndex(comment, side);
+    _removeCommentFromSide(comment, side) {
+      let idx = this._findCommentIndex(comment, side);
       if (idx === -1) {
         idx = this._findDraftIndex(comment, side);
       }
@@ -363,29 +380,29 @@
       }
     },
 
-    _findCommentIndex: function(comment, side) {
+    _findCommentIndex(comment, side) {
       if (!comment.id || !this._comments[side]) {
         return -1;
       }
-      return this._comments[side].findIndex(function(item) {
+      return this._comments[side].findIndex(item => {
         return item.id === comment.id;
       });
     },
 
-    _findDraftIndex: function(comment, side) {
+    _findDraftIndex(comment, side) {
       if (!comment.__draftID || !this._comments[side]) {
         return -1;
       }
-      return this._comments[side].findIndex(function(item) {
+      return this._comments[side].findIndex(item => {
         return item.__draftID === comment.__draftID;
       });
     },
 
-    _prefsObserver: function(newPrefs, oldPrefs) {
+    _prefsObserver(newPrefs, oldPrefs) {
       // Scan the preference objects one level deep to see if they differ.
-      var differ = !oldPrefs;
+      let differ = !oldPrefs;
       if (newPrefs && oldPrefs) {
-        for (var key in newPrefs) {
+        for (const key in newPrefs) {
           if (newPrefs[key] !== oldPrefs[key]) {
             differ = true;
           }
@@ -397,15 +414,15 @@
       }
     },
 
-    _viewModeObserver: function() {
+    _viewModeObserver() {
       this._prefsChanged(this.prefs);
     },
 
-    _lineWrappingObserver: function() {
+    _lineWrappingObserver() {
       this._prefsChanged(this.prefs);
     },
 
-    _prefsChanged: function(prefs) {
+    _prefsChanged(prefs) {
       if (!prefs) { return; }
       if (prefs.line_wrapping) {
         this._diffTableClass = 'full-width';
@@ -417,51 +434,51 @@
         this.customStyle['--content-width'] = prefs.line_length + 'ch';
       }
 
-      if (!!prefs.font_size) {
+      if (prefs.font_size) {
         this.customStyle['--font-size'] = prefs.font_size + 'px';
       }
 
       this.updateStyles();
 
-      if (this._diff && this._comments) {
+      if (this._diff && this._comments && !this.noRenderOnPrefsChange) {
         this._renderDiffTable();
       }
     },
 
-    _renderDiffTable: function() {
+    _renderDiffTable() {
       return this.$.diffBuilder.render(this._comments, this.prefs);
     },
 
-    _clearDiffContent: function() {
+    _clearDiffContent() {
       this.$.diffTable.innerHTML = null;
     },
 
-    _handleGetDiffError: function(response) {
+    _handleGetDiffError(response) {
       // Loading the diff may respond with 409 if the file is too large. In this
       // case, use a toast error..
       if (response.status === 409) {
-        this.fire('server-error', {response: response});
+        this.fire('server-error', {response});
         return;
       }
-      this.fire('page-error', {response: response});
+      this.fire('page-error', {response});
     },
 
-    _getDiff: function() {
+    _getDiff() {
       return this.$.restAPI.getDiff(
           this.changeNum,
           this.patchRange.basePatchNum,
           this.patchRange.patchNum,
           this.path,
-          this._handleGetDiffError.bind(this)).then(function(diff) {
+          this._handleGetDiffError.bind(this)).then(diff => {
             this.filesWeblinks = {
               meta_a: diff && diff.meta_a && diff.meta_a.web_links,
               meta_b: diff && diff.meta_b && diff.meta_b.web_links,
             };
             return diff;
-          }.bind(this));
+          });
     },
 
-    _getDiffComments: function() {
+    _getDiffComments() {
       return this.$.restAPI.getDiffComments(
           this.changeNum,
           this.patchRange.basePatchNum,
@@ -469,8 +486,8 @@
           this.path);
     },
 
-    _getDiffDrafts: function() {
-      return this._getLoggedIn().then(function(loggedIn) {
+    _getDiffDrafts() {
+      return this._getLoggedIn().then(loggedIn => {
         if (!loggedIn) {
           return Promise.resolve({baseComments: [], comments: []});
         }
@@ -479,10 +496,10 @@
             this.patchRange.basePatchNum,
             this.patchRange.patchNum,
             this.path);
-      }.bind(this));
+      });
     },
 
-    _getDiffRobotComments: function() {
+    _getDiffRobotComments() {
       return this.$.restAPI.getDiffRobotComments(
           this.changeNum,
           this.patchRange.basePatchNum,
@@ -490,12 +507,12 @@
           this.path);
     },
 
-    _getDiffCommentsAndDrafts: function() {
-      var promises = [];
+    _getDiffCommentsAndDrafts() {
+      const promises = [];
       promises.push(this._getDiffComments());
       promises.push(this._getDiffDrafts());
       promises.push(this._getDiffRobotComments());
-      return Promise.all(promises).then(function(results) {
+      return Promise.all(promises).then(results => {
         return Promise.resolve({
           comments: results[0],
           drafts: results[1],
@@ -504,16 +521,16 @@
       }).then(this._normalizeDiffCommentsAndDrafts.bind(this));
     },
 
-    _normalizeDiffCommentsAndDrafts: function(results) {
+    _normalizeDiffCommentsAndDrafts(results) {
       function markAsDraft(d) {
         d.__draft = true;
         return d;
       }
-      var baseDrafts = results.drafts.baseComments.map(markAsDraft);
-      var drafts = results.drafts.comments.map(markAsDraft);
+      const baseDrafts = results.drafts.baseComments.map(markAsDraft);
+      const drafts = results.drafts.comments.map(markAsDraft);
 
-      var baseRobotComments = results.robotComments.baseComments;
-      var robotComments = results.robotComments.comments;
+      const baseRobotComments = results.robotComments.baseComments;
+      const robotComments = results.robotComments.comments;
       return Promise.resolve({
         meta: {
           path: this.path,
@@ -528,27 +545,27 @@
       });
     },
 
-    _getLoggedIn: function() {
+    _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
     },
 
-    _computeIsImageDiff: function() {
+    _computeIsImageDiff() {
       if (!this._diff) { return false; }
 
-      var isA = this._diff.meta_a &&
-          this._diff.meta_a.content_type.indexOf('image/') === 0;
-      var isB = this._diff.meta_b &&
-          this._diff.meta_b.content_type.indexOf('image/') === 0;
+      const isA = this._diff.meta_a &&
+          this._diff.meta_a.content_type.startsWith('image/');
+      const isB = this._diff.meta_b &&
+          this._diff.meta_b.content_type.startsWith('image/');
 
       return this._diff.binary && (isA || isB);
     },
 
-    _loadDiffAssets: function() {
+    _loadDiffAssets() {
       if (this.isImageDiff) {
-        return this._getImages().then(function(images) {
+        return this._getImages().then(images => {
           this._baseImage = images.baseImage;
           this._revisionImage = images.revisionImage;
-        }.bind(this));
+        });
       } else {
         this._baseImage = null;
         this._revisionImage = null;
@@ -556,16 +573,31 @@
       }
     },
 
-    _getImages: function() {
-      return this.$.restAPI.getImagesForDiff(this.project, this.commit,
-          this.changeNum, this._diff, this.patchRange);
+    _getImages() {
+      return this.$.restAPI.getImagesForDiff(this.changeNum, this._diff,
+          this.patchRange);
     },
 
-    _projectConfigChanged: function(projectConfig) {
-      var threadEls = this._getCommentThreads();
-      for (var i = 0; i < threadEls.length; i++) {
+    _projectConfigChanged(projectConfig) {
+      const threadEls = this._getCommentThreads();
+      for (let i = 0; i < threadEls.length; i++) {
         threadEls[i].projectConfig = projectConfig;
       }
     },
+
+    _computeDiffHeaderItems(diffInfoRecord) {
+      const diffInfo = diffInfoRecord.base;
+      if (!diffInfo || !diffInfo.diff_header || diffInfo.binary) { return []; }
+      return diffInfo.diff_header.filter(item => {
+        return !(item.startsWith('diff --git ') ||
+            item.startsWith('index ') ||
+            item.startsWith('+++ ') ||
+            item.startsWith('--- '));
+      });
+    },
+
+    _computeDiffHeaderHidden(items) {
+      return items.length === 0;
+    },
   });
 })();
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 34d6de21c..bd7adfe 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
@@ -35,72 +35,82 @@
 </test-fixture>
 
 <script>
-  suite('gr-diff tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-diff tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    suite('not logged in', function() {
-      setup(function() {
+    suite('not logged in', () => {
+      setup(() => {
         stub('gr-rest-api-interface', {
-          getLoggedIn: function() { return Promise.resolve(false); },
+          getLoggedIn() { return Promise.resolve(false); },
         });
         element = fixture('basic');
       });
 
-      test('toggleLeftDiff', function() {
+      test('toggleLeftDiff', () => {
         element.toggleLeftDiff();
         assert.isTrue(element.classList.contains('no-left'));
         element.toggleLeftDiff();
         assert.isFalse(element.classList.contains('no-left'));
       });
 
-      test('view does not start with displayLine classList', function() {
+      test('addDraftAtLine', done => {
+        sandbox.stub(element, '_selectLine');
+        const loggedInErrorSpy = sandbox.spy();
+        element.addEventListener('show-auth-required', loggedInErrorSpy);
+        element.addDraftAtLine();
+        flush(() => {
+          assert.isTrue(loggedInErrorSpy.called);
+          done();
+        });
+      });
+
+      test('view does not start with displayLine classList', () => {
         assert.isFalse(
             element.$$('.diffContainer').classList.contains('displayLine'));
       });
 
-      test('displayLine class added called when displayLine is true',
-          function() {
-        var spy = sandbox.spy(element, '_computeContainerClass');
+      test('displayLine class added called when displayLine is true', () => {
+        const spy = sandbox.spy(element, '_computeContainerClass');
         element.displayLine = true;
         assert.isTrue(spy.called);
         assert.isTrue(
             element.$$('.diffContainer').classList.contains('displayLine'));
       });
 
-      test('get drafts', function(done) {
+      test('get drafts', done => {
         element.patchRange = {basePatchNum: 0, patchNum: 0};
 
-        var getDraftsStub = sandbox.stub(element.$.restAPI, 'getDiffDrafts');
-        element._getDiffDrafts().then(function(result) {
+        const getDraftsStub = sandbox.stub(element.$.restAPI, 'getDiffDrafts');
+        element._getDiffDrafts().then(result => {
           assert.deepEqual(result, {baseComments: [], comments: []});
           sinon.assert.notCalled(getDraftsStub);
           done();
         });
       });
 
-      test('get robot comments', function(done) {
+      test('get robot comments', done => {
         element.patchRange = {basePatchNum: 0, patchNum: 0};
 
-        var getDraftsStub = sandbox.stub(element.$.restAPI,
+        const getDraftsStub = sandbox.stub(element.$.restAPI,
             'getDiffRobotComments');
-        element._getDiffDrafts().then(function(result) {
+        element._getDiffDrafts().then(result => {
           assert.deepEqual(result, {baseComments: [], comments: []});
           sinon.assert.notCalled(getDraftsStub);
           done();
         });
       });
 
-      test('loads files weblinks', function(done) {
-        var diffStub = sandbox.stub(element.$.restAPI, 'getDiff').returns(
+      test('loads files weblinks', done => {
+        sandbox.stub(element.$.restAPI, 'getDiff').returns(
             Promise.resolve({
               meta_a: {
                 web_links: 'foo',
@@ -110,7 +120,7 @@
               },
             }));
         element.patchRange = {};
-        element._getDiff().then(function() {
+        element._getDiff().then(() => {
           assert.deepEqual(element.filesWeblinks, {
             meta_a: 'foo',
             meta_b: 'bar',
@@ -119,7 +129,7 @@
         });
       });
 
-      test('remove comment', function() {
+      test('remove comment', () => {
         element._comments = {
           meta: {
             changeNum: '42',
@@ -172,7 +182,7 @@
         }));
 
         element._removeComment({id: 'bc2', side: 'PARENT',
-            __commentSide: 'left'});
+          __commentSide: 'left'});
         assert.deepEqual(element._comments, {
           meta: {
             changeNum: '42',
@@ -220,13 +230,12 @@
         });
       });
 
-      test('thread groups', function() {
-        var contentEl = document.createElement('div');
-        var rangeToCheck = 'line-left';
-        var commentSide = 'left';
-        var patchNum = 1;
-        var side = 'PARENT';
-        var range = {
+      test('thread groups', () => {
+        const contentEl = document.createElement('div');
+        const commentSide = 'left';
+        const patchNum = 1;
+        const side = 'PARENT';
+        let range = {
           startLine: 1,
           startChar: 1,
           endLine: 1,
@@ -237,10 +246,9 @@
         element.patchRange = {basePatchNum: 1, patchNum: 2};
         element.path = 'file.txt';
 
-        sandbox.stub(element.$.diffBuilder, 'createCommentThreadGroup',
-            function() {
-          var threadGroup =
-              document.createElement('gr-diff-comment-thread-group');
+        sandbox.stub(element.$.diffBuilder, 'createCommentThreadGroup', () => {
+          const threadGroup =
+          document.createElement('gr-diff-comment-thread-group');
           threadGroup.patchForNewThreads = 1;
           return threadGroup;
         });
@@ -268,116 +276,350 @@
         assert.equal(contentEl.querySelectorAll(
             'gr-diff-comment-thread-group').length, 1);
 
-        var threadGroup = contentEl.querySelector(
+        const threadGroup = contentEl.querySelector(
             'gr-diff-comment-thread-group');
-        var threadLength = Polymer.dom(threadGroup.root).
+        const threadLength = Polymer.dom(threadGroup.root).
               querySelectorAll('gr-diff-comment-thread').length;
         assert.equal(threadLength, 2);
       });
 
-      test('renders image diffs', function(done) {
-        var mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
-          intraline_status: 'OK',
-          change_type: 'MODIFIED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index 2adc47d..f9c2f2c 100644',
-            '--- a/carrot.jpg',
-            '+++ b/carrot.jpg',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        var mockFile1 = {
-          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAEwsA' +
-              'AAAAAAAAAAAAAAAA/w==',
-          type: 'image/bmp',
-        };
-        var mockFile2 = {
-          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAEwsA' +
-              'AAAAAAAAAAAA/////w==',
-          type: 'image/bmp'
-        };
-        var mockCommit = {
-          commit: '9a1a1d10baece5efbba10bc4ccf808a67a50ac0a',
-          parents: [{
-            commit: '7338aa9adfe57909f1fdaf88975cdea467d3382f',
-            subject: 'Added a carrot',
-          }],
-          author: {
-            name: 'Wyatt Allen',
-            email: 'wyatta@google.com',
-            date: '2016-05-23 21:44:51.000000000',
-            tz: -420,
-          },
-          committer: {
-            name: 'Wyatt Allen',
-            email: 'wyatta@google.com',
-            date: '2016-05-25 00:25:41.000000000',
-            tz: -420,
-          },
-          subject: 'Updated the carrot',
-          message: 'Updated the carrot\n\nChange-Id: Iabcd123\n',
-        };
-        var mockComments = {baseComments: [], comments: []};
+      suite('image diffs', () => {
+        let mockFile1;
+        let mockFile2;
+        const stubs = [];
+        setup(() => {
+          mockFile1 = {
+            body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+            'wsAAAAAAAAAAAAAAAAA/w==',
+            type: 'image/bmp',
+          };
+          mockFile2 = {
+            body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+            'wsAAAAAAAAAAAAA/////w==',
+            type: 'image/bmp',
+          };
+          const mockCommit = {
+            commit: '9a1a1d10baece5efbba10bc4ccf808a67a50ac0a',
+            parents: [{
+              commit: '7338aa9adfe57909f1fdaf88975cdea467d3382f',
+              subject: 'Added a carrot',
+            }],
+            author: {
+              name: 'Wyatt Allen',
+              email: 'wyatta@google.com',
+              date: '2016-05-23 21:44:51.000000000',
+              tz: -420,
+            },
+            committer: {
+              name: 'Wyatt Allen',
+              email: 'wyatta@google.com',
+              date: '2016-05-25 00:25:41.000000000',
+              tz: -420,
+            },
+            subject: 'Updated the carrot',
+            message: 'Updated the carrot\n\nChange-Id: Iabcd123\n',
+          };
+          const mockComments = {baseComments: [], comments: []};
 
-        var stubs = [];
-        stubs.push(sandbox.stub(element, '_getDiff',
-            function() { return Promise.resolve(mockDiff); }));
-        stubs.push(sandbox.stub(element.$.restAPI, 'getCommitInfo',
-            function() { return Promise.resolve(mockCommit); }));
-        stubs.push(sandbox.stub(element.$.restAPI,
-            'getCommitFileContents',
-            function() { return Promise.resolve(mockFile1); }));
-        stubs.push(sandbox.stub(element.$.restAPI,
-            'getChangeFileContents',
-            function() { return Promise.resolve(mockFile2); }));
-        stubs.push(sandbox.stub(element.$.restAPI, '_getDiffComments',
-            function() { return Promise.resolve(mockComments); }));
-        stubs.push(sandbox.stub(element.$.restAPI, 'getDiffDrafts',
-            function() { return Promise.resolve(mockComments); }));
+          stubs.push(sandbox.stub(element.$.restAPI, 'getCommitInfo',
+              () => Promise.resolve(mockCommit)));
+          stubs.push(sandbox.stub(element.$.restAPI,
+              'getChangeFileContents',
+              (changeId, patchNum, path, opt_parentIndex) => {
+                return Promise.resolve(opt_parentIndex === 1 ? mockFile1 :
+                    mockFile2);
+              }));
+          stubs.push(sandbox.stub(element.$.restAPI, '_getDiffComments',
+              () => Promise.resolve(mockComments)));
+          stubs.push(sandbox.stub(element.$.restAPI, 'getDiffDrafts',
+              () => Promise.resolve(mockComments)));
 
-        element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
+          element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
+        });
 
-        var rendered = function() {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(element.$.diffBuilder._builder, GrDiffBuilderImage);
+        test('renders image diffs with same file name', done => {
+          const mockDiff = {
+            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+            meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+              lines: 560},
+            intraline_status: 'OK',
+            change_type: 'MODIFIED',
+            diff_header: [
+              'diff --git a/carrot.jpg b/carrot.jpg',
+              'index 2adc47d..f9c2f2c 100644',
+              '--- a/carrot.jpg',
+              '+++ b/carrot.jpg',
+              'Binary files differ',
+            ],
+            content: [{skip: 66}],
+            binary: true,
+          };
+          stubs.push(sandbox.stub(element, '_getDiff',
+              () => Promise.resolve(mockDiff)));
 
-          // Left image rendered with the parent commit's version of the file.
-          var leftInmage = element.$.diffTable.querySelector('td.left img');
-          assert.isOk(leftInmage);
-          assert.equal(leftInmage.getAttribute('src'),
-              'data:image/bmp;base64, ' + mockFile1.body);
+          const rendered = () => {
+            // Recognizes that it should be an image diff.
+            assert.isTrue(element.isImageDiff);
+            assert.instanceOf(
+                element.$.diffBuilder._builder, GrDiffBuilderImage);
 
-          // Right image rendered with this change's revision of the image.
-          var rightInmage = element.$.diffTable.querySelector('td.right img');
-          assert.isOk(rightInmage);
-          assert.equal(rightInmage.getAttribute('src'),
-              'data:image/bmp;base64, ' + mockFile2.body);
+            // Left image rendered with the parent commit's version of the file.
+            const leftImage = element.$.diffTable.querySelector('td.left img');
+            const leftLabel =
+                element.$.diffTable.querySelector('td.left label');
+            const leftLabelContent = leftLabel.querySelector('.label');
+            const leftLabelName = leftLabel.querySelector('.name');
 
-          // Cleanup.
-          element.removeEventListener('render', rendered);
+            const rightImage =
+                element.$.diffTable.querySelector('td.right img');
+            const rightLabel = element.$.diffTable.querySelector(
+                'td.right label');
+            const rightLabelContent = rightLabel.querySelector('.label');
+            const rightLabelName = rightLabel.querySelector('.name');
 
-          done();
-        };
+            assert.isNotOk(rightLabelName);
+            assert.isNotOk(leftLabelName);
 
-        element.addEventListener('render', rendered);
+            let leftLoaded = false;
+            let rightLoaded = false;
 
-        element.$.restAPI.getDiffPreferences().then(function(prefs) {
-          element.prefs = prefs;
-          element.reload();
+            leftImage.addEventListener('load', () => {
+              assert.isOk(leftImage);
+              assert.equal(leftImage.getAttribute('src'),
+                  'data:image/bmp;base64, ' + mockFile1.body);
+              assert.equal(leftLabelContent.textContent, '1⨉1 image/bmp');
+              leftLoaded = true;
+              if (rightLoaded) {
+                element.removeEventListener('render', rendered);
+                done();
+              }
+            });
+
+            rightImage.addEventListener('load', () => {
+              assert.isOk(rightImage);
+              assert.equal(rightImage.getAttribute('src'),
+                  'data:image/bmp;base64, ' + mockFile2.body);
+              assert.equal(rightLabelContent.textContent, '1⨉1 image/bmp');
+
+              rightLoaded = true;
+              if (leftLoaded) {
+                element.removeEventListener('render', rendered);
+                done();
+              }
+            });
+          };
+
+          element.addEventListener('render', rendered);
+
+          element.$.restAPI.getDiffPreferences().then(prefs => {
+            element.prefs = prefs;
+            element.reload();
+          });
+        });
+
+        test('renders image diffs with a different file name', done => {
+          const mockDiff = {
+            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+            meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
+              lines: 560},
+            intraline_status: 'OK',
+            change_type: 'MODIFIED',
+            diff_header: [
+              'diff --git a/carrot.jpg b/carrot2.jpg',
+              'index 2adc47d..f9c2f2c 100644',
+              '--- a/carrot.jpg',
+              '+++ b/carrot2.jpg',
+              'Binary files differ',
+            ],
+            content: [{skip: 66}],
+            binary: true,
+          };
+          stubs.push(sandbox.stub(element, '_getDiff',
+              () => Promise.resolve(mockDiff)));
+
+          const rendered = () => {
+            // Recognizes that it should be an image diff.
+            assert.isTrue(element.isImageDiff);
+            assert.instanceOf(
+                element.$.diffBuilder._builder, GrDiffBuilderImage);
+
+            // Left image rendered with the parent commit's version of the file.
+            const leftImage = element.$.diffTable.querySelector('td.left img');
+            const leftLabel =
+                element.$.diffTable.querySelector('td.left label');
+            const leftLabelContent = leftLabel.querySelector('.label');
+            const leftLabelName = leftLabel.querySelector('.name');
+
+            const rightImage =
+                element.$.diffTable.querySelector('td.right img');
+            const rightLabel = element.$.diffTable.querySelector(
+                'td.right label');
+            const rightLabelContent = rightLabel.querySelector('.label');
+            const rightLabelName = rightLabel.querySelector('.name');
+
+            assert.isOk(rightLabelName);
+            assert.isOk(leftLabelName);
+            assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
+            assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
+
+            let leftLoaded = false;
+            let rightLoaded = false;
+
+            leftImage.addEventListener('load', () => {
+              assert.isOk(leftImage);
+              assert.equal(leftImage.getAttribute('src'),
+                  'data:image/bmp;base64, ' + mockFile1.body);
+              assert.equal(leftLabelContent.textContent, '1⨉1 image/bmp');
+              leftLoaded = true;
+              if (rightLoaded) {
+                element.removeEventListener('render', rendered);
+                done();
+              }
+            });
+
+            rightImage.addEventListener('load', () => {
+              assert.isOk(rightImage);
+              assert.equal(rightImage.getAttribute('src'),
+                  'data:image/bmp;base64, ' + mockFile2.body);
+              assert.equal(rightLabelContent.textContent, '1⨉1 image/bmp');
+
+              rightLoaded = true;
+              if (leftLoaded) {
+                element.removeEventListener('render', rendered);
+                done();
+              }
+            });
+          };
+
+          element.addEventListener('render', rendered);
+
+          element.$.restAPI.getDiffPreferences().then(prefs => {
+            element.prefs = prefs;
+            element.reload();
+          });
+        });
+
+        test('renders added image', done => {
+          const mockDiff = {
+            meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+              lines: 560},
+            intraline_status: 'OK',
+            change_type: 'ADDED',
+            diff_header: [
+              'diff --git a/carrot.jpg b/carrot.jpg',
+              'index 0000000..f9c2f2c 100644',
+              '--- /dev/null',
+              '+++ b/carrot.jpg',
+              'Binary files differ',
+            ],
+            content: [{skip: 66}],
+            binary: true,
+          };
+          stubs.push(sandbox.stub(element, '_getDiff',
+              () => Promise.resolve(mockDiff)));
+
+          element.addEventListener('render', () => {
+            // Recognizes that it should be an image diff.
+            assert.isTrue(element.isImageDiff);
+            assert.instanceOf(
+                element.$.diffBuilder._builder, GrDiffBuilderImage);
+
+            const leftImage = element.$.diffTable.querySelector('td.left img');
+            const rightImage = element.$.diffTable.querySelector('td.right img');
+
+            assert.isNotOk(leftImage);
+            assert.isOk(rightImage);
+            done();
+          });
+
+          element.$.restAPI.getDiffPreferences().then(prefs => {
+            element.prefs = prefs;
+            element.reload();
+          });
+        });
+
+        test('renders removed image', done => {
+          const mockDiff = {
+            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
+              lines: 560},
+            intraline_status: 'OK',
+            change_type: 'DELETED',
+            diff_header: [
+              'diff --git a/carrot.jpg b/carrot.jpg',
+              'index f9c2f2c..0000000 100644',
+              '--- a/carrot.jpg',
+              '+++ /dev/null',
+              'Binary files differ',
+            ],
+            content: [{skip: 66}],
+            binary: true,
+          };
+          stubs.push(sandbox.stub(element, '_getDiff',
+              () => Promise.resolve(mockDiff)));
+
+          element.addEventListener('render', () => {
+            // Recognizes that it should be an image diff.
+            assert.isTrue(element.isImageDiff);
+            assert.instanceOf(
+                element.$.diffBuilder._builder, GrDiffBuilderImage);
+
+            const leftImage = element.$.diffTable.querySelector('td.left img');
+            const rightImage = element.$.diffTable.querySelector('td.right img');
+
+            assert.isOk(leftImage);
+            assert.isNotOk(rightImage);
+            done();
+          });
+
+          element.$.restAPI.getDiffPreferences().then(prefs => {
+            element.prefs = prefs;
+            element.reload();
+          });
+        });
+
+        test('does not render disallowed image type', done => {
+          const mockDiff = {
+            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
+              lines: 560},
+            intraline_status: 'OK',
+            change_type: 'DELETED',
+            diff_header: [
+              'diff --git a/carrot.jpg b/carrot.jpg',
+              'index f9c2f2c..0000000 100644',
+              '--- a/carrot.jpg',
+              '+++ /dev/null',
+              'Binary files differ',
+            ],
+            content: [{skip: 66}],
+            binary: true,
+          };
+          mockFile1.type = 'image/jpeg-evil';
+
+          stubs.push(sandbox.stub(element, '_getDiff',
+              () => Promise.resolve(mockDiff)));
+
+          element.addEventListener('render', () => {
+            // Recognizes that it should be an image diff.
+            assert.isTrue(element.isImageDiff);
+            assert.instanceOf(
+                element.$.diffBuilder._builder, GrDiffBuilderImage);
+            const leftImage = element.$.diffTable.querySelector('td.left img');
+            assert.isNotOk(leftImage);
+            done();
+          });
+
+          element.$.restAPI.getDiffPreferences().then(prefs => {
+            element.prefs = prefs;
+            element.reload();
+          });
         });
       });
 
-      test('_handleTap lineNum', function(done) {
-        var addDraftStub = sinon.stub(element, 'addDraftAtLine');
-        var el = document.createElement('div');
+      test('_handleTap lineNum', done => {
+        const addDraftStub = sandbox.stub(element, 'addDraftAtLine');
+        const el = document.createElement('div');
         el.className = 'lineNum';
-        el.addEventListener('click', function(e) {
+        el.addEventListener('click', e => {
           element._handleTap(e);
           assert.isTrue(addDraftStub.called);
           assert.equal(addDraftStub.lastCall.args[0], el);
@@ -386,11 +628,12 @@
         el.click();
       });
 
-      test('_handleTap context', function(done) {
-        var showContextStub = sinon.stub(element.$.diffBuilder, 'showContext');
-        var el = document.createElement('div');
+      test('_handleTap context', done => {
+        const showContextStub =
+            sandbox.stub(element.$.diffBuilder, 'showContext');
+        const el = document.createElement('div');
         el.className = 'showContext';
-        el.addEventListener('click', function(e) {
+        el.addEventListener('click', e => {
           element._handleTap(e);
           assert.isTrue(showContextStub.called);
           done();
@@ -398,16 +641,15 @@
         el.click();
       });
 
-      test('_handleTap content', function(done) {
-        var content = document.createElement('div');
-        var lineEl = document.createElement('div');
+      test('_handleTap content', done => {
+        const content = document.createElement('div');
+        const lineEl = document.createElement('div');
 
-        var selectStub = sandbox.stub(element, '_selectLine');
-        var getLineStub = sandbox.stub(element.$.diffBuilder,
-            'getLineElByChild', function() { return lineEl; });
+        const selectStub = sandbox.stub(element, '_selectLine');
+        sandbox.stub(element.$.diffBuilder, 'getLineElByChild', () => lineEl);
 
         content.className = 'content';
-        content.addEventListener('click', function(e) {
+        content.addEventListener('click', e => {
           element._handleTap(e);
           assert.isTrue(selectStub.called);
           assert.equal(selectStub.lastCall.args[0], lineEl);
@@ -416,9 +658,9 @@
         content.click();
       });
 
-      test('_getDiff handles null diff responses', function(done) {
+      test('_getDiff handles null diff responses', done => {
         stub('gr-rest-api-interface', {
-          getDiff: function() { return Promise.resolve(null); },
+          getDiff() { return Promise.resolve(null); },
         });
         element.changeNum = 123;
         element.patchRange = {basePatchNum: 1, patchNum: 2};
@@ -426,10 +668,9 @@
         element._getDiff().then(done);
       });
 
-      suite('getCursorStops', function() {
-
-        var setupDiff = function() {
-          var mock = document.createElement('mock-diff-response');
+      suite('getCursorStops', () => {
+        const setupDiff = function() {
+          const mock = document.createElement('mock-diff-response');
           element._diff = mock.diffResponse;
           element._comments = {
             left: [],
@@ -456,48 +697,53 @@
           flushAsynchronousOperations();
         };
 
-        test('getCursorStops returns [] when hidden and noAutoRender are true',
-             function() {
+        test('getCursorStops returns [] when hidden and noAutoRender', () => {
           element.noAutoRender = true;
           setupDiff();
           element.hidden = true;
           assert.equal(element.getCursorStops().length, 0);
         });
 
-        test('getCursorStops', function() {
+        test('getCursorStops', () => {
           setupDiff();
           assert.equal(element.getCursorStops().length, 50);
         });
       });
+
+      test('adds .hiddenscroll', () => {
+        Gerrit.hiddenscroll = true;
+        element.displayLine = true;
+        assert.include(element.$$('.diffContainer').className, 'hiddenscroll');
+      });
     });
 
-    suite('logged in', function() {
-      setup(function() {
+    suite('logged in', () => {
+      setup(() => {
         stub('gr-rest-api-interface', {
-          getLoggedIn: function() { return Promise.resolve(true); },
-          getPreferences: function() {
+          getLoggedIn() { return Promise.resolve(true); },
+          getPreferences() {
             return Promise.resolve({time_format: 'HHMM_12'});
           },
         });
         element = fixture('basic');
       });
 
-      test('get drafts', function(done) {
+      test('get drafts', done => {
         element.patchRange = {basePatchNum: 0, patchNum: 0};
-        var draftsResponse = {
+        const draftsResponse = {
           baseComments: [{id: 'foo'}],
           comments: [{id: 'bar'}],
         };
-        var getDraftsStub = sandbox.stub(element.$.restAPI, 'getDiffDrafts',
-            function() { return Promise.resolve(draftsResponse); });
-        element._getDiffDrafts().then(function(result) {
+        sandbox.stub(element.$.restAPI, 'getDiffDrafts',
+            () => Promise.resolve(draftsResponse));
+        element._getDiffDrafts().then(result => {
           assert.deepEqual(result, draftsResponse);
           done();
         });
       });
 
-      test('get comments and drafts', function(done) {
-        var comments = {
+      test('get comments and drafts', done => {
+        const comments = {
           baseComments: [
             {id: 'bc1', __commentSide: 'left'},
             {id: 'bc2', __commentSide: 'left'},
@@ -507,10 +753,10 @@
             {id: 'c2', __commentSide: 'right'},
           ],
         };
-        var diffCommentsStub = sandbox.stub(element, '_getDiffComments',
-            function() { return Promise.resolve(comments); });
+        sandbox.stub(element, '_getDiffComments',
+            () => Promise.resolve(comments));
 
-        var drafts = {
+        const drafts = {
           baseComments: [
             {id: 'bd1', __commentSide: 'left'},
             {id: 'bd2', __commentSide: 'left'},
@@ -521,10 +767,9 @@
           ],
         };
 
-        var diffDraftsStub = sandbox.stub(element, '_getDiffDrafts',
-            function() { return Promise.resolve(drafts); });
+        sandbox.stub(element, '_getDiffDrafts', () => Promise.resolve(drafts));
 
-        var robotComments = {
+        const robotComments = {
           baseComments: [
             {id: 'br1', __commentSide: 'left'},
             {id: 'br2', __commentSide: 'left'},
@@ -535,9 +780,8 @@
           ],
         };
 
-        var diffRobotCommentStub = sandbox.stub(element,
-            '_getDiffRobotComments', function() {
-          return Promise.resolve(robotComments); });
+        sandbox.stub(element,
+            '_getDiffRobotComments', () => Promise.resolve(robotComments));
 
         element.changeNum = '42';
         element.patchRange = {
@@ -547,7 +791,7 @@
         element.path = '/path/to/foo';
         element.projectConfig = {foo: 'bar'};
 
-        element._getDiffCommentsAndDrafts().then(function(result) {
+        element._getDiffCommentsAndDrafts().then(result => {
           assert.deepEqual(result, {
             meta: {
               changeNum: '42',
@@ -580,9 +824,31 @@
         });
       });
 
-      suite('handle comment-update', function() {
+      test('addDraftAtLine', done => {
+        const fakeLineEl = {getAttribute: sandbox.stub().returns(42)};
+        sandbox.stub(element, '_selectLine');
+        sandbox.stub(element, '_addDraft');
+        const loggedInErrorSpy = sandbox.spy();
+        element.addEventListener('show-auth-required', loggedInErrorSpy);
+        element.addDraftAtLine(fakeLineEl);
+        flush(() => {
+          assert.isFalse(loggedInErrorSpy.called);
+          assert.isTrue(element._addDraft.calledWithExactly(fakeLineEl, 42));
+          done();
+        });
+      });
 
-        setup(function() {
+      suite('change in preferences', () => {
+        setup(() => {
+          element._diff = {
+            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+            meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+              lines: 560},
+            diff_header: [],
+            intraline_status: 'OK',
+            change_type: 'MODIFIED',
+            content: [{skip: 66}],
+          };
           element._comments = {
             meta: {
               changeNum: '42',
@@ -608,24 +874,70 @@
           };
         });
 
-        test('creating a draft', function() {
-          var comment = {__draft: true, __draftID: 'tempID', side: 'PARENT',
-              __commentSide: 'left'};
-          element.fire('comment-update', {comment: comment});
+        test('change in preferences re-renders diff', () => {
+          sandbox.stub(element, '_renderDiffTable');
+          element.prefs = {};
+          element.prefs = {time_format: 'HHMM_12'};
+          assert.isTrue(element._renderDiffTable.called);
+        });
+
+        test('change in preferences does not re-renders diff with ' +
+            'noRenderOnPrefsChange', () => {
+          sandbox.stub(element, '_renderDiffTable');
+          element.noRenderOnPrefsChange = true;
+          element.prefs = {};
+          element.prefs = {time_format: 'HHMM_12'};
+          assert.isFalse(element._renderDiffTable.called);
+        });
+      });
+
+      suite('handle comment-update', () => {
+        setup(() => {
+          element._comments = {
+            meta: {
+              changeNum: '42',
+              patchRange: {
+                basePatchNum: 'PARENT',
+                patchNum: 3,
+              },
+              path: '/path/to/foo',
+              projectConfig: {foo: 'bar'},
+            },
+            left: [
+              {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+              {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
+              {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+              {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
+            ],
+            right: [
+              {id: 'c1', __commentSide: 'right'},
+              {id: 'c2', __commentSide: 'right'},
+              {id: 'd1', __draft: true, __commentSide: 'right'},
+              {id: 'd2', __draft: true, __commentSide: 'right'},
+            ],
+          };
+        });
+
+        test('creating a draft', () => {
+          const comment = {__draft: true, __draftID: 'tempID', side: 'PARENT',
+            __commentSide: 'left'};
+          element.fire('comment-update', {comment});
           assert.include(element._comments.left, comment);
         });
 
-        test('saving a draft', function() {
-          var draftID = 'tempID';
-          var id = 'savedID';
-          element._comments.left.push(
-              {__draft: true, __draftID: draftID, side: 'PARENT',
-              __commentSide: 'left'});
-          element.fire('comment-update', {comment:
-              {id: id, __draft: true, __draftID: draftID, side: 'PARENT',
-              __commentSide: 'left'},
-          });
-          var drafts = element._comments.left.filter(function(item) {
+        test('saving a draft', () => {
+          const draftID = 'tempID';
+          const id = 'savedID';
+          const comment = {
+            __draft: true,
+            __draftID: draftID,
+            side: 'PARENT',
+            __commentSide: 'left',
+          };
+          element._comments.left.push(comment);
+          comment.id = id;
+          element.fire('comment-update', {comment});
+          const drafts = element._comments.left.filter(item => {
             return item.__draftID === draftID;
           });
           assert.equal(drafts.length, 1);
@@ -633,5 +945,38 @@
         });
       });
     });
+
+    suite('diff header', () => {
+      setup(() => {
+        element._diff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+            lines: 560},
+          diff_header: [],
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          content: [{skip: 66}],
+        };
+      });
+
+      test('hidden', () => {
+        assert.equal(element._diffHeaderItems.length, 0);
+        element.push('_diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
+        assert.equal(element._diffHeaderItems.length, 0);
+        element.push('_diff.diff_header', 'index 2adc47d..f9c2f2c 100644');
+        assert.equal(element._diffHeaderItems.length, 0);
+        element.push('_diff.diff_header', '--- a/test.jpg');
+        assert.equal(element._diffHeaderItems.length, 0);
+        element.push('_diff.diff_header', '+++ b/test.jpg');
+        assert.equal(element._diffHeaderItems.length, 0);
+        element.push('_diff.diff_header', 'test');
+        assert.equal(element._diffHeaderItems.length, 1);
+        flushAsynchronousOperations();
+
+        assert.equal(element.$.diffHeader.textContent.trim(), 'test');
+        element.set('_diff.binary', true);
+        assert.equal(element._diffHeaderItems.length, 0);
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
index 58d29bd..e9437bd 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
@@ -15,7 +15,7 @@
   'use strict';
 
   // Maximum length for patch set descriptions.
-  var PATCH_DESC_MAX_LENGTH = 500;
+  const PATCH_DESC_MAX_LENGTH = 500;
 
   Polymer({
     is: 'gr-patch-range-select',
@@ -36,15 +36,15 @@
 
     behaviors: [Gerrit.PatchSetBehavior],
 
-    _updateSelected: function() {
+    _updateSelected() {
       this._rightSelected = this.patchRange.patchNum;
       this._leftSelected = this.patchRange.basePatchNum;
     },
 
-    _handlePatchChange: function(e) {
-      var leftPatch = this._leftSelected;
-      var rightPatch = this._rightSelected;
-      var rangeStr = rightPatch;
+    _handlePatchChange(e) {
+      const leftPatch = this._leftSelected;
+      const rightPatch = this._rightSelected;
+      let rangeStr = rightPatch;
       if (leftPatch != 'PARENT') {
         rangeStr = leftPatch + '..' + rangeStr;
       }
@@ -52,11 +52,11 @@
       e.target.blur();
     },
 
-    _computeLeftDisabled: function(patchNum, patchRange) {
+    _computeLeftDisabled(patchNum, patchRange) {
       return parseInt(patchNum, 10) >= parseInt(patchRange.patchNum, 10);
     },
 
-    _computeRightDisabled: function(patchNum, patchRange) {
+    _computeRightDisabled(patchNum, patchRange) {
       if (patchRange.basePatchNum == 'PARENT') { return false; }
       return parseInt(patchNum, 10) <= parseInt(patchRange.basePatchNum, 10);
     },
@@ -66,16 +66,16 @@
     // are loaded, the correct value will get selected.  I attempted to
     // debounce these, but because they are detecting two different
     // events, sometimes the timing was off and one ended up missing.
-    _synchronizeSelectionRight: function() {
+    _synchronizeSelectionRight() {
       this.$.rightPatchSelect.value = this._rightSelected;
     },
 
-    _synchronizeSelectionLeft: function() {
+    _synchronizeSelectionLeft() {
       this.$.leftPatchSelect.value = this._leftSelected;
     },
 
-    _computePatchSetDescription: function(revisions, patchNum) {
-      var rev = this.getRevisionByPatchNum(revisions, patchNum);
+    _computePatchSetDescription(revisions, patchNum) {
+      const rev = this.getRevisionByPatchNum(revisions, patchNum);
       return (rev && rev.description) ?
           rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
     },
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
index 00d73bf..0195eac 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
@@ -34,24 +34,24 @@
 </test-fixture>
 
 <script>
-  suite('gr-patch-range-select tests', function() {
-    var element;
+  suite('gr-patch-range-select tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
     });
 
-    test('enabled/disabled options', function() {
-      var patchRange = {
+    test('enabled/disabled options', () => {
+      const patchRange = {
         basePatchNum: 'PARENT',
         patchNum: '3',
       };
-      ['1', '2', '3'].forEach(function(patchNum) {
+      for (const patchNum of ['1', '2', '3']) {
         assert.isFalse(element._computeRightDisabled(patchNum, patchRange));
-      });
-      ['PARENT', '1', '2'].forEach(function(patchNum) {
+      }
+      for (const patchNum of ['PARENT', '1', '2']) {
         assert.isFalse(element._computeLeftDisabled(patchNum, patchRange));
-      });
+      }
       assert.isTrue(element._computeLeftDisabled('3', patchRange));
 
       patchRange.basePatchNum = '2';
@@ -61,11 +61,11 @@
       assert.isFalse(element._computeRightDisabled('3', patchRange));
     });
 
-    test('navigation', function(done) {
-      var showStub = sinon.stub(page, 'show');
-      var leftSelectEl = element.$.leftPatchSelect;
-      var rightSelectEl = element.$.rightPatchSelect;
-      var blurSpy = sinon.spy(leftSelectEl, 'blur');
+    test('navigation', done => {
+      const showStub = sinon.stub(page, 'show');
+      const leftSelectEl = element.$.leftPatchSelect;
+      const rightSelectEl = element.$.rightPatchSelect;
+      const blurSpy = sinon.spy(leftSelectEl, 'blur');
       element.changeNum = '42';
       element.path = 'path/to/file.txt';
       element.availablePatches = ['1', '2', '3'];
@@ -75,8 +75,8 @@
       };
       flushAsynchronousOperations();
 
-      var numEvents = 0;
-      leftSelectEl.addEventListener('change', function(e) {
+      let numEvents = 0;
+      leftSelectEl.addEventListener('change', e => {
         numEvents++;
         if (numEvents == 1) {
           assert(showStub.lastCall.calledWithExactly(
@@ -98,23 +98,23 @@
       element.fire('change', {}, {node: leftSelectEl});
     });
 
-    test('filesWeblinks', function() {
+    test('filesWeblinks', () => {
       element.filesWeblinks = {
         meta_a: [
           {
             name: 'foo',
             url: 'f.oo',
-          }
+          },
         ],
         meta_b: [
           {
             name: 'bar',
             url: 'ba.r',
-          }
+          },
         ],
       };
       flushAsynchronousOperations();
-      var domApi = Polymer.dom(element.root);
+      const domApi = Polymer.dom(element.root);
       assert.equal(
           domApi.querySelector('a[href="f.oo"]').textContent, 'foo');
       assert.equal(
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
index 1425a79..aa55414 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
@@ -14,13 +14,13 @@
 (function() {
   'use strict';
 
-  var HOVER_PATH_PATTERN = /^comments\.(left|right)\.\#(\d+)\.__hovering$/;
-  var SPLICE_PATH_PATTERN = /^comments\.(left|right)\.splices$/;
+  const HOVER_PATH_PATTERN = /^comments\.(left|right)\.\#(\d+)\.__hovering$/;
+  const SPLICE_PATH_PATTERN = /^comments\.(left|right)\.splices$/;
 
-  var RANGE_HIGHLIGHT = 'range';
-  var HOVER_HIGHLIGHT = 'rangeHighlight';
+  const RANGE_HIGHLIGHT = 'range';
+  const HOVER_HIGHLIGHT = 'rangeHighlight';
 
-  var NORMALIZE_RANGE_EVENT = 'normalize-range';
+  const NORMALIZE_RANGE_EVENT = 'normalize-range';
 
   Polymer({
     is: 'gr-ranged-comment-layer',
@@ -29,11 +29,11 @@
       comments: Object,
       _listeners: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       _commentMap: {
         type: Object,
-        value: function() { return {left: [], right: []}; },
+        value() { return {left: [], right: []}; },
       },
     },
 
@@ -47,8 +47,8 @@
      *     annotation to.
      * @param {GrDiffLine} line The line object.
      */
-    annotate: function(el, line) {
-      var ranges = [];
+    annotate(el, line) {
+      let ranges = [];
       if (line.type === GrDiffLine.Type.REMOVE || (
           line.type === GrDiffLine.Type.BOTH &&
           el.getAttribute('data-side') !== 'right')) {
@@ -60,11 +60,11 @@
         ranges = ranges.concat(this._getRangesForLine(line, 'right'));
       }
 
-      ranges.forEach(function(range) {
+      for (const range of ranges) {
         GrAnnotation.annotateElement(el, range.start,
             range.end - range.start,
             range.hovering ? HOVER_HIGHLIGHT : RANGE_HIGHLIGHT);
-      });
+      }
     },
 
     /**
@@ -73,7 +73,7 @@
      *     Should accept as arguments the line numbers for the start and end of
      *     the update and the side as a string.
      */
-    addListener: function(fn) {
+    addListener(fn) {
       this._listeners.push(fn);
     },
 
@@ -83,10 +83,10 @@
      * @param {Number} end The line where the update ends.
      * @param {String} side The side of the update. ('left' or 'right')
      */
-    _notifyUpdateRange: function(start, end, side) {
-      this._listeners.forEach(function(listener) {
+    _notifyUpdateRange(start, end, side) {
+      for (const listener of this._listeners) {
         listener(start, end, side);
-      });
+      }
     },
 
     /**
@@ -94,7 +94,7 @@
      * emitting appropriate update notifications.
      * @param {Object} record The change record.
      */
-    _handleCommentChange: function(record) {
+    _handleCommentChange(record) {
       if (!record.path) { return; }
 
       // If the entire set of comments was changed.
@@ -105,11 +105,13 @@
       }
 
       // If the change only changed the `hovering` property of a comment.
-      var match = record.path.match(HOVER_PATH_PATTERN);
+      let match = record.path.match(HOVER_PATH_PATTERN);
+      let side;
+
       if (match) {
-        var side = match[1];
-        var index = match[2];
-        var comment = this.comments[side][index];
+        side = match[1];
+        const index = match[2];
+        const comment = this.comments[side][index];
         if (comment && comment.range) {
           this._commentMap[side] = this._computeCommentMap(this.comments[side]);
           this._notifyUpdateRange(
@@ -121,7 +123,7 @@
       // If comments were spliced in or out.
       match = record.path.match(SPLICE_PATH_PATTERN);
       if (match) {
-        var side = match[1];
+        side = match[1];
         this._commentMap[side] = this._computeCommentMap(this.comments[side]);
         this._handleCommentSplice(record.value, side);
       }
@@ -134,44 +136,45 @@
      * @param {Array<Object>} commentList The list of comments.
      * @return {Object} The sparse list.
      */
-    _computeCommentMap: function(commentList) {
-      var result = {};
-      commentList.forEach(function(comment) {
-        if (!comment.range) { return; }
-        var range = comment.range;
-        for (var line = range.start_line; line <= range.end_line; line++) {
+    _computeCommentMap(commentList) {
+      const result = {};
+      for (const comment of commentList) {
+        if (!comment.range) { continue; }
+        const range = comment.range;
+        for (let line = range.start_line; line <= range.end_line; line++) {
           if (!result[line]) { result[line] = []; }
           result[line].push({
-            comment: comment,
+            comment,
             start: line === range.start_line ? range.start_character : 0,
             end: line === range.end_line ? range.end_character : -1,
           });
         }
-      });
+      }
       return result;
     },
 
     /**
      * Translate a splice record into range update notifications.
      */
-    _handleCommentSplice: function(record, side) {
+    _handleCommentSplice(record, side) {
       if (!record || !record.indexSplices) { return; }
-      record.indexSplices.forEach(function(splice) {
-        var ranges = splice.removed.length ?
-          splice.removed.map(function(c) { return c.range; }) :
-          [splice.object[splice.index].range];
-        ranges.forEach(function(range) {
-          if (!range) { return; }
+
+      for (const splice of record.indexSplices) {
+        const ranges = splice.removed.length ?
+            splice.removed.map(c => { return c.range; }) :
+            [splice.object[splice.index].range];
+        for (const range of ranges) {
+          if (!range) { continue; }
           this._notifyUpdateRange(range.start_line, range.end_line, side);
-        }.bind(this));
-      }.bind(this));
+        }
+      }
     },
 
-    _getRangesForLine: function(line, side) {
-      var lineNum = side === 'left' ? line.beforeNumber : line.afterNumber;
-      var ranges = this.get(['_commentMap', side, lineNum]) || [];
+    _getRangesForLine(line, side) {
+      const lineNum = side === 'left' ? line.beforeNumber : line.afterNumber;
+      const ranges = this.get(['_commentMap', side, lineNum]) || [];
       return ranges
-          .map(function(range) {
+          .map(range => {
             range = {
               start: range.start,
               end: range.end === -1 ? line.text.length : range.end,
@@ -189,11 +192,9 @@
             }
 
             return range;
-          }.bind(this))
-          .sort(function(a, b) {
-            // Sort the ranges so that hovering highlights are on top.
-            return a.hovering && !b.hovering ? 1 : 0;
-          });
+          })
+          // Sort the ranges so that hovering highlights are on top.
+          .sort((a, b) => a.hovering && !b.hovering ? 1 : 0);
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
index 20fba4d..1156ba0 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
@@ -34,12 +34,12 @@
 </test-fixture>
 
 <script>
-  suite('gr-ranged-comment-layer', function() {
-    var element;
-    var sandbox;
+  suite('gr-ranged-comment-layer', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
-      var initialComments = {
+    setup(() => {
+      const initialComments = {
         left: [
           {
             id: '12345',
@@ -97,17 +97,17 @@
       element.comments = initialComments;
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    suite('annotate', function() {
-      var sandbox;
-      var el;
-      var line;
-      var annotateElementStub;
+    suite('annotate', () => {
+      let sandbox;
+      let el;
+      let line;
+      let annotateElementStub;
 
-      setup(function() {
+      setup(() => {
         sandbox = sinon.sandbox.create();
         annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
         el = document.createElement('div');
@@ -116,11 +116,11 @@
         line.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit,';
       });
 
-      teardown(function() {
+      teardown(() => {
         sandbox.restore();
       });
 
-      test('type=Remove no-comment', function() {
+      test('type=Remove no-comment', () => {
         line.type = GrDiffLine.Type.REMOVE;
         line.beforeNumber = 40;
 
@@ -129,58 +129,58 @@
         assert.isFalse(annotateElementStub.called);
       });
 
-      test('type=Remove has-comment', function() {
+      test('type=Remove has-comment', () => {
         line.type = GrDiffLine.Type.REMOVE;
         line.beforeNumber = 36;
-        var expectedStart = 6;
-        var expectedLength = line.text.length - expectedStart;
+        const expectedStart = 6;
+        const expectedLength = line.text.length - expectedStart;
 
         element.annotate(el, line);
 
         assert.isTrue(annotateElementStub.called);
-        var lastCall = annotateElementStub.lastCall;
+        const lastCall = annotateElementStub.lastCall;
         assert.equal(lastCall.args[0], el);
         assert.equal(lastCall.args[1], expectedStart);
         assert.equal(lastCall.args[2], expectedLength);
         assert.equal(lastCall.args[3], 'range');
       });
 
-      test('type=Remove has-comment hovering', function() {
+      test('type=Remove has-comment hovering', () => {
         line.type = GrDiffLine.Type.REMOVE;
         line.beforeNumber = 36;
         element.set(['comments', 'left', 0, '__hovering'], true);
 
-        var expectedStart = 6;
-        var expectedLength = line.text.length - expectedStart;
+        const expectedStart = 6;
+        const expectedLength = line.text.length - expectedStart;
 
         element.annotate(el, line);
 
         assert.isTrue(annotateElementStub.called);
-        var lastCall = annotateElementStub.lastCall;
+        const lastCall = annotateElementStub.lastCall;
         assert.equal(lastCall.args[0], el);
         assert.equal(lastCall.args[1], expectedStart);
         assert.equal(lastCall.args[2], expectedLength);
         assert.equal(lastCall.args[3], 'rangeHighlight');
       });
 
-      test('type=Both has-comment', function() {
+      test('type=Both has-comment', () => {
         line.type = GrDiffLine.Type.BOTH;
         line.beforeNumber = 36;
 
-        var expectedStart = 6;
-        var expectedLength = line.text.length - expectedStart;
+        const expectedStart = 6;
+        const expectedLength = line.text.length - expectedStart;
 
         element.annotate(el, line);
 
         assert.isTrue(annotateElementStub.called);
-        var lastCall = annotateElementStub.lastCall;
+        const lastCall = annotateElementStub.lastCall;
         assert.equal(lastCall.args[0], el);
         assert.equal(lastCall.args[1], expectedStart);
         assert.equal(lastCall.args[2], expectedLength);
         assert.equal(lastCall.args[3], 'range');
       });
 
-      test('type=Both has-comment off side', function() {
+      test('type=Both has-comment off side', () => {
         line.type = GrDiffLine.Type.BOTH;
         line.beforeNumber = 36;
         el.setAttribute('data-side', 'right');
@@ -190,18 +190,18 @@
         assert.isFalse(annotateElementStub.called);
       });
 
-      test('type=Add has-comment', function() {
+      test('type=Add has-comment', () => {
         line.type = GrDiffLine.Type.ADD;
         line.afterNumber = 12;
         el.setAttribute('data-side', 'right');
 
-        var expectedStart = 0;
-        var expectedLength = 22;
+        const expectedStart = 0;
+        const expectedLength = 22;
 
         element.annotate(el, line);
 
         assert.isTrue(annotateElementStub.called);
-        var lastCall = annotateElementStub.lastCall;
+        const lastCall = annotateElementStub.lastCall;
         assert.equal(lastCall.args[0], el);
         assert.equal(lastCall.args[1], expectedStart);
         assert.equal(lastCall.args[2], expectedLength);
@@ -209,9 +209,9 @@
       });
     });
 
-    test('_handleCommentChange overwrite', function() {
-      var handlerSpy = sandbox.spy(element, '_handleCommentChange');
-      var mapSpy = sandbox.spy(element, '_computeCommentMap');
+    test('_handleCommentChange overwrite', () => {
+      const handlerSpy = sandbox.spy(element, '_handleCommentChange');
+      const mapSpy = sandbox.spy(element, '_computeCommentMap');
 
       element.set('comments', {left: [], right: []});
 
@@ -222,10 +222,10 @@
       assert.equal(Object.keys(element._commentMap.right).length, 0);
     });
 
-    test('_handleCommentChange hovering', function() {
-      var handlerSpy = sandbox.spy(element, '_handleCommentChange');
-      var mapSpy = sandbox.spy(element, '_computeCommentMap');
-      var notifyStub = sinon.stub();
+    test('_handleCommentChange hovering', () => {
+      const handlerSpy = sandbox.spy(element, '_handleCommentChange');
+      const mapSpy = sandbox.spy(element, '_computeCommentMap');
+      const notifyStub = sinon.stub();
       element.addListener(notifyStub);
 
       element.set(['comments', 'right', 0, '__hovering'], true);
@@ -234,16 +234,16 @@
       assert.isTrue(mapSpy.called);
 
       assert.isTrue(notifyStub.called);
-      var lastCall = notifyStub.lastCall;
+      const lastCall = notifyStub.lastCall;
       assert.equal(lastCall.args[0], 10);
       assert.equal(lastCall.args[1], 12);
       assert.equal(lastCall.args[2], 'right');
     });
 
-    test('_handleCommentChange splice out', function() {
-      var handlerSpy = sandbox.spy(element, '_handleCommentChange');
-      var mapSpy = sandbox.spy(element, '_computeCommentMap');
-      var notifyStub = sinon.stub();
+    test('_handleCommentChange splice out', () => {
+      const handlerSpy = sandbox.spy(element, '_handleCommentChange');
+      const mapSpy = sandbox.spy(element, '_computeCommentMap');
+      const notifyStub = sinon.stub();
       element.addListener(notifyStub);
 
       element.splice('comments.right', 0, 1);
@@ -252,16 +252,16 @@
       assert.isTrue(mapSpy.called);
 
       assert.isTrue(notifyStub.called);
-      var lastCall = notifyStub.lastCall;
+      const lastCall = notifyStub.lastCall;
       assert.equal(lastCall.args[0], 10);
       assert.equal(lastCall.args[1], 12);
       assert.equal(lastCall.args[2], 'right');
     });
 
-    test('_handleCommentChange splice in', function() {
-      var handlerSpy = sandbox.spy(element, '_handleCommentChange');
-      var mapSpy = sandbox.spy(element, '_computeCommentMap');
-      var notifyStub = sinon.stub();
+    test('_handleCommentChange splice in', () => {
+      const handlerSpy = sandbox.spy(element, '_handleCommentChange');
+      const mapSpy = sandbox.spy(element, '_computeCommentMap');
+      const notifyStub = sinon.stub();
       element.addListener(notifyStub);
 
       element.splice('comments.left', element.comments.left.length, 0, {
@@ -280,16 +280,16 @@
       assert.isTrue(mapSpy.called);
 
       assert.isTrue(notifyStub.called);
-      var lastCall = notifyStub.lastCall;
+      const lastCall = notifyStub.lastCall;
       assert.equal(lastCall.args[0], 250);
       assert.equal(lastCall.args[1], 275);
       assert.equal(lastCall.args[2], 'left');
     });
 
-    test('_computeCommentMap creates maps correctly', function() {
+    test('_computeCommentMap creates maps correctly', () => {
       // There is only one ranged comment on the left, but it spans ll.36-39.
-      var leftKeys = [];
-      for (var i = 36; i <= 39; i++) { leftKeys.push('' + i); }
+      const leftKeys = [];
+      for (let i = 36; i <= 39; i++) { leftKeys.push('' + i); }
       assert.deepEqual(Object.keys(element._commentMap.left).sort(),
           leftKeys.sort());
 
@@ -311,8 +311,8 @@
 
       // The right has two ranged comments, one spanning ll.10-12 and the other
       // on line 100.
-      var rightKeys = [];
-      for (i = 10; i <= 12; i++) { rightKeys.push('' + i); }
+      const rightKeys = [];
+      for (let i = 10; i <= 12; i++) { rightKeys.push('' + i); }
       rightKeys.push('55', '100');
       assert.deepEqual(Object.keys(element._commentMap.right).sort(),
           rightKeys.sort());
@@ -334,14 +334,14 @@
       assert.equal(element._commentMap.right[100][0].end, 15);
     });
 
-    test('_getRangesForLine normalizes invalid ranges', function() {
-      var line = {
+    test('_getRangesForLine normalizes invalid ranges', () => {
+      const line = {
         afterNumber: 55,
-        text: '_getRangesForLine normalizes invalid ranges'
+        text: '_getRangesForLine normalizes invalid ranges',
       };
-      var ranges = element._getRangesForLine(line, 'right');
+      const ranges = element._getRangesForLine(line, 'right');
       assert.equal(ranges.length, 1);
-      var range = ranges[0];
+      const range = ranges[0];
       assert.isTrue(range.start < range.end, 'start and end are normalized');
       assert.equal(range.end, line.text.length);
     });
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 0f7f2f2..003de23 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
@@ -26,7 +26,7 @@
     properties: {
       keyEventTarget: {
         type: Object,
-        value: function() { return document.body; },
+        value() { return document.body; },
       },
       range: {
         type: Object,
@@ -48,27 +48,27 @@
     ],
 
     listeners: {
-      'mousedown': '_handleMouseDown', // See https://crbug.com/gerrit/4767
+      mousedown: '_handleMouseDown', // See https://crbug.com/gerrit/4767
     },
 
     keyBindings: {
-      'c': '_handleCKey',
+      c: '_handleCKey',
     },
 
-    placeAbove: function(el) {
-      var rect = this._getTargetBoundingRect(el);
-      var boxRect = this.getBoundingClientRect();
-      var parentRect = this.parentElement.getBoundingClientRect();
+    placeAbove(el) {
+      const rect = this._getTargetBoundingRect(el);
+      const boxRect = this.getBoundingClientRect();
+      const parentRect = this.parentElement.getBoundingClientRect();
       this.style.top =
           rect.top - parentRect.top - boxRect.height - 4 + 'px';
       this.style.left =
           rect.left - parentRect.left + (rect.width - boxRect.width) / 2 + 'px';
     },
 
-    _getTargetBoundingRect: function(el) {
-      var rect;
+    _getTargetBoundingRect(el) {
+      let rect;
       if (el instanceof Text) {
-        var range = document.createRange();
+        const range = document.createRange();
         range.selectNode(el);
         rect = range.getBoundingClientRect();
         range.detach();
@@ -78,7 +78,7 @@
       return rect;
     },
 
-    _handleCKey: function(e) {
+    _handleCKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -86,13 +86,14 @@
       this._fireCreateComment();
     },
 
-    _handleMouseDown: function(e) {
+    _handleMouseDown(e) {
+      if (e.button !== 0) { return; } // 0 = main button
       e.preventDefault();
       e.stopPropagation();
       this._fireCreateComment();
     },
 
-    _fireCreateComment: function() {
+    _fireCreateComment() {
       this.fire('create-comment', {side: this.side, range: this.range});
     },
   });
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
index e9ac0a5..2beb6bc 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
@@ -36,33 +36,57 @@
 </test-fixture>
 
 <script>
-  suite('gr-selection-action-box', function() {
-    var container;
-    var element;
+  suite('gr-selection-action-box', () => {
+    let container;
+    let element;
 
-    setup(function() {
+    setup(() => {
       container = fixture('basic');
       element = container.querySelector('gr-selection-action-box');
       sinon.stub(element, 'fire');
     });
 
-    teardown(function() {
+    teardown(() => {
       element.fire.restore();
     });
 
-    test('ignores regular keys', function() {
+    test('ignores regular keys', () => {
       MockInteractions.pressAndReleaseKeyOn(document.body, 27, null, 'esc');
       assert.isFalse(element.fire.called);
     });
 
-    test('reacts to hotkey', function() {
+    test('reacts to hotkey', () => {
       MockInteractions.pressAndReleaseKeyOn(document.body, 67, null, 'c');
       assert.isTrue(element.fire.called);
     });
 
-    test('event fired contains playload', function() {
-      var side = 'left';
-      var range = {
+    suite('mousedown reacts only to main button', () => {
+      let e;
+
+      setup(() => {
+        e = {
+          button: 0,
+          preventDefault: sinon.stub(),
+          stopPropagation: sinon.stub(),
+        };
+        sinon.stub(element, '_fireCreateComment');
+      });
+
+      test('event handled if main button', () => {
+        element._handleMouseDown(e);
+        assert.isTrue(e.preventDefault.called);
+      });
+
+      test('event ignored if not main button', () => {
+        e.button = 1;
+        element._handleMouseDown(e);
+        assert.isFalse(e.preventDefault.called);
+      });
+    });
+
+    test('event fired contains playload', () => {
+      const side = 'left';
+      const range = {
         startLine: 1,
         startChar: 11,
         endLine: 2,
@@ -74,15 +98,15 @@
       assert(element.fire.calledWithExactly(
           'create-comment',
           {
-            side: side,
-            range: range,
+            side,
+            range,
           }));
     });
 
-    suite('placeAbove', function() {
-      var target;
+    suite('placeAbove', () => {
+      let target;
 
-      setup(function() {
+      setup(() => {
         target = container.querySelector('.target');
         sinon.stub(container, 'getBoundingClientRect').returns(
             {top: 1, bottom: 2, left: 3, right: 4, width: 50, height: 6});
@@ -92,25 +116,25 @@
             {width: 10, height: 10});
       });
 
-      teardown(function() {
+      teardown(() => {
         element.getBoundingClientRect.restore();
         container.getBoundingClientRect.restore();
         element._getTargetBoundingRect.restore();
       });
 
-      test('placeAbove for Element argument', function() {
+      test('placeAbove for Element argument', () => {
         element.placeAbove(target);
         assert.equal(element.style.top, '27px');
         assert.equal(element.style.left, '72px');
       });
 
-      test('placeAbove for Text Node argument', function() {
+      test('placeAbove for Text Node argument', () => {
         element.placeAbove(target.firstChild);
         assert.equal(element.style.top, '27px');
         assert.equal(element.style.left, '72px');
       });
 
-      test('uses document.createRange', function() {
+      test('uses document.createRange', () => {
         sinon.spy(document, 'createRange');
         element._getTargetBoundingRect.restore();
         sinon.spy(element, '_getTargetBoundingRect');
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 0914846..7736c81 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
@@ -14,7 +14,7 @@
 (function() {
   'use strict';
 
-  var LANGUAGE_MAP = {
+  const LANGUAGE_MAP = {
     'application/dart': 'dart',
     'application/json': 'json',
     'application/typescript': 'typescript',
@@ -46,9 +46,9 @@
     'text/x-swift': 'swift',
     'text/x-yaml': 'yaml',
   };
-  var ASYNC_DELAY = 10;
+  const ASYNC_DELAY = 10;
 
-  var CLASS_WHITELIST = {
+  const CLASS_WHITELIST = {
     'gr-diff gr-syntax gr-syntax-literal': true,
     'gr-diff gr-syntax gr-syntax-keyword': true,
     'gr-diff gr-syntax gr-syntax-selector-tag': true,
@@ -77,11 +77,11 @@
     'gr-diff gr-syntax gr-syntax-selector-class': true,
   };
 
-  var CPP_DIRECTIVE_WITH_LT_PATTERN = /^\s*#(if|define).*</;
-  var CPP_WCHAR_PATTERN = /L\'.\'/g;
-  var JAVA_PARAM_ANNOT_PATTERN = /(@[^\s]+)\(([^)]+)\)/g;
-  var GO_BACKSLASH_LITERAL = '\'\\\\\'';
-  var GLOBAL_LT_PATTERN = /</g;
+  const CPP_DIRECTIVE_WITH_LT_PATTERN = /^\s*#(if|define).*</;
+  const CPP_WCHAR_PATTERN = /L\'(\\)?.\'/g;
+  const JAVA_PARAM_ANNOT_PATTERN = /(@[^\s]+)\(([^)]+)\)/g;
+  const GO_BACKSLASH_LITERAL = '\'\\\\\'';
+  const GLOBAL_LT_PATTERN = /</g;
 
   Polymer({
     is: 'gr-syntax-layer',
@@ -97,23 +97,23 @@
       },
       _baseRanges: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       _revisionRanges: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       _baseLanguage: String,
       _revisionLanguage: String,
       _listeners: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       _processHandle: Number,
       _hljs: Object,
     },
 
-    addListener: function(fn) {
+    addListener(fn) {
       this.push('_listeners', fn);
     },
 
@@ -123,11 +123,11 @@
      * @param {!HTMLElement} el
      * @param {!GrDiffLine} line
      */
-    annotate: function(el, line) {
+    annotate(el, line) {
       if (!this.enabled) { return; }
 
       // Determine the side.
-      var side;
+      let side;
       if (line.type === GrDiffLine.Type.REMOVE || (
           line.type === GrDiffLine.Type.BOTH &&
           el.getAttribute('data-side') !== 'right')) {
@@ -138,7 +138,7 @@
       }
 
       // Find the relevant syntax ranges, if any.
-      var ranges = [];
+      let ranges = [];
       if (side === 'left' && this._baseRanges.length >= line.beforeNumber) {
         ranges = this._baseRanges[line.beforeNumber - 1] || [];
       } else if (side === 'right' &&
@@ -147,10 +147,10 @@
       }
 
       // Apply the ranges to the element.
-      ranges.forEach(function(range) {
+      for (const range of ranges) {
         GrAnnotation.annotateElement(
             el, range.start, range.length, range.className);
-      });
+      }
     },
 
     /**
@@ -158,7 +158,7 @@
      * as syntax info comes online.
      * @return {Promise}
      */
-    process: function() {
+    process() {
       // Discard existing ranges.
       this._baseRanges = [];
       this._revisionRanges = [];
@@ -179,7 +179,7 @@
         return Promise.resolve();
       }
 
-      var state = {
+      const state = {
         sectionIndex: 0,
         lineIndex: 0,
         baseContext: undefined,
@@ -188,9 +188,9 @@
         lastNotify: {left: 1, right: 1},
       };
 
-      return this._loadHLJS().then(function() {
-        return new Promise(function(resolve) {
-          var nextStep = function() {
+      return this._loadHLJS().then(() => {
+        return new Promise(resolve => {
+          const nextStep = () => {
             this._processHandle = null;
             this._processNextLine(state);
 
@@ -219,21 +219,21 @@
           };
 
           this._processHandle = this.async(nextStep, 1);
-        }.bind(this));
-      }.bind(this));
+        });
+      });
     },
 
     /**
      * Cancel any asynchronous syntax processing jobs.
      */
-    cancel: function() {
+    cancel() {
       if (this._processHandle) {
         this.cancelAsync(this._processHandle);
         this._processHandle = null;
       }
     },
 
-    _diffChanged: function() {
+    _diffChanged() {
       this.cancel();
       this._baseRanges = [];
       this._revisionRanges = [];
@@ -246,17 +246,16 @@
      * @param {string} str The string of HTML.
      * @return {!Array<!Object>} The list of ranges.
      */
-    _rangesFromString: function(str) {
-      var div = document.createElement('div');
+    _rangesFromString(str) {
+      const div = document.createElement('div');
       div.innerHTML = str;
       return this._rangesFromElement(div, 0);
     },
 
-    _rangesFromElement: function(elem, offset) {
-      var result = [];
-      for (var i = 0; i < elem.childNodes.length; i++) {
-        var node = elem.childNodes[i];
-        var nodeLength = GrAnnotation.getLength(node);
+    _rangesFromElement(elem, offset) {
+      let result = [];
+      for (const node of elem.childNodes) {
+        const 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') {
@@ -281,11 +280,11 @@
      * lines).
      * @param {!Object} state The processing state for the layer.
      */
-    _processNextLine: function(state) {
-      var baseLine;
-      var revisionLine;
+    _processNextLine(state) {
+      let baseLine;
+      let revisionLine;
 
-      var section = this.diff.content[state.sectionIndex];
+      const section = this.diff.content[state.sectionIndex];
       if (section.ab) {
         baseLine = section.ab[state.lineIndex];
         revisionLine = section.ab[state.lineIndex];
@@ -303,7 +302,7 @@
       }
 
       // To store the result of the syntax highlighter.
-      var result;
+      let result;
 
       if (this._baseLanguage && baseLine !== undefined) {
         baseLine = this._workaround(this._baseLanguage, baseLine);
@@ -341,7 +340,7 @@
      * @param {!string} line The line of code to potentially rewrite.
      * @return {string} A potentially-rewritten line of code.
      */
-    _workaround: function(language, line) {
+    _workaround(language, line) {
       if (language === 'cpp') {
         /**
          * Prevent confusing < and << operators for the start of a meta string
@@ -360,7 +359,7 @@
          * {#see https://github.com/isagalaev/highlight.js/issues/1412}
          */
         if (CPP_WCHAR_PATTERN.test(line)) {
-          line = line.replace(CPP_WCHAR_PATTERN, 'L"."');
+          line = line.replace(CPP_WCHAR_PATTERN, 'L"$1."');
         }
 
         return line;
@@ -382,7 +381,7 @@
        * {@see Issue 5007}
        * {#see https://github.com/isagalaev/highlight.js/issues/1411}
        */
-      if (language === 'go' && line.indexOf(GO_BACKSLASH_LITERAL) !== -1) {
+      if (language === 'go' && line.includes(GO_BACKSLASH_LITERAL)) {
         return line.replace(GO_BACKSLASH_LITERAL, '"\\\\"');
       }
 
@@ -394,8 +393,8 @@
      * @param {!Object} state
      * @return {boolean}
      */
-    _isSectionDone: function(state) {
-      var section = this.diff.content[state.sectionIndex];
+    _isSectionDone(state) {
+      const section = this.diff.content[state.sectionIndex];
       if (section.ab) {
         return state.lineIndex >= section.ab.length;
       } else {
@@ -409,33 +408,33 @@
      * that have not yet been notified.
      * @param {!Object} state
      */
-    _notify: function(state) {
+    _notify(state) {
       if (state.lineNums.left - state.lastNotify.left) {
         this._notifyRange(
-          state.lastNotify.left,
-          state.lineNums.left,
-          'left');
+            state.lastNotify.left,
+            state.lineNums.left,
+            'left');
         state.lastNotify.left = state.lineNums.left;
       }
       if (state.lineNums.right - state.lastNotify.right) {
         this._notifyRange(
-          state.lastNotify.right,
-          state.lineNums.right,
-          'right');
+            state.lastNotify.right,
+            state.lineNums.right,
+            'right');
         state.lastNotify.right = state.lineNums.right;
       }
     },
 
-    _notifyRange: function(start, end, side) {
-      this._listeners.forEach(function(fn) {
+    _notifyRange(start, end, side) {
+      for (const fn of this._listeners) {
         fn(start, end, side);
-      });
+      }
     },
 
-    _loadHLJS: function() {
-      return this.$.libLoader.get().then(function(hljs) {
+    _loadHLJS() {
+      return this.$.libLoader.get().then(hljs => {
         this._hljs = hljs;
-      }.bind(this));
+      });
     },
   });
 })();
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 eaa8d29..346a74a 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
@@ -33,17 +33,17 @@
 </test-fixture>
 
 <script>
-  suite('gr-syntax-layer tests', function() {
-    var sandbox;
-    var diff;
-    var element;
+  suite('gr-syntax-layer tests', () => {
+    let sandbox;
+    let diff;
+    let element;
 
     function getMockHLJS() {
-      var html = '<span class="gr-diff gr-syntax gr-syntax-string">' +
+      const html = '<span class="gr-diff gr-syntax gr-syntax-string">' +
           'ipsum</span>';
       return {
-        configure: function() {},
-        highlight: function(lang, line, ignore, state) {
+        configure() {},
+        highlight(lang, line, ignore, state) {
           return {
             value: line.replace(/ipsum/, html),
             top: state === undefined ? 1 : state + 1,
@@ -52,23 +52,23 @@
       };
     }
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
       element = fixture('basic');
-      var mock = document.createElement('mock-diff-response');
+      const mock = document.createElement('mock-diff-response');
       diff = mock.diffResponse;
       element.diff = diff;
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('annotate without range does nothing', function() {
-      var annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
-      var el = document.createElement('div');
+    test('annotate without range does nothing', () => {
+      const annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
+      const el = document.createElement('div');
       el.textContent = 'Etiam dui, blandit wisi.';
-      var line = new GrDiffLine(GrDiffLine.Type.REMOVE);
+      const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
       line.beforeNumber = 12;
 
       element.annotate(el, line);
@@ -76,21 +76,21 @@
       assert.isFalse(annotationSpy.called);
     });
 
-    test('annotate with range applies it', function() {
-      var str = 'Etiam dui, blandit wisi.';
-      var start = 6;
-      var length = 3;
-      var className = 'foobar';
+    test('annotate with range applies it', () => {
+      const str = 'Etiam dui, blandit wisi.';
+      const start = 6;
+      const length = 3;
+      const className = 'foobar';
 
-      var annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
-      var el = document.createElement('div');
+      const annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
+      const el = document.createElement('div');
       el.textContent = str;
-      var line = new GrDiffLine(GrDiffLine.Type.REMOVE);
+      const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
       line.beforeNumber = 12;
       element._baseRanges[11] = [{
-        start: start,
-        length: length,
-        className: className,
+        start,
+        length,
+        className,
       }];
 
       element.annotate(el, line);
@@ -103,21 +103,21 @@
       assert.isOk(el.querySelector('hl.' + className));
     });
 
-    test('annotate with range but disabled does nothing', function() {
-      var str = 'Etiam dui, blandit wisi.';
-      var start = 6;
-      var length = 3;
-      var className = 'foobar';
+    test('annotate with range but disabled does nothing', () => {
+      const str = 'Etiam dui, blandit wisi.';
+      const start = 6;
+      const length = 3;
+      const className = 'foobar';
 
-      var annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
-      var el = document.createElement('div');
+      const annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
+      const el = document.createElement('div');
       el.textContent = str;
-      var line = new GrDiffLine(GrDiffLine.Type.REMOVE);
+      const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
       line.beforeNumber = 12;
       element._baseRanges[11] = [{
-        start: start,
-        length: length,
-        className: className,
+        start,
+        length,
+        className,
       }];
       element.enabled = false;
 
@@ -126,17 +126,17 @@
       assert.isFalse(annotationSpy.called);
     });
 
-    test('process on empty diff does nothing', function(done) {
+    test('process on empty diff does nothing', done => {
       element.diff = {
         meta_a: {content_type: 'application/json'},
         meta_b: {content_type: 'application/json'},
         content: [],
       };
-      var processNextSpy = sandbox.spy(element, '_processNextLine');
+      const processNextSpy = sandbox.spy(element, '_processNextLine');
 
-      var processPromise = element.process();
+      const processPromise = element.process();
 
-      processPromise.then(function() {
+      processPromise.then(() => {
         assert.isFalse(processNextSpy.called);
         assert.equal(element._baseRanges.length, 0);
         assert.equal(element._revisionRanges.length, 0);
@@ -144,17 +144,17 @@
       });
     });
 
-    test('process for unsupported languages does nothing', function(done) {
+    test('process for unsupported languages does nothing', done => {
       element.diff = {
         meta_a: {content_type: 'text/x+objective-cobol-plus-plus'},
         meta_b: {content_type: 'application/not-a-real-language'},
         content: [],
       };
-      var processNextSpy = sandbox.spy(element, '_processNextLine');
+      const processNextSpy = sandbox.spy(element, '_processNextLine');
 
-      var processPromise = element.process();
+      const processPromise = element.process();
 
-      processPromise.then(function() {
+      processPromise.then(() => {
         assert.isFalse(processNextSpy.called);
         assert.equal(element._baseRanges.length, 0);
         assert.equal(element._revisionRanges.length, 0);
@@ -162,14 +162,14 @@
       });
     });
 
-    test('process while disabled does nothing', function(done) {
-      var processNextSpy = sandbox.spy(element, '_processNextLine');
+    test('process while disabled does nothing', done => {
+      const processNextSpy = sandbox.spy(element, '_processNextLine');
       element.enabled = false;
-      var loadHLJSSpy = sandbox.spy(element, '_loadHLJS');
+      const loadHLJSSpy = sandbox.spy(element, '_loadHLJS');
 
-      var processPromise = element.process();
+      const processPromise = element.process();
 
-      processPromise.then(function() {
+      processPromise.then(() => {
         assert.isFalse(processNextSpy.called);
         assert.equal(element._baseRanges.length, 0);
         assert.equal(element._revisionRanges.length, 0);
@@ -178,20 +178,20 @@
       });
     });
 
-    test('process highlight ipsum', function(done) {
+    test('process highlight ipsum', done => {
       element.diff.meta_a.content_type = 'application/json';
       element.diff.meta_b.content_type = 'application/json';
 
-      var mockHLJS = getMockHLJS();
-      var highlightSpy = sinon.spy(mockHLJS, 'highlight');
+      const mockHLJS = getMockHLJS();
+      const highlightSpy = sinon.spy(mockHLJS, 'highlight');
       sandbox.stub(element.$.libLoader, 'get',
-          function() { return Promise.resolve(mockHLJS); });
-      var processNextSpy = sandbox.spy(element, '_processNextLine');
-      var processPromise = element.process();
+          () => { return Promise.resolve(mockHLJS); });
+      const processNextSpy = sandbox.spy(element, '_processNextLine');
+      const processPromise = element.process();
 
-      processPromise.then(function() {
-        var linesA = diff.meta_a.lines;
-        var linesB = diff.meta_b.lines;
+      processPromise.then(() => {
+        const linesA = diff.meta_a.lines;
+        const linesB = diff.meta_b.lines;
 
         assert.isTrue(processNextSpy.called);
         assert.equal(element._baseRanges.length, linesA);
@@ -200,37 +200,39 @@
         assert.equal(highlightSpy.callCount, linesA + linesB);
 
         // The first line of both sides have a range.
-        [element._baseRanges[0], element._revisionRanges[0]]
-            .forEach(function(range) {
-              assert.equal(range.length, 1);
-              assert.equal(range[0].className,
-                  'gr-diff gr-syntax gr-syntax-string');
-              assert.equal(range[0].start, 'lorem '.length);
-              assert.equal(range[0].length, 'ipsum'.length);
-            });
+        let ranges = [element._baseRanges[0], element._revisionRanges[0]];
+        for (const range of ranges) {
+          assert.equal(range.length, 1);
+          assert.equal(range[0].className,
+              'gr-diff gr-syntax gr-syntax-string');
+          assert.equal(range[0].start, 'lorem '.length);
+          assert.equal(range[0].length, 'ipsum'.length);
+        }
 
         // There are no ranges from ll.1-12 on the left and ll.1-11 on the
         // right.
-        element._baseRanges.slice(1, 12)
-            .concat(element._revisionRanges.slice(1, 11))
-            .forEach(function(range) {
-              assert.equal(range.length, 0);
-            });
+        ranges = element._baseRanges.slice(1, 12)
+            .concat(element._revisionRanges.slice(1, 11));
+
+        for (const range of ranges) {
+          assert.equal(range.length, 0);
+        }
 
         // There should be another pair of ranges on l.13 for the left and
         // l.12 for the right.
-        [element._baseRanges[13], element._revisionRanges[12]]
-            .forEach(function(range) {
-              assert.equal(range.length, 1);
-              assert.equal(range[0].className,
-                  'gr-diff gr-syntax gr-syntax-string');
-              assert.equal(range[0].start, 32);
-              assert.equal(range[0].length, 'ipsum'.length);
-            });
+        ranges = [element._baseRanges[13], element._revisionRanges[12]];
+
+        for (const range of ranges) {
+          assert.equal(range.length, 1);
+          assert.equal(range[0].className,
+              'gr-diff gr-syntax gr-syntax-string');
+          assert.equal(range[0].start, 32);
+          assert.equal(range[0].length, 'ipsum'.length);
+        }
 
         // The next group should have a similar instance on either side.
 
-        var range = element._baseRanges[15];
+        let range = element._baseRanges[15];
         assert.equal(range.length, 1);
         assert.equal(range[0].className, 'gr-diff gr-syntax gr-syntax-string');
         assert.equal(range[0].start, 34);
@@ -246,38 +248,38 @@
       });
     });
 
-    test('_diffChanged calls cancel', function() {
-      var cancelSpy = sandbox.spy(element, '_diffChanged');
+    test('_diffChanged calls cancel', () => {
+      const cancelSpy = sandbox.spy(element, '_diffChanged');
       element.diff = {content: []};
       assert.isTrue(cancelSpy.called);
     });
 
-    test('_rangesFromElement no ranges', function() {
-      var elem = document.createElement('span');
+    test('_rangesFromElement no ranges', () => {
+      const elem = document.createElement('span');
       elem.textContent = 'Etiam dui, blandit wisi.';
-      var offset = 100;
+      const offset = 100;
 
-      var result = element._rangesFromElement(elem, offset);
+      const result = element._rangesFromElement(elem, offset);
 
       assert.equal(result.length, 0);
     });
 
-    test('_rangesFromElement single range', function() {
-      var str0 = 'Etiam ';
-      var str1 = 'dui, blandit';
-      var str2 = ' wisi.';
-      var className = 'gr-diff gr-syntax gr-syntax-string';
-      var offset = 100;
+    test('_rangesFromElement single range', () => {
+      const str0 = 'Etiam ';
+      const str1 = 'dui, blandit';
+      const str2 = ' wisi.';
+      const className = 'gr-diff gr-syntax gr-syntax-string';
+      const offset = 100;
 
-      var elem = document.createElement('span');
+      const elem = document.createElement('span');
       elem.appendChild(document.createTextNode(str0));
-      var span = document.createElement('span');
+      const span = document.createElement('span');
       span.textContent = str1;
       span.className = className;
       elem.appendChild(span);
       elem.appendChild(document.createTextNode(str2));
 
-      var result = element._rangesFromElement(elem, offset);
+      const result = element._rangesFromElement(elem, offset);
 
       assert.equal(result.length, 1);
       assert.equal(result[0].start, str0.length + offset);
@@ -285,37 +287,37 @@
       assert.equal(result[0].className, className);
     });
 
-    test('_rangesFromElement non-whitelist', function() {
-      var str0 = 'Etiam ';
-      var str1 = 'dui, blandit';
-      var str2 = ' wisi.';
-      var className = 'not-in-the-whitelist';
-      var offset = 100;
+    test('_rangesFromElement non-whitelist', () => {
+      const str0 = 'Etiam ';
+      const str1 = 'dui, blandit';
+      const str2 = ' wisi.';
+      const className = 'not-in-the-whitelist';
+      const offset = 100;
 
-      var elem = document.createElement('span');
+      const elem = document.createElement('span');
       elem.appendChild(document.createTextNode(str0));
-      var span = document.createElement('span');
+      const span = document.createElement('span');
       span.textContent = str1;
       span.className = className;
       elem.appendChild(span);
       elem.appendChild(document.createTextNode(str2));
 
-      var result = element._rangesFromElement(elem, offset);
+      const result = element._rangesFromElement(elem, offset);
 
       assert.equal(result.length, 0);
     });
 
-    test('_rangesFromElement milti range', function() {
-      var str0 = 'Etiam ';
-      var str1 = 'dui,';
-      var str2 = ' blandit';
-      var str3 = ' wisi.';
-      var className = 'gr-diff gr-syntax gr-syntax-string';
-      var offset = 100;
+    test('_rangesFromElement milti range', () => {
+      const str0 = 'Etiam ';
+      const str1 = 'dui,';
+      const str2 = ' blandit';
+      const str3 = ' wisi.';
+      const className = 'gr-diff gr-syntax gr-syntax-string';
+      const offset = 100;
 
-      var elem = document.createElement('span');
+      const elem = document.createElement('span');
       elem.appendChild(document.createTextNode(str0));
-      var span = document.createElement('span');
+      let span = document.createElement('span');
       span.textContent = str1;
       span.className = className;
       elem.appendChild(span);
@@ -325,7 +327,7 @@
       span.className = className;
       elem.appendChild(span);
 
-      var result = element._rangesFromElement(elem, offset);
+      const result = element._rangesFromElement(elem, offset);
 
       assert.equal(result.length, 2);
 
@@ -339,27 +341,27 @@
       assert.equal(result[1].className, className);
     });
 
-    test('_rangesFromElement nested range', function() {
-      var str0 = 'Etiam ';
-      var str1 = 'dui,';
-      var str2 = ' blandit';
-      var str3 = ' wisi.';
-      var className = 'gr-diff gr-syntax gr-syntax-string';
-      var offset = 100;
+    test('_rangesFromElement nested range', () => {
+      const str0 = 'Etiam ';
+      const str1 = 'dui,';
+      const str2 = ' blandit';
+      const str3 = ' wisi.';
+      const className = 'gr-diff gr-syntax gr-syntax-string';
+      const offset = 100;
 
-      var elem = document.createElement('span');
+      const elem = document.createElement('span');
       elem.appendChild(document.createTextNode(str0));
-      var span1 = document.createElement('span');
+      const span1 = document.createElement('span');
       span1.textContent = str1;
       span1.className = className;
       elem.appendChild(span1);
-      var span2 = document.createElement('span');
+      const span2 = document.createElement('span');
       span2.textContent = str2;
       span2.className = className;
       span1.appendChild(span2);
       elem.appendChild(document.createTextNode(str3));
 
-      var result = element._rangesFromElement(elem, offset);
+      const result = element._rangesFromElement(elem, offset);
 
       assert.equal(result.length, 2);
 
@@ -372,17 +374,17 @@
       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);
+    test('_rangesFromString whitelist allows recursion', () => {
+      const str = [
+        '<span class="non-whtelisted-class">',
+        '<span class="gr-diff gr-syntax gr-syntax-keyword">public</span>',
+        '</span>'].join('');
+      const result = element._rangesFromString(str);
       assert.notEqual(result.length, 0);
     });
 
-    test('_isSectionDone', function() {
-      var state = {sectionIndex: 0, lineIndex: 0};
+    test('_isSectionDone', () => {
+      let state = {sectionIndex: 0, lineIndex: 0};
       assert.isFalse(element._isSectionDone(state));
 
       state = {sectionIndex: 0, lineIndex: 2};
@@ -407,9 +409,9 @@
       assert.isTrue(element._isSectionDone(state));
     });
 
-    test('workaround CPP LT directive', function() {
+    test('workaround CPP LT directive', () => {
       // Does nothing to regular line.
-      var line = 'int main(int argc, char** argv) { return 0; }';
+      let line = 'int main(int argc, char** argv) { return 0; }';
       assert.equal(element._workaround('cpp', line), line);
 
       // Does nothing to include directive.
@@ -418,7 +420,7 @@
 
       // Converts left-shift operator in #define.
       line = '#define GiB (1ull << 30)';
-      var expected = '#define GiB (1ull || 30)';
+      let expected = '#define GiB (1ull || 30)';
       assert.equal(element._workaround('cpp', line), expected);
 
       // Converts less-than operator in #if.
@@ -427,9 +429,9 @@
       assert.equal(element._workaround('cpp', line), expected);
     });
 
-    test('workaround Java param-annotation', function() {
+    test('workaround Java param-annotation', () => {
       // Does nothing to regular line.
-      var line = 'public static void foo(int bar) { }';
+      let line = 'public static void foo(int bar) { }';
       assert.equal(element._workaround('java', line), line);
 
       // Does nothing to regular annotation.
@@ -438,14 +440,14 @@
 
       // Converts parameterized annotation.
       line = 'public static void foo(@SuppressWarnings("unused") int bar) { }';
-      var expected = 'public static void foo(@SuppressWarnings "unused" ' +
+      const expected = 'public static void foo(@SuppressWarnings "unused" ' +
           ' int bar) { }';
       assert.equal(element._workaround('java', line), expected);
     });
 
-    test('workaround CPP whcar_t character literals', function() {
+    test('workaround CPP whcar_t character literals', () => {
       // Does nothing to regular line.
-      var line = 'int main(int argc, char** argv) { return 0; }';
+      let line = 'int main(int argc, char** argv) { return 0; }';
       assert.equal(element._workaround('cpp', line), line);
 
       // Does nothing to wchar_t string.
@@ -454,13 +456,18 @@
 
       // Converts wchar_t character literal to string.
       line = 'wchar_t myChar = L\'#\'';
-      var expected = 'wchar_t myChar = L"."';
+      let expected = 'wchar_t myChar = L"."';
+      assert.equal(element._workaround('cpp', line), expected);
+
+      // Converts wchar_t character literal with escape sequence to string.
+      line = 'wchar_t myChar = L\'\\"\'';
+      expected = 'wchar_t myChar = L"\\."';
       assert.equal(element._workaround('cpp', line), expected);
     });
 
-    test('workaround go backslash character literals', function() {
+    test('workaround go backslash character literals', () => {
       // Does nothing to regular line.
-      var line = 'func foo(in []byte) (lit []byte, n int, err error) {';
+      let line = 'func foo(in []byte) (lit []byte, n int, err error) {';
       assert.equal(element._workaround('go', line), line);
 
       // Does nothing to string with backslash literal
@@ -469,7 +476,7 @@
 
       // Converts backslash literal character to a string.
       line = 'c := \'\\\\\'';
-      var expected = 'c := "\\\\"';
+      const expected = 'c := "\\\\"';
       assert.equal(element._workaround('go', line), expected);
     });
   });
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader.js b/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader.js
index 520f24d..75b00e8 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader.js
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader.js
@@ -14,8 +14,8 @@
 (function() {
   'use strict';
 
-  var HLJS_PATH = 'bower_components/highlightjs/highlight.min.js';
-  var LIB_ROOT_PATTERN = /(.+\/)elements\/gr-app\.html/;
+  const HLJS_PATH = 'bower_components/highlightjs/highlight.min.js';
+  const LIB_ROOT_PATTERN = /(.+\/)elements\/gr-app\.html/;
 
   Polymer({
     is: 'gr-syntax-lib-loader',
@@ -30,11 +30,11 @@
           loading: false,
           callbacks: [],
         },
-      }
+      },
     },
 
-    get: function() {
-      return new Promise(function(resolve) {
+    get() {
+      return new Promise(resolve => {
         // If the lib is totally loaded, resolve immediately.
         if (this._state.loaded) {
           resolve(this._getHighlightLib());
@@ -48,27 +48,29 @@
         }
 
         this._state.callbacks.push(resolve);
-      }.bind(this));
+      });
     },
 
-    _onLibLoaded: function() {
-      var lib = this._getHighlightLib();
+    _onLibLoaded() {
+      const lib = this._getHighlightLib();
       this._state.loaded = true;
       this._state.loading = false;
-      this._state.callbacks.forEach(function(cb) { cb(lib); });
+      for (const cb of this._state.callbacks) {
+        cb(lib);
+      }
       this._state.callbacks = [];
     },
 
-    _getHighlightLib: function() {
+    _getHighlightLib() {
       return window.hljs;
     },
 
-    _configureHighlightLib: function() {
+    _configureHighlightLib() {
       this._getHighlightLib().configure(
           {classPrefix: 'gr-diff gr-syntax gr-syntax-'});
     },
 
-    _getLibRoot: function() {
+    _getLibRoot() {
       if (this._cachedLibRoot) { return this._cachedLibRoot; }
 
       return this._cachedLibRoot = document.head
@@ -78,16 +80,16 @@
     },
     _cachedLibRoot: null,
 
-    _loadHLJS: function() {
-      return new Promise(function(resolve) {
-        var script = document.createElement('script');
+    _loadHLJS() {
+      return new Promise(resolve => {
+        const script = document.createElement('script');
         script.src = this._getLibRoot() + HLJS_PATH;
         script.onload = function() {
           this._configureHighlightLib();
           resolve();
         }.bind(this);
         Polymer.dom(document.head).appendChild(script);
-      }.bind(this));
+      });
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader_test.html b/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader_test.html
index 985fc6d..0a916d0 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader_test.html
@@ -32,26 +32,24 @@
 </test-fixture>
 
 <script>
-  suite('gr-syntax-lib-loader tests', function() {
-    var element;
-    var resolveLoad;
-    var loadStub;
+  suite('gr-syntax-lib-loader tests', () => {
+    let element;
+    let resolveLoad;
+    let loadStub;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
 
-      loadStub = sinon.stub(element, '_loadHLJS', function() {
-        return new Promise(function(resolve) {
-          resolveLoad = resolve;
-        });
-      });
+      loadStub = sinon.stub(element, '_loadHLJS', () =>
+        new Promise(resolve => resolveLoad = resolve)
+      );
 
       // Assert preconditions:
       assert.isFalse(element._state.loaded);
       assert.isFalse(element._state.loading);
     });
 
-    teardown(function() {
+    teardown(() => {
       if (window.hljs) {
         delete window.hljs;
       }
@@ -63,8 +61,8 @@
       element._state.callbacks = [];
     });
 
-    test('only load once', function(done) {
-      var firstCallHandler = sinon.stub();
+    test('only load once', done => {
+      const firstCallHandler = sinon.stub();
       element.get().then(firstCallHandler);
 
       // It should now be in the loading state.
@@ -73,7 +71,7 @@
       assert.isFalse(element._state.loaded);
       assert.isFalse(firstCallHandler.called);
 
-      var secondCallHandler = sinon.stub();
+      const secondCallHandler = sinon.stub();
       element.get().then(secondCallHandler);
 
       // No change in state.
@@ -84,7 +82,7 @@
 
       // Now load the library.
       resolveLoad();
-      flush(function() {
+      flush(() => {
         // The state should be loaded and both handlers called.
         assert.isFalse(element._state.loading);
         assert.isTrue(element._state.loaded);
diff --git a/polygerrit-ui/app/elements/gr-app-it_test.html b/polygerrit-ui/app/elements/gr-app-it_test.html
new file mode 100644
index 0000000..67c5139
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app-it_test.html
@@ -0,0 +1,93 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-app-it_test</title>
+
+<script src="../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-app.html">
+
+<script>void(0);</script>
+
+<test-fixture id="element">
+  <template>
+    <gr-app id="app"></gr-app>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-app integration tests', () => {
+    let sandbox;
+    let element;
+
+    setup(done => {
+      sandbox = sinon.sandbox.create();
+      stub('gr-reporting', {
+        appStarted: sandbox.stub(),
+      });
+      stub('gr-account-dropdown', {
+        _getTopContent: sinon.stub(),
+      });
+      stub('gr-rest-api-interface', {
+        getAccount() { return Promise.resolve(null); },
+        getAccountCapabilities() { return Promise.resolve({}); },
+        getConfig() {
+          return Promise.resolve({
+            gerrit: {web_uis: ['GWT', 'POLYGERRIT']},
+            plugin: {
+              js_resource_paths: [],
+              html_resource_paths: [
+                new URL('test/plugin.html', window.location.href).toString(),
+              ],
+            },
+          });
+        },
+        getVersion() { return Promise.resolve(42); },
+        getLoggedIn() { return Promise.resolve(false); },
+      });
+      element = fixture('element');
+
+      const importSpy = sandbox.spy(element.$.externalStyle, '_import');
+      Gerrit.awaitPluginsLoaded().then(() => {
+        Promise.all(importSpy.returnValues).then(() => {
+          flush(done);
+        });
+      });
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('applies --primary-text-color', () => {
+      assert.equal(
+          element.getComputedStyleValue('--primary-text-color'), '#F00BAA');
+    });
+
+    test('applies --header-background-color', () => {
+      assert.equal(element.getComputedStyleValue('--header-background-color'),
+          '#F01BAA');
+    });
+    test('applies --footer-background-color', () => {
+      assert.equal(element.getComputedStyleValue('--footer-background-color'),
+          '#F02BAA');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
index c92610bf..0fe48fd 100644
--- a/polygerrit-ui/app/elements/gr-app.html
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -14,27 +14,26 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../bower_components/polymer/polymer.html">
 <link rel="import" href="../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../bower_components/polymer/polymer.html">
 <link rel="import" href="../styles/app-theme.html">
-
+<link rel="import" href="./admin/gr-admin-project-list/gr-admin-project-list.html">
 <link rel="import" href="./admin/gr-admin-view/gr-admin-view.html">
-
-<link rel="import" href="./core/gr-error-manager/gr-error-manager.html">
-<link rel="import" href="./core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html">
-<link rel="import" href="./core/gr-main-header/gr-main-header.html">
-<link rel="import" href="./core/gr-router/gr-router.html">
-<link rel="import" href="./core/gr-reporting/gr-reporting.html">
-
 <link rel="import" href="./change-list/gr-change-list-view/gr-change-list-view.html">
 <link rel="import" href="./change-list/gr-dashboard-view/gr-dashboard-view.html">
 <link rel="import" href="./change/gr-change-view/gr-change-view.html">
+<link rel="import" href="./core/gr-error-manager/gr-error-manager.html">
+<link rel="import" href="./core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html">
+<link rel="import" href="./core/gr-main-header/gr-main-header.html">
+<link rel="import" href="./core/gr-reporting/gr-reporting.html">
+<link rel="import" href="./core/gr-router/gr-router.html">
 <link rel="import" href="./diff/gr-diff-view/gr-diff-view.html">
+<link rel="import" href="./plugins/gr-external-style/gr-external-style.html">
+<link rel="import" href="./plugins/gr-plugin-host/gr-plugin-host.html">
 <link rel="import" href="./settings/gr-cla-view/gr-cla-view.html">
 <link rel="import" href="./settings/gr-registration-dialog/gr-registration-dialog.html">
 <link rel="import" href="./settings/gr-settings-view/gr-settings-view.html">
-
 <link rel="import" href="./shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="./shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
@@ -45,7 +44,7 @@
     <style>
       :host {
         display: flex;
-        min-height: 100vh;
+        min-height: 100%;
         flex-direction: column;
       }
       gr-main-header,
@@ -53,11 +52,11 @@
         color: var(--primary-text-color);
       }
       gr-main-header {
-        background-color: var(--header-background-color, #eee);
+        background-color: var(--header-background-color);
         padding: 0 var(--default-horizontal-margin);
       }
       footer {
-        background-color: var(--footer-background-color, #eee);
+        background-color: var(--footer-background-color);
         display: flex;
         justify-content: space-between;
         padding: .5rem var(--default-horizontal-margin);
@@ -127,6 +126,11 @@
             on-account-detail-update="_handleAccountDetailUpdate">
         </gr-settings-view>
       </template>
+      <template is="dom-if" if="[[_showProjectListView]]" restamp="true">
+        <gr-admin-project-list
+            params="[[params]]"
+            id="projectList"></gr-admin-project-list>
+      </template>
       <template is="dom-if" if="[[_showAdminView]]" restamp="true">
         <gr-admin-view path="[[_path]]"></gr-admin-view>
       </template>
@@ -171,6 +175,10 @@
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-reporting id="reporting"></gr-reporting>
     <gr-router id="router"></gr-router>
+    <gr-plugin-host id="plugins"
+        config="[[_serverConfig.plugin]]">
+    </gr-plugin-host>
+    <gr-external-style id="externalStyle" name="app-theme"></gr-external-style>
   </template>
   <script src="gr-app.js" crossorigin="anonymous"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index d820bc7..846c186 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -34,7 +34,7 @@
       params: Object,
       keyEventTarget: {
         type: Object,
-        value: function() { return document.body; },
+        value() { return document.body; },
       },
 
       _account: {
@@ -48,6 +48,9 @@
       _showChangeView: Boolean,
       _showDiffView: Boolean,
       _showSettingsView: Boolean,
+      _showProjectListView: Boolean,
+      _showAdminView: Boolean,
+      _showCLAView: Boolean,
       _viewState: Object,
       _lastError: Object,
       _lastSearchPage: String,
@@ -62,7 +65,6 @@
 
     observers: [
       '_viewChanged(params.view)',
-      '_loadPlugins(_serverConfig.plugin.js_resource_paths)',
     ],
 
     behaviors: [
@@ -74,18 +76,18 @@
       '?': '_showKeyboardShortcuts',
     },
 
-    ready: function() {
+    ready() {
       this.$.router.start();
 
-      this.$.restAPI.getAccount().then(function(account) {
+      this.$.restAPI.getAccount().then(account => {
         this._account = account;
-      }.bind(this));
-      this.$.restAPI.getConfig().then(function(config) {
+      });
+      this.$.restAPI.getConfig().then(config => {
         this._serverConfig = config;
-      }.bind(this));
-      this.$.restAPI.getVersion().then(function(version) {
+      });
+      this.$.restAPI.getVersion().then(version => {
         this._version = version;
-      }.bind(this));
+      });
 
       this.$.reporting.appStarted();
       this._viewState = {
@@ -95,6 +97,8 @@
           selectedFileIndex: 0,
           showReplyDialog: false,
           diffMode: null,
+          numFilesShown: null,
+          scrollTop: 0,
         },
         changeListView: {
           query: null,
@@ -107,23 +111,24 @@
       };
     },
 
-    _accountChanged: function(account) {
+    _accountChanged(account) {
       if (!account) { return; }
 
       // Preferences are cached when a user is logged in; warm them.
       this.$.restAPI.getPreferences();
       this.$.restAPI.getDiffPreferences();
       this.$.errorManager.knownAccountId =
-        this._account && this._account._account_id || null;
+          this._account && this._account._account_id || null;
     },
 
-    _viewChanged: function(view) {
+    _viewChanged(view) {
       this.$.errorView.hidden = true;
       this.set('_showChangeListView', view === 'gr-change-list-view');
       this.set('_showDashboardView', view === 'gr-dashboard-view');
       this.set('_showChangeView', view === 'gr-change-view');
       this.set('_showDiffView', view === 'gr-diff-view');
       this.set('_showSettingsView', view === 'gr-settings-view');
+      this.set('_showProjectListView', view === 'gr-admin-project-list');
       this.set('_showAdminView', view === 'gr-admin-view');
       this.set('_showCLAView', view === 'gr-cla-view');
       if (this.params.justRegistered) {
@@ -131,80 +136,69 @@
       }
     },
 
-    _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);
-      }
-    },
-
-    _loginTapHandler: function(e) {
+    _loginTapHandler(e) {
       e.preventDefault();
       page.show('/login/' + encodeURIComponent(
           window.location.pathname + window.location.hash));
     },
 
     // Argument used for binding update only.
-    _computeLoggedIn: function(account) {
+    _computeLoggedIn(account) {
       return !!(account && Object.keys(account).length > 0);
     },
 
-    _computeShowGwtUiLink: function(config) {
-      return config.gerrit.web_uis &&
-          config.gerrit.web_uis.indexOf('GWT') !== -1;
+    _computeShowGwtUiLink(config) {
+      return config.gerrit.web_uis && config.gerrit.web_uis.includes('GWT');
     },
 
-    _handlePageError: function(e) {
-      [
+    _handlePageError(e) {
+      const props = [
         '_showChangeListView',
         '_showDashboardView',
         '_showChangeView',
         '_showDiffView',
         '_showSettingsView',
-      ].forEach(function(showProp) {
+      ];
+      for (const showProp of props) {
         this.set(showProp, false);
-      }.bind(this));
+      }
 
       this.$.errorView.hidden = false;
-      var response = e.detail.response;
-      var err = {text: [response.status, response.statusText].join(' ')};
+      const response = e.detail.response;
+      const err = {text: [response.status, response.statusText].join(' ')};
       if (response.status === 404) {
         err.emoji = '¯\\_(ツ)_/¯';
         this._lastError = err;
       } else {
         err.emoji = 'o_O';
-        response.text().then(function(text) {
+        response.text().then(text => {
           err.moreInfo = text;
           this._lastError = err;
-        }.bind(this));
+        });
       }
     },
 
-    _handleLocationChange: function(e) {
-      var hash = e.detail.hash.substring(1);
-      var pathname = e.detail.pathname;
-      if (pathname.indexOf('/c/') === 0 && parseInt(hash, 10) > 0) {
+    _handleLocationChange(e) {
+      const hash = e.detail.hash.substring(1);
+      let pathname = e.detail.pathname;
+      if (pathname.startsWith('/c/') && parseInt(hash, 10) > 0) {
         pathname += '@' + hash;
       }
       this.set('_path', pathname);
       this._handleSearchPageChange();
     },
 
-    _handleSearchPageChange: function() {
+    _handleSearchPageChange() {
       if (!this.params) {
         return;
       }
-      var viewsToCheck = ['gr-change-list-view', 'gr-dashboard-view'];
-      if (viewsToCheck.indexOf(this.params.view) !== -1) {
+      const viewsToCheck = ['gr-change-list-view', 'gr-dashboard-view'];
+      if (viewsToCheck.includes(this.params.view)) {
         this.set('_lastSearchPage', location.pathname);
       }
     },
 
-    _handleTitleChange: function(e) {
+    _handleTitleChange(e) {
       if (e.detail.title) {
         document.title = e.detail.title + ' · Gerrit Code Review';
       } else {
@@ -212,23 +206,23 @@
       }
     },
 
-    _showKeyboardShortcuts: function(e) {
+    _showKeyboardShortcuts(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       this.$.keyboardShortcuts.open();
     },
 
-    _handleKeyboardShortcutDialogClose: function() {
+    _handleKeyboardShortcutDialogClose() {
       this.$.keyboardShortcuts.close();
     },
 
-    _handleAccountDetailUpdate: function(e) {
+    _handleAccountDetailUpdate(e) {
       this.$.mainHeader.reload();
       if (this.params.view === 'gr-settings-view') {
         this.$$('gr-settings-view').reloadAccountDetail();
       }
     },
 
-    _handleRegistrationDialogClose: function(e) {
+    _handleRegistrationDialogClose(e) {
       this.params.justRegistered = false;
       this.$.registration.close();
     },
diff --git a/polygerrit-ui/app/elements/gr-app_test.html b/polygerrit-ui/app/elements/gr-app_test.html
index 28251fe..e25869b 100644
--- a/polygerrit-ui/app/elements/gr-app_test.html
+++ b/polygerrit-ui/app/elements/gr-app_test.html
@@ -18,7 +18,7 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-app</title>
 
-<script src="../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../bower_components/web-component-tester/browser.js"></script>
 
 <link rel="import" href="gr-app.html">
@@ -32,11 +32,11 @@
 </test-fixture>
 
 <script>
-  suite('gr-app tests', function() {
-    var sandbox;
-    var element;
+  suite('gr-app tests', () => {
+    let sandbox;
+    let element;
 
-    setup(function(done) {
+    setup(done => {
       sandbox = sinon.sandbox.create();
       stub('gr-reporting', {
         appStarted: sandbox.stub(),
@@ -45,43 +45,43 @@
         _getTopContent: sinon.stub(),
       });
       stub('gr-rest-api-interface', {
-        getAccount: function() { return Promise.resolve({}); },
-        getAccountCapabilities: function() { return Promise.resolve({}); },
-        getConfig: function() {
+        getAccount() { return Promise.resolve({}); },
+        getAccountCapabilities() { return Promise.resolve({}); },
+        getConfig() {
           return Promise.resolve({
             gerrit: {web_uis: ['GWT', 'POLYGERRIT']},
             plugin: {js_resource_paths: []},
           });
         },
-        getVersion: function() { return Promise.resolve(42); },
+        getPreferences() { return Promise.resolve({my: []}); },
+        getVersion() { return Promise.resolve(42); },
+        probePath() { return Promise.resolve(42); },
       });
 
       element = fixture('basic');
       flush(done);
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('reporting', function() {
+    test('reporting', () => {
       assert.isTrue(element.$.reporting.appStarted.calledOnce);
     });
 
-    test('location change updates gwt footer', function(done) {
+    test('location change updates gwt footer', done => {
       element._path = '/test/path';
-      flush(function() {
-        var gwtLink = element.$$('#gwtLink');
-        assert.equal(
-          gwtLink.href,
-          'http://' + location.host + element.getBaseUrl() + '/?polygerrit=0#/test/path'
-        );
+      flush(() => {
+        const gwtLink = element.$$('#gwtLink');
+        assert.equal(gwtLink.href, 'http://' + location.host +
+            element.getBaseUrl() + '/?polygerrit=0#/test/path');
         done();
       });
     });
 
-    test('_handleLocationChange handles hashes', function(done) {
-      var curLocation = {
+    test('_handleLocationChange handles hashes', done => {
+      const curLocation = {
         pathname: '/c/1/1/testfile.txt',
         hash: '#2',
         host: location.host,
@@ -89,21 +89,23 @@
       sandbox.stub(element, '_handleSearchPageChange');
       element._handleLocationChange({detail: curLocation});
 
-      flush(function() {
-        var gwtLink = element.$$('#gwtLink');
+      flush(() => {
+        const gwtLink = element.$$('#gwtLink');
         assert.equal(
-          gwtLink.href,
-          'http://' + location.host + element.getBaseUrl() +
+            gwtLink.href,
+            'http://' + location.host + element.getBaseUrl() +
             '/?polygerrit=0#/c/1/1/testfile.txt@2'
         );
         done();
       });
     });
 
-    test('sets plugins count', function() {
-      sandbox.stub(Gerrit, '_setPluginsCount');
-      element._loadPlugins([]);
-      assert.isTrue(Gerrit._setPluginsCount.calledWithExactly(0));
+    test('passes config to gr-plugin-host', done => {
+      element.$.restAPI.getConfig.lastCall.returnValue.then(config => {
+        const pluginConfig = config.plugin;
+        assert.deepEqual(element.$.plugins.config, pluginConfig);
+        done();
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.html b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.html
new file mode 100644
index 0000000..623d304
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.html
@@ -0,0 +1,25 @@
+<!--
+Copyright (C) 2017 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.
+-->
+
+<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-external-style">
+  <template>
+    <content></content>
+  </template>
+  <script src="gr-external-style.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
new file mode 100644
index 0000000..5e3749d
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
@@ -0,0 +1,59 @@
+// Copyright (C) 2017 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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-external-style',
+
+    properties: {
+      name: String,
+    },
+
+    _import(url) {
+      return new Promise((resolve, reject) => {
+        this.importHref(url, resolve, reject);
+      });
+    },
+
+    _applyStyle(name) {
+      const s = document.createElement('style', 'custom-style');
+      s.setAttribute('include', name);
+      Polymer.dom(this.root).appendChild(s);
+    },
+
+    ready() {
+      Gerrit.awaitPluginsLoaded().then(() => {
+        const sharedStyles = Gerrit._styleModules[this.name];
+        if (sharedStyles) {
+          const pluginUrls = [];
+          const moduleNames = [];
+          sharedStyles.reduce((result, item) => {
+            if (!result.pluginUrls.includes(item.pluginUrl)) {
+              result.pluginUrls.push(item.pluginUrl);
+            }
+            result.moduleNames.push(item.moduleName);
+            return result;
+          }, {pluginUrls, moduleNames});
+          Promise.all(pluginUrls.map(this._import.bind(this)))
+              .then(() => {
+                for (const name of moduleNames) {
+                  this._applyStyle(name);
+                }
+              });
+        }
+      });
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
new file mode 100644
index 0000000..f0e254f
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
@@ -0,0 +1,61 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-external-style</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-external-style.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-external-style name="foo"></gr-external-style>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-change-metadata integration tests', () => {
+    let sandbox;
+    let element;
+
+    setup(done => {
+      sandbox = sinon.sandbox.create();
+      sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
+      Gerrit._styleModules = {foo: [{pluginUrl: 'bar', moduleName: 'baz'}]};
+
+      element = fixture('basic');
+      sandbox.stub(element, '_applyStyle');
+      sandbox.stub(element, 'importHref', (url, resolve) => { resolve(); });
+      flush(done);
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('imports plugin-provided module', () => {
+      assert.isTrue(element.importHref.calledWith('bar'));
+    });
+
+    test('applies plugin-provided styles', () => {
+      assert.isTrue(element._applyStyle.calledWith('baz'));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.html b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.html
new file mode 100644
index 0000000..a3c44e2
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.html
@@ -0,0 +1,22 @@
+<!--
+Copyright (C) 2017 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.
+-->
+
+<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-plugin-host">
+  <script src="gr-plugin-host.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
new file mode 100644
index 0000000..cd7059c
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
@@ -0,0 +1,55 @@
+// Copyright (C) 2017 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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-plugin-host',
+
+    properties: {
+      config: {
+        type: Object,
+        observer: '_configChanged',
+      },
+    },
+
+    _configChanged(config) {
+      const jsPlugins = config.js_resource_paths || [];
+      const htmlPlugins = config.html_resource_paths || [];
+      Gerrit._setPluginsCount(jsPlugins.length + htmlPlugins.length);
+      this._loadJsPlugins(jsPlugins);
+      this._importHtmlPlugins(htmlPlugins);
+    },
+
+    _importHtmlPlugins(plugins) {
+      for (let url of plugins) {
+        if (!url.startsWith('http')) {
+          url = '/' + url;
+        }
+        this.importHref(
+            url, Gerrit._pluginInstalled, Gerrit._pluginInstalled, true);
+      }
+    },
+
+    _loadJsPlugins(plugins) {
+      for (let i = 0; i < plugins.length; i++) {
+        const scriptEl = document.createElement('script');
+        scriptEl.defer = true;
+        scriptEl.src = '/' + plugins[i];
+        scriptEl.onerror = Gerrit._pluginInstalled;
+        document.body.appendChild(scriptEl);
+      }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
new file mode 100644
index 0000000..17f4875
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
@@ -0,0 +1,67 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-plugin-host</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="gr-plugin-host.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-plugin-host></gr-plugin-host>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      element = fixture('basic');
+      sandbox = sinon.sandbox.create();
+      sandbox.stub(document.body, 'appendChild');
+      sandbox.stub(element, 'importHref');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('counts plugins', () => {
+      sandbox.stub(Gerrit, '_setPluginsCount');
+      element.config = {
+        html_resource_paths: ['foo/bar', 'baz'],
+        js_resource_paths: ['42'],
+      };
+      assert.isTrue(Gerrit._setPluginsCount.calledWith(3));
+    });
+
+    test('imports html plugins from config', () => {
+      element.config = {
+        html_resource_paths: ['foo/bar', 'baz'],
+      };
+      assert.isTrue(element.importHref.calledWith(
+          '/foo/bar', Gerrit._pluginInstalled, Gerrit._pluginInstalled, true));
+      assert.isTrue(element.importHref.calledWith(
+          '/baz', Gerrit._pluginInstalled, Gerrit._pluginInstalled, true));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
index 0c61998..dfbf3038 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
@@ -19,12 +19,12 @@
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
-<link rel="import" href="../../../styles/gr-settings-styles.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
 
 <dom-module id="gr-account-info">
   <template>
-    <style include="gr-settings-styles"></style>
-    <div class="gr-settings-styles">
+    <style include="gr-form-styles"></style>
+    <div class="gr-form-styles">
       <section>
         <span class="title">ID</span>
         <span class="value">[[_account._account_id]]</span>
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
index 91bc628..010b136 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
@@ -60,25 +60,25 @@
       '_statusChanged(_account.status)',
     ],
 
-    loadData: function() {
-      var promises = [];
+    loadData() {
+      const promises = [];
 
       this._loading = true;
 
-      promises.push(this.$.restAPI.getConfig().then(function(config) {
+      promises.push(this.$.restAPI.getConfig().then(config => {
         this._serverConfig = config;
-      }.bind(this)));
+      }));
 
-      promises.push(this.$.restAPI.getAccount().then(function(account) {
+      promises.push(this.$.restAPI.getAccount().then(account => {
         this._account = account;
-      }.bind(this)));
+      }));
 
-      return Promise.all(promises).then(function() {
+      return Promise.all(promises).then(() => {
         this._loading = false;
-      }.bind(this));
+      });
     },
 
-    save: function() {
+    save() {
       if (!this.hasUnsavedChanges) {
         return Promise.resolve();
       }
@@ -88,45 +88,45 @@
       // Must be done in sequence to avoid race conditions (@see Issue 5721)
       return this._maybeSetName()
           .then(this._maybeSetStatus.bind(this))
-          .then(function() {
+          .then(() => {
             this._hasNameChange = false;
             this._hasStatusChange = false;
             this._saving = false;
             this.fire('account-detail-update');
-          }.bind(this));
+          });
     },
 
-    _maybeSetName: function() {
+    _maybeSetName() {
       return this._hasNameChange && this.mutable ?
                 this.$.restAPI.setAccountName(this._account.name) :
                 Promise.resolve();
     },
 
-    _maybeSetStatus: function() {
+    _maybeSetStatus() {
       return this._hasStatusChange ?
           this.$.restAPI.setAccountStatus(this._account.status) :
           Promise.resolve();
     },
 
-    _computeHasUnsavedChanges: function(name, status) {
+    _computeHasUnsavedChanges(name, status) {
       return name || status;
     },
 
-    _computeMutable: function(config) {
-      return config.auth.editable_account_fields.indexOf('FULL_NAME') !== -1;
+    _computeMutable(config) {
+      return config.auth.editable_account_fields.includes('FULL_NAME');
     },
 
-    _statusChanged: function() {
+    _statusChanged() {
       if (this._loading) { return; }
       this._hasStatusChange = true;
     },
 
-    _nameChanged: function() {
+    _nameChanged() {
       if (this._loading) { return; }
       this._hasNameChange = true;
     },
 
-    _handleKeydown: function(e) {
+    _handleKeydown(e) {
       if (e.keyCode === 13) { // Enter
         e.stopPropagation();
         this.save();
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
index cf35450..d354f07 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
@@ -33,16 +33,16 @@
 </test-fixture>
 
 <script>
-  suite('gr-account-info tests', function() {
-    var element;
-    var account;
-    var config;
-    var sandbox;
+  suite('gr-account-info tests', () => {
+    let element;
+    let account;
+    let config;
+    let sandbox;
 
     function valueOf(title) {
-      var sections = Polymer.dom(element.root).querySelectorAll('section');
-      var titleEl;
-      for (var i = 0; i < sections.length; i++) {
+      const sections = Polymer.dom(element.root).querySelectorAll('section');
+      let titleEl;
+      for (let i = 0; i < sections.length; i++) {
         titleEl = sections[i].querySelector('.title');
         if (titleEl.textContent === title) {
           return sections[i].querySelector('.value');
@@ -50,7 +50,7 @@
       }
     }
 
-    setup(function(done) {
+    setup(done => {
       sandbox = sinon.sandbox.create();
       account = {
         _account_id: 123,
@@ -62,22 +62,22 @@
       config = {auth: {editable_account_fields: []}};
 
       stub('gr-rest-api-interface', {
-        getAccount: function() { return Promise.resolve(account); },
-        getConfig: function() { return Promise.resolve(config); },
-        getPreferences: function() {
+        getAccount() { return Promise.resolve(account); },
+        getConfig() { return Promise.resolve(config); },
+        getPreferences() {
           return Promise.resolve({time_format: 'HHMM_12'});
         },
       });
       element = fixture('basic');
       // Allow the element to render.
-      element.loadData().then(function() { flush(done); });
+      element.loadData().then(() => { flush(done); });
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('basic account info render', function() {
+    test('basic account info render', () => {
       assert.isFalse(element._loading);
 
       assert.equal(valueOf('ID').textContent, account._account_id);
@@ -85,10 +85,10 @@
       assert.equal(valueOf('Username').textContent, account.username);
     });
 
-    test('user name render (immutable)', function() {
-      var section = element.$.nameSection;
-      var displaySpan = section.querySelectorAll('.value')[0];
-      var inputSpan = section.querySelectorAll('.value')[1];
+    test('user name render (immutable)', () => {
+      const section = element.$.nameSection;
+      const displaySpan = section.querySelectorAll('.value')[0];
+      const inputSpan = section.querySelectorAll('.value')[1];
 
       assert.isFalse(element.mutable);
       assert.isFalse(displaySpan.hasAttribute('hidden'));
@@ -96,13 +96,13 @@
       assert.isTrue(inputSpan.hasAttribute('hidden'));
     });
 
-    test('user name render (mutable)', function() {
+    test('user name render (mutable)', () => {
       element.set('_serverConfig',
           {auth: {editable_account_fields: ['FULL_NAME']}});
 
-      var section = element.$.nameSection;
-      var displaySpan = section.querySelectorAll('.value')[0];
-      var inputSpan = section.querySelectorAll('.value')[1];
+      const section = element.$.nameSection;
+      const displaySpan = section.querySelectorAll('.value')[0];
+      const inputSpan = section.querySelectorAll('.value')[1];
 
       assert.isTrue(element.mutable);
       assert.isTrue(displaySpan.hasAttribute('hidden'));
@@ -110,25 +110,25 @@
       assert.isFalse(inputSpan.hasAttribute('hidden'));
     });
 
-    suite('account info edit', function() {
-      var nameChangedSpy;
-      var statusChangedSpy;
-      var nameStub;
-      var statusStub;
+    suite('account info edit', () => {
+      let nameChangedSpy;
+      let statusChangedSpy;
+      let nameStub;
+      let statusStub;
 
-      setup(function() {
+      setup(() => {
         nameChangedSpy = sandbox.spy(element, '_nameChanged');
         statusChangedSpy = sandbox.spy(element, '_statusChanged');
         element.set('_serverConfig',
           {auth: {editable_account_fields: ['FULL_NAME']}});
 
-        nameStub = sandbox.stub(element.$.restAPI, 'setAccountName',
-            function(name) { return Promise.resolve(); });
+        nameStub = sandbox.stub(element.$.restAPI, 'setAccountName', name =>
+          Promise.resolve());
         statusStub = sandbox.stub(element.$.restAPI, 'setAccountStatus',
-            function(status) { return Promise.resolve(); });
+            status => Promise.resolve());
       });
 
-      test('name', function(done) {
+      test('name', done => {
         assert.isTrue(element.mutable);
         assert.isFalse(element.hasUnsavedChanges);
 
@@ -142,13 +142,13 @@
 
         assert.isTrue(nameStub.called);
         assert.isFalse(statusStub.called);
-        nameStub.lastCall.returnValue.then(function() {
+        nameStub.lastCall.returnValue.then(() => {
           assert.equal(nameStub.lastCall.args[0], 'new name');
           done();
         });
       });
 
-      test('status', function(done) {
+      test('status', done => {
         assert.isTrue(element.mutable);
         assert.isFalse(element.hasUnsavedChanges);
 
@@ -158,10 +158,10 @@
         assert.isTrue(statusChangedSpy.called);
         assert.isTrue(element.hasUnsavedChanges);
 
-        element.save().then(function() {
+        element.save().then(() => {
           assert.isTrue(statusStub.called);
           assert.isFalse(nameStub.called);
-          statusStub.lastCall.returnValue.then(function() {
+          statusStub.lastCall.returnValue.then(() => {
             assert.equal(statusStub.lastCall.args[0], 'new status');
             done();
           });
@@ -169,25 +169,25 @@
       });
     });
 
-    suite('edit name and status', function() {
-      var nameChangedSpy;
-      var statusChangedSpy;
-      var nameStub;
-      var statusStub;
+    suite('edit name and status', () => {
+      let nameChangedSpy;
+      let statusChangedSpy;
+      let nameStub;
+      let statusStub;
 
-      setup(function() {
+      setup(() => {
         nameChangedSpy = sandbox.spy(element, '_nameChanged');
         statusChangedSpy = sandbox.spy(element, '_statusChanged');
         element.set('_serverConfig',
           {auth: {editable_account_fields: ['FULL_NAME']}});
 
-        nameStub = sandbox.stub(element.$.restAPI, 'setAccountName',
-            function(name) { return Promise.resolve(); });
+        nameStub = sandbox.stub(element.$.restAPI, 'setAccountName', name =>
+          Promise.resolve());
         statusStub = sandbox.stub(element.$.restAPI, 'setAccountStatus',
-            function(status) { return Promise.resolve(); });
+            status => Promise.resolve());
       });
 
-      test('set name and status', function(done) {
+      test('set name and status', done => {
         assert.isTrue(element.mutable);
         assert.isFalse(element.hasUnsavedChanges);
 
@@ -201,7 +201,7 @@
 
         assert.isTrue(element.hasUnsavedChanges);
 
-        element.save().then(function() {
+        element.save().then(() => {
           assert.isTrue(statusStub.called);
           assert.isTrue(nameStub.called);
 
@@ -214,23 +214,23 @@
       });
     });
 
-    suite('set status but read name', function() {
-      var statusChangedSpy;
-      var statusStub;
+    suite('set status but read name', () => {
+      let statusChangedSpy;
+      let statusStub;
 
-      setup(function() {
+      setup(() => {
         statusChangedSpy = sandbox.spy(element, '_statusChanged');
         element.set('_serverConfig',
           {auth: {editable_account_fields: []}});
 
         statusStub = sandbox.stub(element.$.restAPI, 'setAccountStatus',
-            function(status) { return Promise.resolve(); });
+            status => Promise.resolve());
       });
 
-      test('read full name but set status', function(done) {
-        var section = element.$.nameSection;
-        var displaySpan = section.querySelectorAll('.value')[0];
-        var inputSpan = section.querySelectorAll('.value')[1];
+      test('read full name but set status', done => {
+        const section = element.$.nameSection;
+        const displaySpan = section.querySelectorAll('.value')[0];
+        const inputSpan = section.querySelectorAll('.value')[1];
 
         assert.isFalse(element.mutable);
 
@@ -246,9 +246,9 @@
 
         assert.isTrue(element.hasUnsavedChanges);
 
-        element.save().then(function() {
+        element.save().then(() => {
           assert.isTrue(statusStub.called);
-          statusStub.lastCall.returnValue.then(function() {
+          statusStub.lastCall.returnValue.then(() => {
             assert.equal(statusStub.lastCall.args[0], 'new status');
             done();
           });
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html
index e2488f4..86da29d 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html
@@ -20,7 +20,7 @@
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
-<link rel="import" href="../../../styles/gr-settings-styles.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
 
 <dom-module id="gr-change-table-editor">
   <template>
@@ -40,8 +40,8 @@
         border: 1px solid #ddd;
       }
     </style>
-    <style include="gr-settings-styles"></style>
-    <div class="gr-settings-styles">
+    <style include="gr-form-styles"></style>
+    <div class="gr-form-styles">
       <table>
         <thead>
           <tr>
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
index 6a83a46..50a1146 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
@@ -28,13 +28,13 @@
       Gerrit.ChangeTableBehavior,
     ],
 
-    _getButtonText: function(isShown) {
+    _getButtonText(isShown) {
       return isShown ? 'Hide' : 'Show';
     },
 
-    _updateDisplayedColumns: function(displayedColumns, name, checked) {
+    _updateDisplayedColumns(displayedColumns, name, checked) {
       if (!checked) {
-        return displayedColumns.filter(function(column) {
+        return displayedColumns.filter(column => {
           return name.toLowerCase() !== column.toLowerCase();
         });
       } else {
@@ -45,8 +45,8 @@
     /**
      * Handles tap on either the checkbox itself or the surrounding table cell.
      */
-    _handleTargetTap: function(e) {
-      var checkbox = Polymer.dom(e.target).querySelector('input');
+    _handleTargetTap(e) {
+      let checkbox = Polymer.dom(e.target).querySelector('input');
       if (checkbox) {
         checkbox.click();
       } else {
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html
index d4443ac..15edb94 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html
@@ -33,12 +33,12 @@
 </test-fixture>
 
 <script>
-  suite('gr-change-table-editor tests', function() {
-    var element;
-    var columns;
-    var sandbox;
+  suite('gr-change-table-editor tests', () => {
+    let element;
+    let columns;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
 
@@ -55,25 +55,25 @@
       flushAsynchronousOperations();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('renders', function() {
-      var rows = element.$$('tbody').querySelectorAll('tr');
-      var tds;
+    test('renders', () => {
+      const rows = element.$$('tbody').querySelectorAll('tr');
+      let tds;
 
       assert.equal(rows.length, element.columnNames.length);
-      for (var i = 0; i < columns.length; i++) {
+      for (let i = 0; i < columns.length; i++) {
         tds = rows[i].querySelectorAll('td');
         assert.equal(tds[0].textContent, columns[i]);
       }
     });
 
-    test('hide item', function() {
-      var checkbox = element.$$('table input');
-      var isChecked = checkbox.checked;
-      var displayedLength = element.displayedColumns.length;
+    test('hide item', () => {
+      const checkbox = element.$$('table input');
+      const isChecked = checkbox.checked;
+      const displayedLength = element.displayedColumns.length;
       assert.isTrue(isChecked);
 
       MockInteractions.tap(checkbox);
@@ -83,7 +83,7 @@
           displayedLength - 1);
     });
 
-    test('show item', function() {
+    test('show item', () => {
       element.set('displayedColumns', [
         'Status',
         'Owner',
@@ -92,9 +92,9 @@
         'Updated',
       ]);
       flushAsynchronousOperations();
-      var checkbox = element.$$('table input');
-      var isChecked = checkbox.checked;
-      var displayedLength = element.displayedColumns.length;
+      const checkbox = element.$$('table input');
+      const isChecked = checkbox.checked;
+      const displayedLength = element.displayedColumns.length;
       assert.isFalse(isChecked);
       assert.equal(element.$$('table').style.display, '');
 
@@ -105,11 +105,11 @@
           displayedLength + 1);
     });
 
-    test('_handleTargetTap', function() {
-      var checkbox = element.$$('table input');
-      var originalDisplayedColumns = element.displayedColumns;
-      var td = element.$$('table .checkboxContainer');
-      var displayedColumnStub =
+    test('_handleTargetTap', () => {
+      const checkbox = element.$$('table input');
+      let originalDisplayedColumns = element.displayedColumns;
+      const td = element.$$('table .checkboxContainer');
+      const displayedColumnStub =
           sandbox.stub(element, '_updateDisplayedColumns');
 
       MockInteractions.tap(checkbox);
@@ -126,9 +126,9 @@
           checkbox.checked));
     });
 
-    test('_updateDisplayedColumns', function() {
-      var name = 'Subject';
-      var checked = false;
+    test('_updateDisplayedColumns', () => {
+      let name = 'Subject';
+      let checked = false;
       assert.deepEqual(element._updateDisplayedColumns(columns, name, checked),
           [
             'Status',
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html
index 5339c5e..6c9f34f 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html
@@ -44,8 +44,8 @@
         border: 1px solid #ddd;
       }
     </style>
-    <style include="gr-settings-styles"></style>
-    <div class="gr-settings-styles">
+    <style include="gr-form-styles"></style>
+    <div class="gr-form-styles">
       <table>
         <thead>
           <tr>
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
index 90dd119c..8490c1e 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
@@ -27,7 +27,7 @@
       _emails: Array,
       _emailsToRemove: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       _newPreferred: {
         type: String,
@@ -35,18 +35,17 @@
       },
     },
 
-    loadData: function() {
-      return this.$.restAPI.getAccountEmails().then(function(emails) {
+    loadData() {
+      return this.$.restAPI.getAccountEmails().then(emails => {
         this._emails = emails;
-      }.bind(this));
+      });
     },
 
-    save: function() {
-      var promises = [];
+    save() {
+      const promises = [];
 
-      for (var i = 0; i < this._emailsToRemove.length; i++) {
-        promises.push(this.$.restAPI.deleteAccountEmail(
-            this._emailsToRemove[i].email));
+      for (const emailObj of this._emailsToRemove) {
+        promises.push(this.$.restAPI.deleteAccountEmail(emailObj.email));
       }
 
       if (this._newPreferred) {
@@ -54,30 +53,30 @@
             this._newPreferred));
       }
 
-      return Promise.all(promises).then(function() {
+      return Promise.all(promises).then(() => {
         this._emailsToRemove = [];
         this._newPreferred = null;
         this.hasUnsavedChanges = false;
-      }.bind(this));
+      });
     },
 
-    _handleDeleteButton: function(e) {
-      var index = parseInt(e.target.getAttribute('data-index'));
-      var email = this._emails[index];
+    _handleDeleteButton(e) {
+      const index = parseInt(e.target.getAttribute('data-index'));
+      const email = this._emails[index];
       this.push('_emailsToRemove', email);
       this.splice('_emails', index, 1);
       this.hasUnsavedChanges = true;
     },
 
-    _handlePreferredControlTap: function(e) {
+    _handlePreferredControlTap(e) {
       if (e.target.classList.contains('preferredControl')) {
         e.target.firstElementChild.click();
       }
     },
 
-    _handlePreferredChange: function(e) {
-      var preferred = e.target.value;
-      for (var i = 0; i < this._emails.length; i++) {
+    _handlePreferredChange(e) {
+      const preferred = e.target.value;
+      for (let i = 0; i < this._emails.length; i++) {
         if (preferred === this._emails[i].email) {
           this.set(['_emails', i, 'preferred'], true);
           this._newPreferred = preferred;
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
index b949643..608179e 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
@@ -33,18 +33,18 @@
 </test-fixture>
 
 <script>
-  suite('gr-email-editor tests', function() {
-    var element;
+  suite('gr-email-editor tests', () => {
+    let element;
 
-    setup(function(done) {
-      var emails = [
+    setup(done => {
+      const emails = [
         {email: 'email@one.com'},
         {email: 'email@two.com', preferred: true},
         {email: 'email@three.com'},
       ];
 
       stub('gr-rest-api-interface', {
-        getAccountEmails: function() { return Promise.resolve(emails); },
+        getAccountEmails() { return Promise.resolve(emails); },
       });
 
       element = fixture('basic');
@@ -52,8 +52,8 @@
       element.loadData().then(done);
     });
 
-    test('renders', function() {
-      var rows = element.$$('table').querySelectorAll('tbody tr');
+    test('renders', () => {
+      const rows = element.$$('table').querySelectorAll('tbody tr');
 
       assert.equal(rows.length, 3);
 
@@ -69,9 +69,9 @@
       assert.isFalse(element.hasUnsavedChanges);
     });
 
-    test('edit preferred', function() {
-      var preferredChangedSpy = sinon.spy(element, '_handlePreferredChange');
-      var radios = element.$$('table').querySelectorAll('input[type=radio]');
+    test('edit preferred', () => {
+      const preferredChangedSpy = sinon.spy(element, '_handlePreferredChange');
+      const radios = element.$$('table').querySelectorAll('input[type=radio]');
 
       assert.isFalse(element.hasUnsavedChanges);
       assert.isNotOk(element._newPreferred);
@@ -92,8 +92,8 @@
       assert.isTrue(preferredChangedSpy.called);
     });
 
-    test('delete email', function() {
-      var buttons = element.$$('table').querySelectorAll('gr-button');
+    test('delete email', () => {
+      const buttons = element.$$('table').querySelectorAll('gr-button');
 
       assert.isFalse(element.hasUnsavedChanges);
       assert.isNotOk(element._newPreferred);
@@ -110,11 +110,12 @@
       assert.equal(element._emailsToRemove[0].email, 'email@three.com');
     });
 
-    test('save changes', function(done) {
-      var deleteEmailStub = sinon.stub(element.$.restAPI, 'deleteAccountEmail');
-      var setPreferredStub = sinon.stub(element.$.restAPI,
+    test('save changes', done => {
+      const deleteEmailStub =
+          sinon.stub(element.$.restAPI, 'deleteAccountEmail');
+      const setPreferredStub = sinon.stub(element.$.restAPI,
           'setPreferredAccountEmail');
-      var rows = element.$$('table').querySelectorAll('tbody tr');
+      const rows = element.$$('table').querySelectorAll('tbody tr');
 
       assert.isFalse(element.hasUnsavedChanges);
       assert.isNotOk(element._newPreferred);
@@ -132,7 +133,7 @@
       assert.equal(element._emails.length, 2);
 
       // Save the changes.
-      element.save().then(function() {
+      element.save().then(() => {
         assert.equal(deleteEmailStub.callCount, 1);
         assert.equal(deleteEmailStub.getCall(0).args[0], 'email@one.com');
 
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html
index 303d836..4074ecc 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html
@@ -17,7 +17,7 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
-<link rel="import" href="../../../styles/gr-settings-styles.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
 
 <dom-module id="gr-group-list">
   <template>
@@ -32,8 +32,8 @@
         text-align: center;
       }
     </style>
-    <style include="gr-settings-styles"></style>
-    <div class="gr-settings-styles">
+    <style include="gr-form-styles"></style>
+    <div class="gr-form-styles">
       <table>
         <thead>
           <tr>
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
index d14c755..d26482d 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
@@ -21,15 +21,15 @@
       _groups: Array,
     },
 
-    loadData: function() {
-      return this.$.restAPI.getAccountGroups().then(function(groups) {
-        this._groups = groups.sort(function(a, b) {
+    loadData() {
+      return this.$.restAPI.getAccountGroups().then(groups => {
+        this._groups = groups.sort((a, b) => {
           return a.name.localeCompare(b.name);
         });
-      }.bind(this));
+      });
     },
 
-    _computeVisibleToAll: function(group) {
+    _computeVisibleToAll(group) {
       return group.options.visible_to_all ? 'Yes' : 'No';
     },
   });
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
index 2abf797..c0cecdf 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
@@ -32,11 +32,11 @@
 </test-fixture>
 
 <script>
-  suite('gr-group-list tests', function() {
-    var element;
-    var groups;
+  suite('gr-group-list tests', () => {
+    let element;
+    let groups;
 
-    setup(function(done) {
+    setup(done => {
       groups = [{
         url: 'some url',
         options: {},
@@ -46,39 +46,40 @@
         owner_id: '123',
         id: 'abc',
         name: 'Group 1',
-      },{
+      }, {
         options: {visible_to_all: true},
         id: '456',
         name: 'Group 2',
-      },{
+      }, {
         options: {},
         id: '789',
         name: 'Group 3',
       }];
 
       stub('gr-rest-api-interface', {
-        getAccountGroups: function() { return Promise.resolve(groups); },
+        getAccountGroups() { return Promise.resolve(groups); },
       });
 
       element = fixture('basic');
 
-      element.loadData().then(function() { flush(done); });
+      element.loadData().then(() => { flush(done); });
     });
 
-    test('renders', function() {
-      var rows = Polymer.dom(element.root).querySelectorAll('tbody tr');
+    test('renders', () => {
+      const rows = Polymer.dom(element.root).querySelectorAll('tbody tr');
 
       assert.equal(rows.length, 3);
 
-      var nameCells = rows.map(
-          function(row) { return row.querySelectorAll('td')[0].textContent; });
+      const nameCells = rows.map(row =>
+        row.querySelectorAll('td')[0].textContent
+      );
 
       assert.equal(nameCells[0], 'Group 1');
       assert.equal(nameCells[1], 'Group 2');
       assert.equal(nameCells[2], 'Group 3');
     });
 
-    test('_computeVisibleToAll', function() {
+    test('_computeVisibleToAll', () => {
       assert.equal(element._computeVisibleToAll(groups[0]), 'No');
       assert.equal(element._computeVisibleToAll(groups[1]), 'Yes');
     });
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html
index e01ab94..86bb1b7 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html
@@ -15,7 +15,7 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/gr-settings-styles.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -46,8 +46,8 @@
         right: 2em;
       }
     </style>
-    <style include="gr-settings-styles"></style>
-    <div class="gr-settings-styles">
+    <style include="gr-form-styles"></style>
+    <div class="gr-form-styles">
       <div hidden$="[[_passwordUrl]]">
         <section>
           <span class="title">Username</span>
@@ -58,7 +58,7 @@
             on-tap="_handleGenerateTap">Generate new password</gr-button>
       </div>
       <span hidden$="[[!_passwordUrl]]">
-        <a href="[[_passwordUrl]]" target="_blank" rel="noopener">
+        <a href$="[[_passwordUrl]]" target="_blank" rel="noopener">
           Obtain password</a>
         (opens in a new tab)
       </span>
@@ -67,7 +67,7 @@
         id="generatedPasswordOverlay"
         on-iron-overlay-closed="_generatedPasswordOverlayClosed"
         with-backdrop>
-      <div class="gr-settings-styles">
+      <div class="gr-form-styles">
         <section id="generatedPasswordDisplay">
           <span class="title">New Password:</span>
           <span class="value">[[_generatedPassword]]</span>
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
index f4894e9..d9c19e2 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
@@ -23,33 +23,33 @@
       _passwordUrl: String,
     },
 
-    loadData: function() {
-      var promises = [];
+    loadData() {
+      const promises = [];
 
-      promises.push(this.$.restAPI.getAccount().then(function(account) {
+      promises.push(this.$.restAPI.getAccount().then(account => {
         this._username = account.username;
-      }.bind(this)));
+      }));
 
-      promises.push(this.$.restAPI.getConfig().then(function(info) {
+      promises.push(this.$.restAPI.getConfig().then(info => {
         this._passwordUrl = info.auth.http_password_url || null;
-      }.bind(this)));
+      }));
 
       return Promise.all(promises);
     },
 
-    _handleGenerateTap: function() {
+    _handleGenerateTap() {
       this._generatedPassword = 'Generating...';
       this.$.generatedPasswordOverlay.open();
-      this.$.restAPI.generateAccountHttpPassword().then(function(newPassword) {
+      this.$.restAPI.generateAccountHttpPassword().then(newPassword => {
         this._generatedPassword = newPassword;
-      }.bind(this));
+      });
     },
 
-    _closeOverlay: function() {
+    _closeOverlay() {
       this.$.generatedPasswordOverlay.close();
     },
 
-    _generatedPasswordOverlayClosed: function() {
+    _generatedPasswordOverlayClosed() {
       this._generatedPassword = null;
     },
   });
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
index 787c2c4..acc9f60 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
@@ -33,33 +33,31 @@
 </test-fixture>
 
 <script>
-  suite('gr-http-password tests', function() {
-    var element;
-    var account;
-    var password;
-    var config;
+  suite('gr-http-password tests', () => {
+    let element;
+    let account;
+    let config;
 
-    setup(function(done) {
+    setup(done => {
       account = {username: 'user name'};
       config = {auth: {}};
-      password = 'the password';
 
       stub('gr-rest-api-interface', {
-        getAccount: function() { return Promise.resolve(account); },
-        getConfig: function() { return Promise.resolve(config); },
+        getAccount() { return Promise.resolve(account); },
+        getConfig() { return Promise.resolve(config); },
       });
 
       element = fixture('basic');
-      element.loadData().then(function() { flush(done); });
+      element.loadData().then(() => { flush(done); });
     });
 
-    test('generate password', function() {
-      var button = element.$.generateButton;
-      var nextPassword = 'the new password';
-      var generateResolve;
-      var generateStub = sinon.stub(element.$.restAPI,
-          'generateAccountHttpPassword', function() {
-            return new Promise(function(resolve) {
+    test('generate password', () => {
+      const button = element.$.generateButton;
+      const nextPassword = 'the new password';
+      let generateResolve;
+      const generateStub = sinon.stub(element.$.restAPI,
+          'generateAccountHttpPassword', () => {
+            return new Promise(resolve => {
               generateResolve = resolve;
             });
           });
@@ -73,18 +71,18 @@
 
       generateResolve(nextPassword);
 
-      generateStub.lastCall.returnValue.then(function() {
+      generateStub.lastCall.returnValue.then(() => {
         assert.equal(element._generatedPassword, nextPassword);
       });
     });
 
-    test('without http_password_url', function() {
+    test('without http_password_url', () => {
       assert.isNull(element._passwordUrl);
     });
 
-    test('with http_password_url', function(done) {
+    test('with http_password_url', done => {
       config.auth.http_password_url = 'http://example.com/';
-      element.loadData().then(function() {
+      element.loadData().then(() => {
         assert.isNotNull(element._passwordUrl);
         assert.equal(element._passwordUrl, config.auth.http_password_url);
         done();
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html
index e603e8c..a1fcf86 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html
@@ -20,7 +20,7 @@
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
-<link rel="import" href="../../../styles/gr-settings-styles.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
 
 <dom-module id="gr-menu-editor">
   <template>
@@ -42,8 +42,8 @@
         width: 23em;
       }
     </style>
-    <style include="gr-settings-styles"></style>
-    <div class="gr-settings-styles">
+    <style include="gr-form-styles"></style>
+    <div class="gr-form-styles">
       <table>
         <thead>
           <tr>
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
index d3a2e2d..543c86d 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
@@ -23,28 +23,28 @@
       _newUrl: String,
     },
 
-    _handleMoveUpButton: function(e) {
-      var index = e.target.dataIndex;
+    _handleMoveUpButton(e) {
+      const index = e.target.dataIndex;
       if (index === 0) { return; }
-      var row = this.menuItems[index];
-      var prev = this.menuItems[index - 1];
+      const row = this.menuItems[index];
+      const prev = this.menuItems[index - 1];
       this.splice('menuItems', index - 1, 2, row, prev);
     },
 
-    _handleMoveDownButton: function(e) {
-      var index = e.target.dataIndex;
+    _handleMoveDownButton(e) {
+      const index = e.target.dataIndex;
       if (index === this.menuItems.length - 1) { return; }
-      var row = this.menuItems[index];
-      var next = this.menuItems[index + 1];
+      const row = this.menuItems[index];
+      const next = this.menuItems[index + 1];
       this.splice('menuItems', index, 2, next, row);
     },
 
-    _handleDeleteButton: function(e) {
-      var index = e.target.dataIndex;
+    _handleDeleteButton(e) {
+      const index = e.target.dataIndex;
       this.splice('menuItems', index, 1);
     },
 
-    _handleAddButton: function() {
+    _handleAddButton() {
       if (this._computeAddDisabled(this._newName, this._newUrl)) { return; }
 
       this.splice('menuItems', this.menuItems.length, 0, {
@@ -57,11 +57,11 @@
       this._newUrl = '';
     },
 
-    _computeAddDisabled: function(newName, newUrl) {
+    _computeAddDisabled(newName, newUrl) {
       return !newName.length || !newUrl.length;
     },
 
-    _handleInputKeydown: function(e) {
+    _handleInputKeydown(e) {
       if (e.keyCode === 13) {
         e.stopPropagation();
         this._handleAddButton();
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
index a7078093..f9e905d 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
@@ -33,14 +33,14 @@
 </test-fixture>
 
 <script>
-  suite('gr-settings-view tests', function() {
-    var element;
-    var menu;
+  suite('gr-settings-view tests', () => {
+    let element;
+    let menu;
 
     function assertMenuNamesEqual(element, expected) {
-      var names = element.menuItems.map(function(i) { return i.name; });
+      const names = element.menuItems.map(i => { return i.name; });
       assert.equal(names.length, expected.length);
-      for (var i = 0; i < names.length; i++) {
+      for (let i = 0; i < names.length; i++) {
         assert.equal(names[i], expected[i]);
       }
     }
@@ -48,13 +48,13 @@
     // Click the up/down button (according to direction) for the index'th row.
     // The index of the first row is 0, corresponding to the array.
     function move(element, index, direction) {
-      var selector =
+      const selector =
           'tr:nth-child(' + (index + 1) + ') .move-' + direction + '-button';
-      var button = element.$$('tbody').querySelector(selector);
+      const button = element.$$('tbody').querySelector(selector);
       MockInteractions.tap(button);
     }
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       menu = [
         {url: '/first/url', name: 'first name', target: '_blank'},
@@ -65,12 +65,12 @@
       Polymer.dom.flush();
     });
 
-    test('renders', function() {
-      var rows = element.$$('tbody').querySelectorAll('tr');
-      var tds;
+    test('renders', () => {
+      const rows = element.$$('tbody').querySelectorAll('tr');
+      let tds;
 
       assert.equal(rows.length, menu.length);
-      for (var i = 0; i < menu.length; i++) {
+      for (let i = 0; i < menu.length; i++) {
         tds = rows[i].querySelectorAll('td');
         assert.equal(tds[0].textContent, menu[i].name);
         assert.equal(tds[1].textContent, menu[i].url);
@@ -80,23 +80,23 @@
           element._newUrl));
     });
 
-    test('_computeAddDisabled', function() {
+    test('_computeAddDisabled', () => {
       assert.isTrue(element._computeAddDisabled('', ''));
       assert.isTrue(element._computeAddDisabled('name', ''));
       assert.isTrue(element._computeAddDisabled('', 'url'));
       assert.isFalse(element._computeAddDisabled('name', 'url'));
     });
 
-    test('add a new menu item', function() {
-      var newName = 'new name';
-      var newUrl = 'new url';
+    test('add a new menu item', () => {
+      const newName = 'new name';
+      const newUrl = 'new url';
 
       element._newName = newName;
       element._newUrl = newUrl;
       assert.isFalse(element._computeAddDisabled(element._newName,
           element._newUrl));
 
-      var originalMenuLength = element.menuItems.length;
+      const originalMenuLength = element.menuItems.length;
 
       element._handleAddButton();
 
@@ -106,7 +106,7 @@
       assert.equal(element.menuItems[element.menuItems.length - 1].url, newUrl);
     });
 
-    test('move items down', function() {
+    test('move items down', () => {
       assertMenuNamesEqual(element,
           ['first name', 'second name', 'third name']);
 
@@ -121,7 +121,7 @@
           ['first name', 'third name', 'second name']);
     });
 
-    test('move items up', function() {
+    test('move items up', () => {
       assertMenuNamesEqual(element,
           ['first name', 'second name', 'third name']);
 
@@ -137,7 +137,7 @@
           ['third name', 'first name', 'second name']);
     });
 
-    test('remove item', function() {
+    test('remove item', () => {
       assertMenuNamesEqual(element,
           ['first name', 'second name', 'third name']);
 
@@ -148,7 +148,7 @@
       assertMenuNamesEqual(element, ['first name', 'third name']);
 
       // Delete remaining items.
-      for (var i = 0; i < 2; i++) {
+      for (let i = 0; i < 2; i++) {
         MockInteractions.tap(
             element.$$('tbody').querySelector('tr:first-child .remove-button'));
       }
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
index ee358d5..e3167e2 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
@@ -15,13 +15,13 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/gr-settings-styles.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-registration-dialog">
   <template>
-    <style include="gr-settings-styles"></style>
+    <style include="gr-form-styles"></style>
     <style>
       :host {
         display: block;
@@ -47,7 +47,7 @@
         justify-content: space-between;
       }
     </style>
-    <main class="gr-settings-styles">
+    <main class="gr-form-styles">
       <header>Please confirm your contact information</header>
       <main>
         <p>
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
index 9acdba9..319143a 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
@@ -38,39 +38,39 @@
       role: 'dialog',
     },
 
-    attached: function() {
-      this.$.restAPI.getAccount().then(function(account) {
+    attached() {
+      this.$.restAPI.getAccount().then(account => {
         this._account = account;
-      }.bind(this));
+      });
     },
 
-    _handleNameKeydown: function(e) {
+    _handleNameKeydown(e) {
       if (e.keyCode === 13) { // Enter
         e.stopPropagation();
         this._save();
       }
     },
 
-    _save: function() {
+    _save() {
       this._saving = true;
-      var promises = [
+      const promises = [
         this.$.restAPI.setAccountName(this.$.name.value),
         this.$.restAPI.setPreferredAccountEmail(this.$.email.value),
       ];
-      return Promise.all(promises).then(function() {
+      return Promise.all(promises).then(() => {
         this._saving = false;
         this.fire('account-detail-update');
-      }.bind(this));
+      });
     },
 
-    _handleSave: function(e) {
+    _handleSave(e) {
       e.preventDefault();
-      this._save().then(function() {
+      this._save().then(() => {
         this.fire('close');
-      }.bind(this));
+      });
     },
 
-    _handleClose: function(e) {
+    _handleClose(e) {
       e.preventDefault();
       this._saving = true; // disable buttons indefinitely
       this.fire('close');
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
index ee5a206..1f8a34a 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
@@ -39,12 +39,12 @@
 </test-fixture>
 
 <script>
-  suite('gr-registration-dialog tests', function() {
-    var element;
-    var account;
-    var _listeners;
+  suite('gr-registration-dialog tests', () => {
+    let element;
+    let account;
+    let _listeners;
 
-    setup(function(done) {
+    setup(done => {
       _listeners = {};
 
       account = {
@@ -57,16 +57,16 @@
       };
 
       stub('gr-rest-api-interface', {
-        getAccount: function() {
-          // Once the account is resolved, we can let the test proceed.
+        getAccount() {
+        // Once the account is resolved, we can let the test proceed.
           flush(done);
           return Promise.resolve(account);
         },
-        setAccountName: function(name) {
+        setAccountName(name) {
           account.name = name;
           return Promise.resolve();
         },
-        setPreferredAccountEmail: function(email) {
+        setPreferredAccountEmail(email) {
           account.email = email;
           return Promise.resolve();
         },
@@ -75,8 +75,8 @@
       element = fixture('basic');
     });
 
-    teardown(function() {
-      for (var eventType in _listeners) {
+    teardown(() => {
+      for (const eventType in _listeners) {
         if (_listeners.hasOwnProperty(eventType)) {
           element.removeEventListener(eventType, _listeners[eventType]);
         }
@@ -84,14 +84,14 @@
     });
 
     function listen(eventType) {
-      return new Promise(function(resolve) {
+      return new Promise(resolve => {
         _listeners[eventType] = function() { resolve(); };
         element.addEventListener(eventType, _listeners[eventType]);
       });
     }
 
     function save(opt_action) {
-      var promise = listen('account-detail-update');
+      const promise = listen('account-detail-update');
       if (opt_action) {
         opt_action();
       } else {
@@ -101,7 +101,7 @@
     }
 
     function close(opt_action) {
-      var promise = listen('close');
+      const promise = listen('close');
       if (opt_action) {
         opt_action();
       } else {
@@ -110,18 +110,18 @@
       return promise;
     }
 
-    test('fires the close event on close', function(done) {
+    test('fires the close event on close', done => {
       close().then(done);
     });
 
-    test('fires the close event on save', function(done) {
-      close(function() {
+    test('fires the close event on save', done => {
+      close(() => {
         MockInteractions.tap(element.$.saveButton);
       }).then(done);
     });
 
-    test('saves name and preferred email', function(done) {
-      flush(function() {
+    test('saves name and preferred email', done => {
+      flush(() => {
         element.$.name.value = 'new name';
         element.$.email.value = 'email3';
 
@@ -130,18 +130,18 @@
         assert.equal(account.email, 'email');
 
         // Save and verify new values are committed.
-        save().then(function() {
+        save().then(() => {
           assert.equal(account.name, 'new name');
           assert.equal(account.email, 'email3');
         }).then(done);
       });
     });
 
-    test('pressing enter saves name', function(done) {
+    test('pressing enter saves name', done => {
       element.$.name.value = 'entered name';
-      save(function() {
+      save(() => {
         MockInteractions.pressAndReleaseKeyOn(element.$.name, 13);  // 'enter'
-      }).then(function() {
+      }).then(() => {
         assert.equal(account.name, 'entered name');
       }).then(done);
     });
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
index f485dd6..fac367c 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
@@ -29,7 +29,7 @@
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-select/gr-select.html">
 
-<link rel="import" href="../../../styles/gr-settings-styles.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
 
 <dom-module id="gr-settings-view">
   <template>
@@ -42,9 +42,6 @@
         margin: 2em auto;
         max-width: 46em;
       }
-      h1 {
-        margin-bottom: .1em;
-      }
       h2.edited:after {
         color: #444;
         content: ' *';
@@ -91,7 +88,7 @@
         }
       }
     </style>
-    <style include="gr-settings-styles"></style>
+    <style include="gr-form-styles"></style>
     <div class="loading" hidden$="[[!_loading]]">Loading...</div>
     <div hidden$="[[_loading]]" hidden>
       <nav id="settingsNav">
@@ -110,7 +107,7 @@
           <li><a href="#Groups">Groups</a></li>
         </ul>
       </nav>
-      <main class="gr-settings-styles">
+      <main class="gr-form-styles">
         <h1>User Settings</h1>
         <h2
             id="Profile"
@@ -205,6 +202,16 @@
                   on-change="_handleExpandInlineDiffsChanged">
             </span>
           </section>
+          <section>
+            <span class="title">Publish comments on push</span>
+            <span class="value">
+              <input
+                  id="publishCommentsOnPush"
+                  type="checkbox"
+                  checked$="[[_localPrefs.publish_comments_on_push]]"
+                  on-change="_handlePublishCommentsOnPushChanged">
+            </span>
+          </section>
           <gr-button
               id="savePrefs"
               on-tap="_handleSavePreferences"
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
index 4647a2d..d44fcfa 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
@@ -14,13 +14,14 @@
 (function() {
   'use strict';
 
-  var PREFS_SECTION_FIELDS = [
+  const PREFS_SECTION_FIELDS = [
     'changes_per_page',
     'date_format',
     'time_format',
     'email_strategy',
     'diff_view',
     'expand_inline_diffs',
+    'publish_comments_on_push',
     'email_format',
   ];
 
@@ -42,11 +43,11 @@
     properties: {
       prefs: {
         type: Object,
-        value: function() { return {}; },
+        value() { return {}; },
       },
       params: {
         type: Object,
-        value: function() { return {}; },
+        value() { return {}; },
       },
       _accountNameMutable: Boolean,
       _accountInfoChanged: Boolean,
@@ -54,15 +55,15 @@
       _changeTableColumnsNotDisplayed: Array,
       _localPrefs: {
         type: Object,
-        value: function() { return {}; },
+        value() { return {}; },
       },
       _localChangeTableColumns: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       _localMenu: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       _loading: {
         type: Boolean,
@@ -121,68 +122,68 @@
       '_handleChangeTableChanged(_localChangeTableColumns)',
     ],
 
-    attached: function() {
+    attached() {
       this.fire('title-change', {title: 'Settings'});
 
-      var promises = [
+      const promises = [
         this.$.accountInfo.loadData(),
         this.$.watchedProjectsEditor.loadData(),
         this.$.groupList.loadData(),
         this.$.httpPass.loadData(),
       ];
 
-      promises.push(this.$.restAPI.getPreferences().then(function(prefs) {
+      promises.push(this.$.restAPI.getPreferences().then(prefs => {
         this.prefs = prefs;
         this._copyPrefs('_localPrefs', 'prefs');
         this._cloneMenu();
         this._cloneChangeTableColumns();
-      }.bind(this)));
+      }));
 
-      promises.push(this.$.restAPI.getDiffPreferences().then(function(prefs) {
+      promises.push(this.$.restAPI.getDiffPreferences().then(prefs => {
         this._diffPrefs = prefs;
-      }.bind(this)));
+      }));
 
-      promises.push(this.$.restAPI.getConfig().then(function(config) {
+      promises.push(this.$.restAPI.getConfig().then(config => {
         this._serverConfig = config;
         if (this._serverConfig.sshd) {
           return this.$.sshEditor.loadData();
         }
-      }.bind(this)));
+      }));
 
       if (this.params.emailToken) {
         promises.push(this.$.restAPI.confirmEmail(this.params.emailToken).then(
-          function(message) {
-            if (message) {
-              this.fire('show-alert', {message: message});
-            }
-            this.$.emailEditor.loadData();
-          }.bind(this)));
+            message => {
+              if (message) {
+                this.fire('show-alert', {message});
+              }
+              this.$.emailEditor.loadData();
+            }));
       } else {
         promises.push(this.$.emailEditor.loadData());
       }
 
-      this._loadingPromise = Promise.all(promises).then(function() {
+      this._loadingPromise = Promise.all(promises).then(() => {
         this._loading = false;
-      }.bind(this));
+      });
 
       this.listen(window, 'scroll', '_handleBodyScroll');
     },
 
-    detached: function() {
+    detached() {
       this.unlisten(window, 'scroll', '_handleBodyScroll');
     },
 
-    reloadAccountDetail: function() {
+    reloadAccountDetail() {
       Promise.all([
         this.$.accountInfo.loadData(),
         this.$.emailEditor.loadData(),
       ]);
     },
 
-    _handleBodyScroll: function(e) {
+    _handleBodyScroll(e) {
       if (this._headerHeight === undefined) {
-        var top = this.$.settingsNav.offsetTop;
-        for (var offsetParent = this.$.settingsNav.offsetParent;
+        let top = this.$.settingsNav.offsetTop;
+        for (let offsetParent = this.$.settingsNav.offsetParent;
            offsetParent;
            offsetParent = offsetParent.offsetParent) {
           top += offsetParent.offsetTop;
@@ -194,158 +195,163 @@
           window.scrollY >= this._headerHeight);
     },
 
-    _isLoading: function() {
+    _isLoading() {
       return this._loading || this._loading === undefined;
     },
 
-    _copyPrefs: function(to, from) {
-      for (var i = 0; i < PREFS_SECTION_FIELDS.length; i++) {
+    _copyPrefs(to, from) {
+      for (let i = 0; i < PREFS_SECTION_FIELDS.length; i++) {
         this.set([to, PREFS_SECTION_FIELDS[i]],
             this[from][PREFS_SECTION_FIELDS[i]]);
       }
     },
 
-    _cloneMenu: function() {
-      var menu = [];
-      this.prefs.my.forEach(function(item) {
+    _cloneMenu() {
+      const menu = [];
+      for (const item of this.prefs.my) {
         menu.push({
           name: item.name,
           url: item.url,
           target: item.target,
         });
-      });
+      }
       this._localMenu = menu;
     },
 
-    _cloneChangeTableColumns: function() {
-      var columns = this.prefs.change_table;
+    _cloneChangeTableColumns() {
+      let columns = this.prefs.change_table;
 
       if (columns.length === 0) {
         columns = this.columnNames;
         this._changeTableColumnsNotDisplayed = [];
       } else {
         this._changeTableColumnsNotDisplayed = this.getComplementColumns(
-          this.prefs.change_table);
+            this.prefs.change_table);
       }
       this._localChangeTableColumns = columns;
     },
 
-    _formatChangeTableColumns: function(changeTableArray) {
-      return changeTableArray.map(function(item) {
+    _formatChangeTableColumns(changeTableArray) {
+      return changeTableArray.map(item => {
         return {column: item};
       });
     },
 
-    _handleChangeTableChanged: function() {
+    _handleChangeTableChanged() {
       if (this._isLoading()) { return; }
       this._changeTableChanged = true;
     },
 
-    _handlePrefsChanged: function(prefs) {
+    _handlePrefsChanged(prefs) {
       if (this._isLoading()) { return; }
       this._prefsChanged = true;
     },
 
-    _handleDiffPrefsChanged: function() {
+    _handleDiffPrefsChanged() {
       if (this._isLoading()) { return; }
       this._diffPrefsChanged = true;
     },
 
-    _handleExpandInlineDiffsChanged: function() {
+    _handleExpandInlineDiffsChanged() {
       this.set('_localPrefs.expand_inline_diffs',
           this.$.expandInlineDiffs.checked);
     },
 
-    _handleMenuChanged: function() {
+    _handlePublishCommentsOnPushChanged() {
+      this.set('_localPrefs.publish_comments_on_push',
+          this.$.publishCommentsOnPush.checked);
+    },
+
+    _handleMenuChanged() {
       if (this._isLoading()) { return; }
       this._menuChanged = true;
     },
 
-    _handleSaveAccountInfo: function() {
+    _handleSaveAccountInfo() {
       this.$.accountInfo.save();
     },
 
-    _handleSavePreferences: function() {
+    _handleSavePreferences() {
       this._copyPrefs('prefs', '_localPrefs');
 
-      return this.$.restAPI.savePreferences(this.prefs).then(function() {
+      return this.$.restAPI.savePreferences(this.prefs).then(() => {
         this._prefsChanged = false;
-      }.bind(this));
+      });
     },
 
-    _handleLineWrappingChanged: function() {
+    _handleLineWrappingChanged() {
       this.set('_diffPrefs.line_wrapping', this.$.lineWrapping.checked);
     },
 
-    _handleShowTabsChanged: function() {
+    _handleShowTabsChanged() {
       this.set('_diffPrefs.show_tabs', this.$.showTabs.checked);
     },
 
-    _handleShowTrailingWhitespaceChanged: function() {
+    _handleShowTrailingWhitespaceChanged() {
       this.set('_diffPrefs.show_whitespace_errors',
           this.$.showTrailingWhitespace.checked);
     },
 
-    _handleSyntaxHighlightingChanged: function() {
+    _handleSyntaxHighlightingChanged() {
       this.set('_diffPrefs.syntax_highlighting',
           this.$.syntaxHighlighting.checked);
     },
 
-    _handleSaveChangeTable: function() {
+    _handleSaveChangeTable() {
       this.set('prefs.change_table', this._localChangeTableColumns);
       this._cloneChangeTableColumns();
-      return this.$.restAPI.savePreferences(this.prefs).then(function() {
+      return this.$.restAPI.savePreferences(this.prefs).then(() => {
         this._changeTableChanged = false;
-      }.bind(this));
+      });
     },
 
-    _handleSaveDiffPreferences: function() {
+    _handleSaveDiffPreferences() {
       return this.$.restAPI.saveDiffPreferences(this._diffPrefs)
-          .then(function() {
+          .then(() => {
             this._diffPrefsChanged = false;
-          }.bind(this));
+          });
     },
 
-    _handleSaveMenu: function() {
+    _handleSaveMenu() {
       this.set('prefs.my', this._localMenu);
       this._cloneMenu();
-      return this.$.restAPI.savePreferences(this.prefs).then(function() {
+      return this.$.restAPI.savePreferences(this.prefs).then(() => {
         this._menuChanged = false;
-      }.bind(this));
+      });
     },
 
-    _handleSaveWatchedProjects: function() {
+    _handleSaveWatchedProjects() {
       this.$.watchedProjectsEditor.save();
     },
 
-    _computeHeaderClass: function(changed) {
+    _computeHeaderClass(changed) {
       return changed ? 'edited' : '';
     },
 
-    _handleSaveEmails: function() {
+    _handleSaveEmails() {
       this.$.emailEditor.save();
     },
 
-    _handleNewEmailKeydown: function(e) {
+    _handleNewEmailKeydown(e) {
       if (e.keyCode === 13) { // Enter
         e.stopPropagation();
         this._handleAddEmailButton();
       }
     },
 
-    _isNewEmailValid: function(newEmail) {
-      return newEmail.indexOf('@') !== -1;
+    _isNewEmailValid(newEmail) {
+      return newEmail.includes('@');
     },
 
-    _computeAddEmailButtonEnabled: function(newEmail, addingEmail) {
+    _computeAddEmailButtonEnabled(newEmail, addingEmail) {
       return this._isNewEmailValid(newEmail) && !addingEmail;
     },
 
-    _handleAddEmailButton: function() {
+    _handleAddEmailButton() {
       if (!this._isNewEmailValid(this._newEmail)) { return; }
 
       this._addingEmail = true;
-      this.$.restAPI.addAccountEmail(this._newEmail).then(function(response) {
+      this.$.restAPI.addAccountEmail(this._newEmail).then(response => {
         this._addingEmail = false;
 
         // If it was unsuccessful.
@@ -353,7 +359,7 @@
 
         this._lastSentVerificationEmail = this._newEmail;
         this._newEmail = '';
-      }.bind(this));
+      });
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
index cb471d4..2d6c16d 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
@@ -39,18 +39,18 @@
 </test-fixture>
 
 <script>
-  suite('gr-settings-view tests', function() {
-    var element;
-    var account;
-    var preferences;
-    var diffPreferences;
-    var config;
-    var sandbox;
+  suite('gr-settings-view tests', () => {
+    let element;
+    let account;
+    let preferences;
+    let diffPreferences;
+    let config;
+    let sandbox;
 
     function valueOf(title, fieldsetid) {
-      var sections = element.$[fieldsetid].querySelectorAll('section');
-      var titleEl;
-      for (var i = 0; i < sections.length; i++) {
+      const sections = element.$[fieldsetid].querySelectorAll('section');
+      let titleEl;
+      for (let i = 0; i < sections.length; i++) {
         titleEl = sections[i].querySelector('.title');
         if (titleEl.textContent === title) {
           return sections[i].querySelector('.value');
@@ -61,7 +61,7 @@
     // Because deepEqual isn't behaving in Safari.
     function assertMenusEqual(actual, expected) {
       assert.equal(actual.length, expected.length);
-      for (var i = 0; i < actual.length; i++) {
+      for (let i = 0; i < actual.length; i++) {
         assert.equal(actual[i].name, expected[i].name);
         assert.equal(actual[i].url, expected[i].url);
       }
@@ -69,10 +69,10 @@
 
     function stubAddAccountEmail(statusCode) {
       return sandbox.stub(element.$.restAPI, 'addAccountEmail',
-          function() { return Promise.resolve({status: statusCode}); });
+          () => { return Promise.resolve({status: statusCode}); });
     }
 
-    setup(function(done) {
+    setup(done => {
       sandbox = sinon.sandbox.create();
       account = {
         _account_id: 123,
@@ -109,23 +109,23 @@
         syntax_highlighting: true,
         auto_hide_diff_table_header: true,
         theme: 'DEFAULT',
-        ignore_whitespace: 'IGNORE_NONE'
+        ignore_whitespace: 'IGNORE_NONE',
       };
       config = {auth: {editable_account_fields: []}};
 
       stub('gr-rest-api-interface', {
-        getLoggedIn: function() { return Promise.resolve(true); },
-        getAccount: function() { return Promise.resolve(account); },
-        getPreferences: function() { return Promise.resolve(preferences); },
-        getDiffPreferences: function() {
+        getLoggedIn() { return Promise.resolve(true); },
+        getAccount() { return Promise.resolve(account); },
+        getPreferences() { return Promise.resolve(preferences); },
+        getDiffPreferences() {
           return Promise.resolve(diffPreferences);
         },
-        getWatchedProjects: function() {
+        getWatchedProjects() {
           return Promise.resolve([]);
         },
-        getAccountEmails: function() { return Promise.resolve(); },
-        getConfig: function() { return Promise.resolve(config); },
-        getAccountGroups: function() { return Promise.resolve([]); },
+        getAccountEmails() { return Promise.resolve(); },
+        getConfig() { return Promise.resolve(config); },
+        getAccountGroups() { return Promise.resolve([]); },
       });
       element = fixture('basic');
 
@@ -133,19 +133,19 @@
       element._loadingPromise.then(done);
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('calls the title-change event', function() {
-      var titleChangedStub = sandbox.stub();
+    test('calls the title-change event', () => {
+      const titleChangedStub = sandbox.stub();
 
       // Create a new view.
-      var newElement = document.createElement('gr-settings-view');
+      const newElement = document.createElement('gr-settings-view');
       newElement.addEventListener('title-change', titleChangedStub);
 
       // Attach it to the fixture.
-      var blank = fixture('blank');
+      const blank = fixture('blank');
       blank.appendChild(newElement);
 
       Polymer.dom.flush();
@@ -155,7 +155,7 @@
           'Settings');
     });
 
-    test('user preferences', function(done) {
+    test('user preferences', done => {
       // Rendered with the expected preferences selected.
       assert.equal(valueOf('Changes per page', 'preferences')
           .firstElementChild.bindValue, preferences.changes_per_page);
@@ -171,15 +171,17 @@
           .firstElementChild.bindValue, preferences.diff_view);
       assert.equal(valueOf('Expand inline diffs', 'preferences')
           .firstElementChild.checked, false);
+      assert.equal(valueOf('Publish comments on push', 'preferences')
+          .firstElementChild.checked, false);
 
       assert.isFalse(element._prefsChanged);
       assert.isFalse(element._menuChanged);
 
       // Change the diff view element.
-      var diffSelect = valueOf('Diff view', 'preferences').firstElementChild;
+      const diffSelect = valueOf('Diff view', 'preferences').firstElementChild;
       diffSelect.bindValue = 'SIDE_BY_SIDE';
 
-      var expandInlineDiffs =
+      const expandInlineDiffs =
           valueOf('Expand inline diffs', 'preferences').firstElementChild;
       diffSelect.fire('change');
 
@@ -189,23 +191,46 @@
       assert.isFalse(element._menuChanged);
 
       stub('gr-rest-api-interface', {
-        savePreferences: function(prefs) {
+        savePreferences(prefs) {
           assert.equal(prefs.diff_view, 'SIDE_BY_SIDE');
           assertMenusEqual(prefs.my, preferences.my);
           assert.equal(prefs.expand_inline_diffs, true);
           return Promise.resolve();
-        }
+        },
       });
 
       // Save the change.
-      element._handleSavePreferences().then(function() {
+      element._handleSavePreferences().then(() => {
         assert.isFalse(element._prefsChanged);
         assert.isFalse(element._menuChanged);
         done();
       });
     });
 
-    test('diff preferences', function(done) {
+    test('publish comments on push', done => {
+      const publishCommentsOnPush =
+        valueOf('Publish comments on push', 'preferences').firstElementChild;
+      MockInteractions.tap(publishCommentsOnPush);
+
+      assert.isFalse(element._menuChanged);
+      assert.isTrue(element._prefsChanged);
+
+      stub('gr-rest-api-interface', {
+        savePreferences(prefs) {
+          assert.equal(prefs.publish_comments_on_push, true);
+          return Promise.resolve();
+        },
+      });
+
+      // Save the change.
+      element._handleSavePreferences().then(() => {
+        assert.isFalse(element._prefsChanged);
+        assert.isFalse(element._menuChanged);
+        done();
+      });
+    });
+
+    test('diff preferences', done => {
       // Rendered with the expected preferences selected.
       assert.equal(valueOf('Context', 'diffPreferences')
           .firstElementChild.bindValue, diffPreferences.context);
@@ -224,7 +249,7 @@
 
       assert.isFalse(element._diffPrefsChanged);
 
-      var showTabsCheckbox = valueOf('Show tabs', 'diffPreferences')
+      const showTabsCheckbox = valueOf('Show tabs', 'diffPreferences')
           .firstElementChild;
       showTabsCheckbox.checked = false;
       element._handleShowTabsChanged();
@@ -232,20 +257,20 @@
       assert.isTrue(element._diffPrefsChanged);
 
       stub('gr-rest-api-interface', {
-        saveDiffPreferences: function(prefs) {
+        saveDiffPreferences(prefs) {
           assert.equal(prefs.show_tabs, false);
           return Promise.resolve();
-        }
+        },
       });
 
       // Save the change.
-      element._handleSaveDiffPreferences().then(function() {
+      element._handleSaveDiffPreferences().then(() => {
         assert.isFalse(element._diffPrefsChanged);
         done();
       });
     });
 
-    test('columns input is hidden with fit to scsreen is selected', function() {
+    test('columns input is hidden with fit to scsreen is selected', () => {
       assert.isFalse(element.$.columnsPref.hidden);
 
       MockInteractions.tap(element.$.lineWrapping);
@@ -255,14 +280,14 @@
       assert.isFalse(element.$.columnsPref.hidden);
     });
 
-    test('menu', function(done) {
+    test('menu', done => {
       assert.isFalse(element._menuChanged);
       assert.isFalse(element._prefsChanged);
 
       assertMenusEqual(element._localMenu, preferences.my);
 
-      var menu = element.$.menu.firstElementChild;
-      var tableRows = Polymer.dom(menu.root).querySelectorAll('tbody tr');
+      const menu = element.$.menu.firstElementChild;
+      let tableRows = Polymer.dom(menu.root).querySelectorAll('tbody tr');
       assert.equal(tableRows.length, preferences.my.length);
 
       // Add a menu item:
@@ -276,13 +301,13 @@
       assert.isFalse(element._prefsChanged);
 
       stub('gr-rest-api-interface', {
-        savePreferences: function(prefs) {
+        savePreferences(prefs) {
           assertMenusEqual(prefs.my, element._localMenu);
           return Promise.resolve();
-        }
+        },
       });
 
-      element._handleSaveMenu().then(function() {
+      element._handleSaveMenu().then(() => {
         assert.isFalse(element._menuChanged);
         assert.isFalse(element._prefsChanged);
         assertMenusEqual(element.prefs.my, element._localMenu);
@@ -290,7 +315,7 @@
       });
     });
 
-    test('add email validation', function() {
+    test('add email validation', () => {
       assert.isFalse(element._isNewEmailValid('invalid email'));
       assert.isTrue(element._isNewEmailValid('vaguely@valid.email'));
 
@@ -302,8 +327,8 @@
           element._computeAddEmailButtonEnabled('vaguely@valid.email', false));
     });
 
-    test('add email does not save invalid', function() {
-      var addEmailStub = stubAddAccountEmail(201);
+    test('add email does not save invalid', () => {
+      const addEmailStub = stubAddAccountEmail(201);
 
       assert.isFalse(element._addingEmail);
       assert.isNotOk(element._lastSentVerificationEmail);
@@ -318,8 +343,8 @@
       assert.isFalse(addEmailStub.called);
     });
 
-    test('add email does save valid', function(done) {
-      var addEmailStub = stubAddAccountEmail(201);
+    test('add email does save valid', done => {
+      const addEmailStub = stubAddAccountEmail(201);
 
       assert.isFalse(element._addingEmail);
       assert.isNotOk(element._lastSentVerificationEmail);
@@ -331,14 +356,14 @@
       assert.isTrue(addEmailStub.called);
 
       assert.isTrue(addEmailStub.called);
-      addEmailStub.lastCall.returnValue.then(function() {
+      addEmailStub.lastCall.returnValue.then(() => {
         assert.isOk(element._lastSentVerificationEmail);
         done();
       });
     });
 
-    test('add email does not set last-email if error', function(done) {
-      var addEmailStub = stubAddAccountEmail(500);
+    test('add email does not set last-email if error', done => {
+      const addEmailStub = stubAddAccountEmail(500);
 
       assert.isNotOk(element._lastSentVerificationEmail);
       element._newEmail = 'valid@email.com';
@@ -346,58 +371,57 @@
       element._handleAddEmailButton();
 
       assert.isTrue(addEmailStub.called);
-      addEmailStub.lastCall.returnValue.then(function() {
+      addEmailStub.lastCall.returnValue.then(() => {
         assert.isNotOk(element._lastSentVerificationEmail);
         done();
       });
     });
 
-    test('emails are loaded without emailToken', function() {
+    test('emails are loaded without emailToken', () => {
       sandbox.stub(element.$.emailEditor, 'loadData');
       element.params = {};
       element.attached();
       assert.isTrue(element.$.emailEditor.loadData.calledOnce);
     });
 
-    suite('when email verification token is provided', function() {
-      var resolveConfirm;
+    suite('when email verification token is provided', () => {
+      let resolveConfirm;
 
-      setup(function() {
+      setup(() => {
         sandbox.stub(element.$.emailEditor, 'loadData');
-        sandbox.stub(element.$.restAPI, 'confirmEmail', function() {
-          return new Promise(function(resolve) { resolveConfirm = resolve; });
+        sandbox.stub(element.$.restAPI, 'confirmEmail', () => {
+          return new Promise(resolve => { resolveConfirm = resolve; });
         });
         element.params = {emailToken: 'foo'};
         element.attached();
       });
 
-      test('it is used to confirm email via rest API', function() {
+      test('it is used to confirm email via rest API', () => {
         assert.isTrue(element.$.restAPI.confirmEmail.calledOnce);
         assert.isTrue(element.$.restAPI.confirmEmail.calledWith('foo'));
       });
 
-      test('emails are not loaded initially', function() {
+      test('emails are not loaded initially', () => {
         assert.isFalse(element.$.emailEditor.loadData.called);
       });
 
-      test('user emails are loaded after email confirmed', function(done) {
-        element._loadingPromise.then(function() {
+      test('user emails are loaded after email confirmed', done => {
+        element._loadingPromise.then(() => {
           assert.isTrue(element.$.emailEditor.loadData.calledOnce);
           done();
         });
         resolveConfirm();
       });
 
-      test('show-alert is fired when email is confirmed', function(done) {
+      test('show-alert is fired when email is confirmed', done => {
         sandbox.spy(element, 'fire');
-        element._loadingPromise.then(function() {
+        element._loadingPromise.then(() => {
           assert.isTrue(
               element.fire.calledWith('show-alert', {message: 'bar'}));
           done();
         });
         resolveConfirm('bar');
       });
-
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html
index 339c7a7..5aec600 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html
@@ -16,7 +16,7 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../styles/gr-settings-styles.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -49,8 +49,8 @@
         right: 2em;
       }
     </style>
-    <style include="gr-settings-styles"></style>
-    <div class="gr-settings-styles">
+    <style include="gr-form-styles"></style>
+    <div class="gr-form-styles">
       <fieldset>
         <table>
           <thead>
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
index 2a05033..d9f02d5 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
@@ -31,64 +31,63 @@
       },
       _keysToRemove: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
     },
 
-    loadData: function() {
-      return this.$.restAPI.getAccountSSHKeys().then(function(keys) {
+    loadData() {
+      return this.$.restAPI.getAccountSSHKeys().then(keys => {
         this._keys = keys;
-      }.bind(this));
+      });
     },
 
-    save: function() {
-      var promises = this._keysToRemove.map(function(key) {
+    save() {
+      const promises = this._keysToRemove.map(key => {
         this.$.restAPI.deleteAccountSSHKey(key.seq);
-      }.bind(this));
+      });
 
-      return Promise.all(promises).then(function() {
+      return Promise.all(promises).then(() => {
         this._keysToRemove = [];
         this.hasUnsavedChanges = false;
-      }.bind(this));
+      });
     },
 
-    _getStatusLabel: function(isValid) {
+    _getStatusLabel(isValid) {
       return isValid ? 'Valid' : 'Invalid';
     },
 
-    _showKey: function(e) {
-      var index = parseInt(e.target.getAttribute('data-index'), 10);
+    _showKey(e) {
+      const index = parseInt(e.target.getAttribute('data-index'), 10);
       this._keyToView = this._keys[index];
       this.$.viewKeyOverlay.open();
     },
 
-    _closeOverlay: function() {
+    _closeOverlay() {
       this.$.viewKeyOverlay.close();
     },
 
-    _handleDeleteKey: function(e) {
-      var index = parseInt(e.target.getAttribute('data-index'), 10);
+    _handleDeleteKey(e) {
+      const index = parseInt(e.target.getAttribute('data-index'), 10);
       this.push('_keysToRemove', this._keys[index]);
       this.splice('_keys', index, 1);
       this.hasUnsavedChanges = true;
     },
 
-    _handleAddKey: function() {
+    _handleAddKey() {
       this.$.addButton.disabled = true;
       this.$.newKey.disabled = true;
       return this.$.restAPI.addAccountSSHKey(this._newKey.trim())
-          .then(function(key) {
+          .then(key => {
             this.$.newKey.disabled = false;
             this._newKey = '';
             this.push('_keys', key);
-          }.bind(this))
-          .catch(function() {
+          }).catch(() => {
             this.$.addButton.disabled = false;
             this.$.newKey.disabled = false;
-          }.bind(this));
+          });
     },
 
-    _computeAddButtonDisabled: function(newKey) {
+    _computeAddButtonDisabled(newKey) {
       return !newKey.length;
     },
   });
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
index 7bb5528..09da19f 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
@@ -33,11 +33,11 @@
 </test-fixture>
 
 <script>
-  suite('gr-ssh-editor tests', function() {
-    var element;
-    var keys;
+  suite('gr-ssh-editor tests', () => {
+    let element;
+    let keys;
 
-    setup(function(done) {
+    setup(done => {
       keys = [{
         seq: 1,
         ssh_public_key: 'ssh-rsa <key 1> comment-one@machine-one',
@@ -55,37 +55,37 @@
       }];
 
       stub('gr-rest-api-interface', {
-        getAccountSSHKeys: function() { return Promise.resolve(keys); },
+        getAccountSSHKeys() { return Promise.resolve(keys); },
       });
 
       element = fixture('basic');
 
-      element.loadData().then(function() { flush(done); });
+      element.loadData().then(() => { flush(done); });
     });
 
-    test('renders', function() {
-      var rows = Polymer.dom(element.root).querySelectorAll('tbody tr');
+    test('renders', () => {
+      const rows = Polymer.dom(element.root).querySelectorAll('tbody tr');
 
       assert.equal(rows.length, 2);
 
-      var cells = rows[0].querySelectorAll('td');
+      let cells = rows[0].querySelectorAll('td');
       assert.equal(cells[0].textContent, keys[0].comment);
 
       cells = rows[1].querySelectorAll('td');
       assert.equal(cells[0].textContent, keys[1].comment);
     });
 
-    test('remove key', function(done) {
-      var lastKey = keys[1];
+    test('remove key', done => {
+      const lastKey = keys[1];
 
-      var saveStub = sinon.stub(element.$.restAPI, 'deleteAccountSSHKey',
-          function() { return Promise.resolve(); });
+      const saveStub = sinon.stub(element.$.restAPI, 'deleteAccountSSHKey',
+          () => { return Promise.resolve(); });
 
       assert.equal(element._keysToRemove.length, 0);
       assert.isFalse(element.hasUnsavedChanges);
 
       // Get the delete button for the last row.
-      var button = Polymer.dom(element.root).querySelector(
+      const button = Polymer.dom(element.root).querySelector(
           'tbody tr:last-of-type td:nth-child(4) gr-button');
 
       MockInteractions.tap(button);
@@ -96,7 +96,7 @@
       assert.isTrue(element.hasUnsavedChanges);
       assert.isFalse(saveStub.called);
 
-      element.save().then(function() {
+      element.save().then(() => {
         assert.isTrue(saveStub.called);
         assert.equal(saveStub.lastCall.args[0], lastKey.seq);
         assert.equal(element._keysToRemove.length, 0);
@@ -105,11 +105,11 @@
       });
     });
 
-    test('show key', function() {
-      var openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
+    test('show key', () => {
+      const openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
 
       // Get the show button for the last row.
-      var button = Polymer.dom(element.root).querySelector(
+      const button = Polymer.dom(element.root).querySelector(
           'tbody tr:last-of-type td:nth-child(3) gr-button');
 
       MockInteractions.tap(button);
@@ -118,9 +118,9 @@
       assert.isTrue(openSpy.called);
     });
 
-    test('add key', function(done) {
-      var newKeyString = 'ssh-rsa <key 3> comment-three@machine-three';
-      var newKeyObject = {
+    test('add key', done => {
+      const newKeyString = 'ssh-rsa <key 3> comment-three@machine-three';
+      const newKeyObject = {
         seq: 3,
         ssh_public_key: newKeyString,
         encoded_key: '<key 3>',
@@ -129,15 +129,15 @@
         valid: true,
       };
 
-      var addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey',
-          function() { return Promise.resolve(newKeyObject); });
+      const addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey',
+          () => { return Promise.resolve(newKeyObject); });
 
       element._newKey = newKeyString;
 
       assert.isFalse(element.$.addButton.disabled);
       assert.isFalse(element.$.newKey.disabled);
 
-      element._handleAddKey().then(function() {
+      element._handleAddKey().then(() => {
         assert.isTrue(element.$.addButton.disabled);
         assert.isFalse(element.$.newKey.disabled);
         assert.equal(element._keys.length, 3);
@@ -151,18 +151,18 @@
       assert.equal(addStub.lastCall.args[0], newKeyString);
     });
 
-    test('add invalid key', function(done) {
-      var newKeyString = 'not even close to valid';
+    test('add invalid key', done => {
+      const newKeyString = 'not even close to valid';
 
-      var addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey',
-          function() { return Promise.reject(); });
+      const addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey',
+          () => { return Promise.reject(); });
 
       element._newKey = newKeyString;
 
       assert.isFalse(element.$.addButton.disabled);
       assert.isFalse(element.$.newKey.disabled);
 
-      element._handleAddKey().then(function() {
+      element._handleAddKey().then(() => {
         assert.isFalse(element.$.addButton.disabled);
         assert.isFalse(element.$.newKey.disabled);
         assert.equal(element._keys.length, 2);
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html
index 61d35f6..f10a2a6 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html
@@ -17,7 +17,7 @@
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/gr-settings-styles.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
 
 <dom-module id="gr-watched-projects-editor">
   <template>
@@ -54,8 +54,8 @@
         width: 26em;
       }
     </style>
-    <style include="gr-settings-styles"></style>
-    <div class="gr-settings-styles">
+    <style include="gr-form-styles"></style>
+    <div class="gr-form-styles">
       <table>
         <thead>
           <tr>
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
index d65f512..c1eabad 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
@@ -14,7 +14,7 @@
 (function() {
   'use strict';
 
-  var NOTIFICATION_TYPES = [
+  const NOTIFICATION_TYPES = [
     {name: 'Changes', key: 'notify_new_changes'},
     {name: 'Patches', key: 'notify_new_patch_sets'},
     {name: 'Comments', key: 'notify_all_comments'},
@@ -35,24 +35,24 @@
       _projects: Array,
       _projectsToRemove: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       _query: {
         type: Function,
-        value: function() {
+        value() {
           return this._getProjectSuggestions.bind(this);
         },
       },
     },
 
-    loadData: function() {
-      return this.$.restAPI.getWatchedProjects().then(function(projs) {
+    loadData() {
+      return this.$.restAPI.getWatchedProjects().then(projs => {
         this._projects = projs;
-      }.bind(this));
+      });
     },
 
-    save: function() {
-      var deletePromise;
+    save() {
+      let deletePromise;
       if (this._projectsToRemove.length) {
         deletePromise = this.$.restAPI.deleteWatchedProjects(
             this._projectsToRemove);
@@ -61,56 +61,57 @@
       }
 
       return deletePromise
-          .then(function() {
+          .then(() => {
             return this.$.restAPI.saveWatchedProjects(this._projects);
-          }.bind(this))
-          .then(function(projects) {
+          })
+          .then(projects => {
             this._projects = projects;
             this._projectsToRemove = [];
             this.hasUnsavedChanges = false;
-          }.bind(this));
+          });
     },
 
-    _getTypes: function() {
+    _getTypes() {
       return NOTIFICATION_TYPES;
     },
 
-    _getTypeCount: function() {
+    _getTypeCount() {
       return this._getTypes().length;
     },
 
-    _computeCheckboxChecked: function(project, key) {
+    _computeCheckboxChecked(project, key) {
       return project.hasOwnProperty(key);
     },
 
-    _getProjectSuggestions: function(input) {
+    _getProjectSuggestions(input) {
       return this.$.restAPI.getSuggestedProjects(input)
-        .then(function(response) {
-          var projects = [];
-          for (var key in response) {
-            projects.push({
-              name: key,
-              value: response[key],
-            });
-          }
-          return projects;
-        });
+          .then(response => {
+            const projects = [];
+            for (const key in response) {
+              if (!response.hasOwnProperty(key)) { continue; }
+              projects.push({
+                name: key,
+                value: response[key],
+              });
+            }
+            return projects;
+          });
     },
 
-    _handleRemoveProject: function(e) {
-      var index = parseInt(e.target.getAttribute('data-index'), 10);
-      var project = this._projects[index];
+    _handleRemoveProject(e) {
+      const index = parseInt(e.target.getAttribute('data-index'), 10);
+      const project = this._projects[index];
       this.splice('_projects', index, 1);
       this.push('_projectsToRemove', project);
       this.hasUnsavedChanges = true;
     },
 
-    _canAddProject: function(project, filter) {
+    _canAddProject(project, filter) {
       if (!project || !project.id) { return false; }
 
       // Check if the project with filter is already in the list. Compare
       // filters using == to coalesce null and undefined.
-      for (var i = 0; i < this._projects.length; i++) {
+      for (let i = 0; i < this._projects.length; i++) {
         if (this._projects[i].project === project.id &&
             this._projects[i].filter == filter) {
           return false;
@@ -120,8 +121,9 @@
       return true;
     },
 
-    _getNewProjectIndex: function(name, filter) {
-      for (var i = 0; i < this._projects.length; i++) {
+    _getNewProjectIndex(name, filter) {
+      let i;
+      for (i = 0; i < this._projects.length; i++) {
         if (this._projects[i].project > name ||
             (this._projects[i].project === name &&
                 this._projects[i].filter > filter)) {
@@ -131,18 +133,18 @@
       return i;
     },
 
-    _handleAddProject: function() {
-      var newProject = this.$.newProject.value;
-      var newProjectName = this.$.newProject.text;
-      var filter = this.$.newFilter.value || null;
+    _handleAddProject() {
+      const newProject = this.$.newProject.value;
+      const newProjectName = this.$.newProject.text;
+      const filter = this.$.newFilter.value || null;
 
       if (!this._canAddProject(newProject, filter)) { return; }
 
-      var insertIndex = this._getNewProjectIndex(newProjectName, filter);
+      const insertIndex = this._getNewProjectIndex(newProjectName, filter);
 
       this.splice('_projects', insertIndex, 0, {
         project: newProjectName,
-        filter: filter,
+        filter,
         _is_local: true,
       });
 
@@ -151,16 +153,16 @@
       this.hasUnsavedChanges = true;
     },
 
-    _handleCheckboxChange: function(e) {
-      var index = parseInt(e.target.getAttribute('data-index'), 10);
-      var key = e.target.getAttribute('data-key');
-      var checked = e.target.checked;
+    _handleCheckboxChange(e) {
+      const index = parseInt(e.target.getAttribute('data-index'), 10);
+      const key = e.target.getAttribute('data-key');
+      const checked = e.target.checked;
       this.set(['_projects', index, key], !!checked);
       this.hasUnsavedChanges = true;
     },
 
-    _handleNotifCellTap: function(e) {
-      var checkbox = Polymer.dom(e.target).querySelector('input');
+    _handleNotifCellTap(e) {
+      const checkbox = Polymer.dom(e.target).querySelector('input');
       if (checkbox) { checkbox.click(); }
     },
   });
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
index 59e87b0..1e6ed7a 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
@@ -33,11 +33,12 @@
 </test-fixture>
 
 <script>
-  suite('gr-watched-projects-editor tests', function() {
-    var element;
+  suite('gr-watched-projects-editor tests', () => {
+    let element;
 
-    setup(function(done) {
-      var projects = [{
+    setup(done => {
+      const projects = [
+        {
           project: 'project a',
           notify_submitted_changes: true,
           notify_abandoned_changes: true,
@@ -57,8 +58,8 @@
       ];
 
       stub('gr-rest-api-interface', {
-        getSuggestedProjects: function(input) {
-          if (input.indexOf('the') === 0) {
+        getSuggestedProjects(input) {
+          if (input.startsWith('the')) {
             return Promise.resolve({'the project': {
               id: 'the project',
               state: 'ACTIVE',
@@ -68,27 +69,27 @@
             return Promise.resolve({});
           }
         },
-        getWatchedProjects: function() {
+        getWatchedProjects() {
           return Promise.resolve(projects);
         },
       });
 
       element = fixture('basic');
 
-      element.loadData().then(function() { flush(done); });
+      element.loadData().then(() => { flush(done); });
     });
 
-    test('renders', function() {
-      var rows = element.$$('table').querySelectorAll('tbody tr');
+    test('renders', () => {
+      const rows = element.$$('table').querySelectorAll('tbody tr');
       assert.equal(rows.length, 4);
 
       function getKeysOfRow(row) {
-        var boxes = rows[row].querySelectorAll('input[checked]');
+        const boxes = rows[row].querySelectorAll('input[checked]');
         return Array.prototype.map.call(boxes,
-            function(e) { return e.getAttribute('data-key'); });
+            e => { return e.getAttribute('data-key'); });
       }
 
-      var checkedKeys = getKeysOfRow(0);
+      let checkedKeys = getKeysOfRow(0);
       assert.equal(checkedKeys.length, 2);
       assert.equal(checkedKeys[0], 'notify_submitted_changes');
       assert.equal(checkedKeys[1], 'notify_abandoned_changes');
@@ -107,22 +108,22 @@
       assert.equal(checkedKeys[2], 'notify_all_comments');
     });
 
-    test('_getProjectSuggestions empty', function(done) {
-      element._getProjectSuggestions('nonexistent').then(function(projects) {
+    test('_getProjectSuggestions empty', done => {
+      element._getProjectSuggestions('nonexistent').then(projects => {
         assert.equal(projects.length, 0);
         done();
       });
     });
 
-    test('_getProjectSuggestions non-empty', function(done) {
-      element._getProjectSuggestions('the project').then(function(projects) {
+    test('_getProjectSuggestions non-empty', done => {
+      element._getProjectSuggestions('the project').then(projects => {
         assert.equal(projects.length, 1);
         assert.equal(projects[0].name, 'the project');
         done();
       });
     });
 
-    test('_canAddProject', function() {
+    test('_canAddProject', () => {
       assert.isFalse(element._canAddProject(null, null));
       assert.isFalse(element._canAddProject({}, null));
 
@@ -144,7 +145,7 @@
       assert.isTrue(element._canAddProject({id: 'project b'}, 'filter 3'));
     });
 
-    test('_getNewProjectIndex', function() {
+    test('_getNewProjectIndex', () => {
       // Projects are sorted in ASCII order.
       assert.equal(element._getNewProjectIndex('project A', 'filter'), 0);
       assert.equal(element._getNewProjectIndex('project a', 'filter'), 1);
@@ -158,7 +159,7 @@
       assert.equal(element._getNewProjectIndex('project c', 'filter'), 4);
     });
 
-    test('_handleAddProject', function() {
+    test('_handleAddProject', () => {
       element.$.newProject.value = {id: 'project d'};
       element.$.newProject.setText('project d');
       element.$.newFilter.bindValue = '';
@@ -171,7 +172,7 @@
       assert.isTrue(element._projects[4]._is_local);
     });
 
-    test('_handleAddProject with invalid inputs', function() {
+    test('_handleAddProject with invalid inputs', () => {
       element.$.newProject.value = {id: 'project b'};
       element.$.newProject.setText('project b');
       element.$.newFilter.bindValue = 'filter 1';
@@ -181,14 +182,14 @@
       assert.equal(element._projects.length, 4);
     });
 
-    test('_handleRemoveProject', function() {
+    test('_handleRemoveProject', () => {
       assert.equal(element._projectsToRemove, 0);
-      var button = element.$$('table tbody tr:nth-child(2) gr-button');
+      const button = element.$$('table tbody tr:nth-child(2) gr-button');
       MockInteractions.tap(button);
 
       flushAsynchronousOperations();
 
-      var rows = element.$$('table tbody').querySelectorAll('tr');
+      const rows = element.$$('table tbody').querySelectorAll('tr');
       assert.equal(rows.length, 3);
 
       assert.equal(element._projectsToRemove.length, 1);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
index 33fc50e..c4650f3e 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
@@ -47,23 +47,23 @@
       },
     },
 
-    ready: function() {
-      this._getHasAvatars().then(function(hasAvatars) {
+    ready() {
+      this._getHasAvatars().then(hasAvatars => {
         this.showAvatar = hasAvatars;
-      }.bind(this));
+      });
     },
 
-    _getBackgroundClass: function(transparent) {
+    _getBackgroundClass(transparent) {
       return transparent ? 'transparentBackground' : '';
     },
 
-    _handleRemoveTap: function(e) {
+    _handleRemoveTap(e) {
       e.preventDefault();
       this.fire('remove', {account: this.account});
     },
 
-    _getHasAvatars: function() {
-      return this.$.restAPI.getConfig().then(function(cfg) {
+    _getHasAvatars() {
+      return this.$.restAPI.getConfig().then(cfg => {
         return Promise.resolve(!!(cfg && cfg.plugin && cfg.plugin.has_avatars));
       });
     },
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
index e9f18df..9ebef82 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
@@ -29,9 +29,9 @@
       },
     },
 
-    _computeAccountTitle: function(account) {
+    _computeAccountTitle(account) {
       if (!account || (!account.name && !account.email)) { return; }
-      var result = '';
+      let result = '';
       if (account.name) {
         result += account.name;
       }
@@ -41,11 +41,11 @@
       return result;
     },
 
-    _computeShowEmail: function(showEmail, account) {
+    _computeShowEmail(showEmail, account) {
       return !!(showEmail && account && account.email);
     },
 
-    _computeEmailStr: function(account) {
+    _computeEmailStr(account) {
       if (!account || !account.email) {
         return '';
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
index de7d6a3..2c43879 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
@@ -33,32 +33,32 @@
 </test-fixture>
 
 <script>
-  suite('gr-account-label tests', function() {
-    var element;
+  suite('gr-account-label tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
-        getLoggedIn: function() { return Promise.resolve(false); },
+        getConfig() { return Promise.resolve({}); },
+        getLoggedIn() { return Promise.resolve(false); },
       });
       element = fixture('basic');
     });
 
-    test('null guard', function() {
-      assert.doesNotThrow(function() {
+    test('null guard', () => {
+      assert.doesNotThrow(() => {
         element.account = null;
       });
     });
 
-    test('missing email', function() {
+    test('missing email', () => {
       assert.equal('', element._computeEmailStr({name: 'foo'}));
     });
 
-    test('computed fields', function() {
+    test('computed fields', () => {
       assert.equal(element._computeAccountTitle(
           {
             name: 'Andrew Bonventre',
-            email: 'andybons+gerrit@gmail.com'
+            email: 'andybons+gerrit@gmail.com',
           }),
           'Andrew Bonventre <andybons+gerrit@gmail.com>');
 
@@ -69,7 +69,7 @@
       assert.equal(element._computeShowEmail(true,
           {
             name: 'Andrew Bonventre',
-            email: 'andybons+gerrit@gmail.com'
+            email: 'andybons+gerrit@gmail.com',
           }), true);
 
       assert.equal(element._computeShowEmail(true,
@@ -88,6 +88,5 @@
           element._computeEmailStr({name: 'test', email: 'test'}), '(test)');
       assert.equal(element._computeEmailStr({email: 'test'}, ''), 'test');
     });
-
   });
 </script>
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 69beb78..b30a26c 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
@@ -29,13 +29,13 @@
       Gerrit.BaseUrlBehavior,
     ],
 
-    _computeOwnerLink: function(account) {
+    _computeOwnerLink(account) {
       if (!account) { return; }
-      var accountID = account.email || account._account_id;
+      const accountID = account.email || account._account_id;
       return this.getBaseUrl() + '/q/owner:' + encodeURIComponent(accountID);
     },
 
-    _computeShowEmail: function(account) {
+    _computeShowEmail(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 5cc0600..29d1580 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
@@ -32,21 +32,21 @@
 </test-fixture>
 
 <script>
-  suite('gr-account-link tests', function() {
-    var element;
+  suite('gr-account-link tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
+        getConfig() { return Promise.resolve({}); },
       });
       element = fixture('basic');
     });
 
-    test('computed fields', function() {
+    test('computed fields', () => {
       assert.equal(element._computeOwnerLink(
           {
             _account_id: 123,
-            email: 'andybons+gerrit@gmail.com'
+            email: 'andybons+gerrit@gmail.com',
           }),
           '/q/owner:andybons%2Bgerrit%40gmail.com');
 
@@ -57,6 +57,5 @@
 
       assert.equal(element._computeShowEmail({}), true);
     });
-
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
index 10eadf9..71cdec4 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
@@ -17,6 +17,8 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../gr-button/gr-button.html">
 
+<script src="../../../scripts/rootElement.js"></script>
+
 <dom-module id="gr-alert">
   <template>
     <style>
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
index 84846fb..bfe7c25 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
@@ -41,50 +41,52 @@
       _hideActionButton: Boolean,
       _boundTransitionEndHandler: {
         type: Function,
-        value: function() { return this._handleTransitionEnd.bind(this); },
+        value() { return this._handleTransitionEnd.bind(this); },
       },
+      _actionCallback: Function,
     },
 
-    attached: function() {
+    attached() {
       this.addEventListener('transitionend', this._boundTransitionEndHandler);
     },
 
-    detached: function() {
+    detached() {
       this.removeEventListener('transitionend',
           this._boundTransitionEndHandler);
     },
 
-    show: function(text, opt_actionText) {
+    show(text, opt_actionText, opt_actionCallback) {
       this.text = text;
       this.actionText = opt_actionText;
       this._hideActionButton = !opt_actionText;
-      document.body.appendChild(this);
+      this._actionCallback = opt_actionCallback;
+      Gerrit.getRootElement().appendChild(this);
       this._setShown(true);
     },
 
-    hide: function() {
+    hide() {
       this._setShown(false);
       if (this._hasZeroTransitionDuration()) {
-        document.body.removeChild(this);
+        Gerrit.getRootElement().removeChild(this);
       }
     },
 
-    _hasZeroTransitionDuration: function() {
-      var style = window.getComputedStyle(this);
+    _hasZeroTransitionDuration() {
+      const style = window.getComputedStyle(this);
       // transitionDuration is always given in seconds.
-      var duration = Math.round(parseFloat(style.transitionDuration) * 100);
+      const duration = Math.round(parseFloat(style.transitionDuration) * 100);
       return duration === 0;
     },
 
-    _handleTransitionEnd: function(e) {
+    _handleTransitionEnd(e) {
       if (this.shown) { return; }
 
-      document.body.removeChild(this);
+      Gerrit.getRootElement().removeChild(this);
     },
 
-    _handleActionTap: function(e) {
+    _handleActionTap(e) {
       e.preventDefault();
-      this.fire('action', null, {bubbles: false});
+      if (this._actionCallback) { this._actionCallback(); }
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
index 067ac5b..6bbfcfb 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
@@ -25,20 +25,20 @@
 <link rel="import" href="gr-alert.html">
 
 <script>
-  suite('gr-alert tests', function() {
-    var element;
+  suite('gr-alert tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       element = document.createElement('gr-alert');
     });
 
-    teardown(function() {
+    teardown(() => {
       if (element.parentNode) {
         element.parentNode.removeChild(element);
       }
     });
 
-    test('show/hide', function() {
+    test('show/hide', () => {
       assert.isNull(element.parentNode);
       element.show();
       assert.equal(element.parentNode, document.body);
@@ -48,13 +48,10 @@
       assert.isNull(element.parentNode);
     });
 
-    test('action event', function(done) {
+    test('action event', done => {
       element.show();
-      element.addEventListener('action', function() {
-        done();
-      });
+      element._actionCallback = done;
       MockInteractions.tap(element.$$('.action'));
     });
-
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html
new file mode 100644
index 0000000..172bb18
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html
@@ -0,0 +1,72 @@
+<!--
+Copyright (C) 2017 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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
+<script src="../../../scripts/rootElement.js"></script>
+
+<dom-module id="gr-autocomplete-dropdown">
+  <template>
+    <style>
+      :host {
+        background: #fff;
+        box-shadow: rgba(0, 0, 0, 0.3) 0 1px 3px;
+        position: absolute;
+        z-index: 104;
+      }
+      /* This must be set here vs. the container component because in some cases
+      the element is moved in the DOM to a base element and is no longer a
+      child of its original parent. */
+      :host(.fixed){
+        position: fixed;
+      }
+      ul {
+        list-style: none;
+      }
+      li {
+        cursor: pointer;
+        padding: .5em .75em;
+      }
+      li:focus {
+        outline: none;
+      }
+      li.selected {
+        background-color: #eee;
+      }
+    </style>
+    <div id="suggestions" role="listbox">
+      <ul>
+        <template is="dom-repeat" items="[[suggestions]]">
+          <li data-index$="[[index]]"
+              data-value$="[[item.dataValue]]"
+              tabindex="-1"
+              aria-label$="[[item.name]]"
+              role="option"
+              on-tap="_handleTapItem">[[item.text]]</li>
+        </template>
+      </ul>
+    </div>
+    <gr-cursor-manager
+        id="cursor"
+        index="{{index}}"
+        cursor-target-class="selected"
+        scroll-behavior="never"
+        focus-on-move
+        stops="[[_suggestionEls]]"></gr-cursor-manager>
+  </template>
+  <script src="gr-autocomplete-dropdown.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
new file mode 100644
index 0000000..8be89c4
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
@@ -0,0 +1,170 @@
+// Copyright (C) 2017 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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-autocomplete-dropdown',
+
+    /**
+     * Fired when the dropdown is closed.
+     *
+     * @event dropdown-closed
+     */
+
+    /**
+     * Fired when item is selected.
+     *
+     * @event item-selected
+     */
+
+    properties: {
+      index: Number,
+      moveToRoot: Boolean,
+      fixedPosition: Boolean,
+      suggestions: {
+        type: Array,
+        observer: '_resetCursorStops',
+      },
+      _suggestionEls: {
+        type: Array,
+        observer: '_resetCursorIndex',
+      },
+    },
+
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+    ],
+
+    keyBindings: {
+      up: '_handleUp',
+      down: '_handleDown',
+      enter: '_handleEnter',
+      esc: '_handleEscape',
+      tab: '_handleTab',
+    },
+
+    attached() {
+      if (this.fixedPosition) {
+        this.classList.add('fixed');
+      }
+    },
+
+    close() {
+      if (this.moveToRoot) {
+        Gerrit.getRootElement().removeChild(this);
+      } else {
+        this.hidden = true;
+      }
+    },
+
+    open() {
+      if (this.moveToRoot) {
+        Gerrit.getRootElement().appendChild(this);
+      }
+      this._resetCursorStops();
+      this._resetCursorIndex();
+    },
+
+    setPosition(top, left) {
+      this.style.top = top;
+      this.style.left = left;
+    },
+
+    getCurrentText() {
+      return this.getCursorTarget().dataset.value;
+    },
+
+    _handleUp(e) {
+      if (!this.hidden) {
+        e.preventDefault();
+        e.stopPropagation();
+        this.cursorUp();
+      }
+    },
+
+    _handleDown(e) {
+      if (!this.hidden) {
+        e.preventDefault();
+        e.stopPropagation();
+        this.cursorDown();
+      }
+    },
+
+    // TODO @beckysiegel make this work with shadow dom.
+    cursorDown(e) {
+      if (!this.hidden) {
+        this.$.cursor.next();
+      }
+    },
+
+    // TODO @beckysiegel make this work with shadow dom.
+    cursorUp(e) {
+      if (!this.hidden) {
+        this.$.cursor.previous();
+      }
+    },
+
+    _handleTab(e) {
+      e.preventDefault();
+      e.stopPropagation();
+      this.fire('item-selected', {
+        trigger: 'tab',
+        selected: this.$.cursor.target,
+      });
+    },
+
+    _handleEnter(e) {
+      e.preventDefault();
+      e.stopPropagation();
+      this.fire('item-selected', {
+        trigger: 'enter',
+        selected: this.$.cursor.target,
+      });
+    },
+
+    _handleEscape() {
+      this._fireClose();
+      if (!this.hidden) {
+        this.close();
+      }
+    },
+
+    _handleTapItem(e) {
+      e.preventDefault();
+      e.stopPropagation();
+      this.fire('item-selected', {
+        trigger: 'tap',
+        selected: e.target,
+      });
+    },
+
+    _fireClose() {
+      this.fire('dropdown-closed');
+    },
+
+    getCursorTarget() {
+      return this.$.cursor.target;
+    },
+
+    _resetCursorStops() {
+      Polymer.dom.flush();
+      this._suggestionEls = this.$.suggestions.querySelectorAll('li');
+    },
+
+    _resetCursorIndex() {
+      this.$.cursor.setCursorAtIndex(0);
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html
new file mode 100644
index 0000000..c071f0c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html
@@ -0,0 +1,205 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-autocomplete-dropdown</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="gr-autocomplete-dropdown.html">
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-autocomplete-dropdown id="dropdown"></gr-autocomplete-dropdown>
+  </template>
+</test-fixture>
+
+<test-fixture id="move">
+  <template>
+    <gr-autocomplete-dropdown id="dropdown" move-to-root></gr-autocomplete-dropdown>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-autocomplete-dropdown', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      element.open();
+      element.suggestions = [
+        {dataValue: 'test value 1', name: 'test name 1', text: 1},
+        {dataValue: 'test value 2', name: 'test name 2', text: 2}];
+    });
+
+    teardown(() => {
+      sandbox.restore();
+      if (element.isOpen) element.close();
+    });
+
+    test('dropdown has not been moved from text fixture to the body', () => {
+      assert.equal(Polymer.dom(document.root)
+          .querySelectorAll('gr-autocomplete-dropdown').length, 1);
+      const dropdown = Polymer.dom(document.root)
+          .querySelector('gr-autocomplete-dropdown');
+      assert.isOk(dropdown);
+      assert.notDeepEqual(dropdown.parentElement,
+          Polymer.dom(document.root).querySelector('body'));
+    });
+
+    test('escape key', () => {
+      const listener = sandbox.spy();
+      element.hidden = false;
+      element.addEventListener('dropdown-closed', listener);
+      const closeSpy = sandbox.spy(element, 'close');
+      MockInteractions.pressAndReleaseKeyOn(element, 27);
+      flushAsynchronousOperations();
+      assert.isTrue(listener.called);
+      assert.isTrue(closeSpy.called);
+      assert.isTrue(element.hidden);
+    });
+
+    test('tab key', () => {
+      const handleTabSpy = sandbox.spy(element, '_handleTab');
+      const itemSelectedStub = sandbox.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+      MockInteractions.pressAndReleaseKeyOn(element, 9);
+      assert.isTrue(handleTabSpy.called);
+      assert.equal(element.$.cursor.index, 0);
+      assert.isTrue(itemSelectedStub.called);
+      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+        trigger: 'tab',
+        selected: element.getCursorTarget(),
+      });
+    });
+
+    test('enter key', () => {
+      const handleEnterSpy = sandbox.spy(element, '_handleEnter');
+      const itemSelectedStub = sandbox.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+      MockInteractions.pressAndReleaseKeyOn(element, 13);
+      assert.isTrue(handleEnterSpy.called);
+      assert.equal(element.$.cursor.index, 0);
+      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+        trigger: 'enter',
+        selected: element.getCursorTarget(),
+      });
+    });
+
+    test('down key', () => {
+      element.hidden = true;
+      const nextSpy = sandbox.spy(element.$.cursor, 'next');
+      MockInteractions.pressAndReleaseKeyOn(element, 40);
+      assert.isFalse(nextSpy.called);
+      assert.equal(element.$.cursor.index, 0);
+      element.hidden = false;
+      MockInteractions.pressAndReleaseKeyOn(element, 40);
+      assert.isTrue(nextSpy.called);
+      assert.equal(element.$.cursor.index, 1);
+    });
+
+    test('up key', () => {
+      element.hidden = true;
+      const prevSpy = sandbox.spy(element.$.cursor, 'previous');
+      MockInteractions.pressAndReleaseKeyOn(element, 38);
+      assert.isFalse(prevSpy.called);
+      assert.equal(element.$.cursor.index, 0);
+      element.hidden = false;
+      element.$.cursor.setCursorAtIndex(1);
+      assert.equal(element.$.cursor.index, 1);
+      MockInteractions.pressAndReleaseKeyOn(element, 38);
+      assert.isTrue(prevSpy.called);
+      assert.equal(element.$.cursor.index, 0);
+    });
+
+    test('tapping selects item', () => {
+      const itemSelectedStub = sandbox.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+
+      MockInteractions.tap(element.$.suggestions.querySelectorAll('li')[1]);
+      flushAsynchronousOperations();
+      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+        trigger: 'tap',
+        selected: element.$.suggestions.querySelectorAll('li')[1],
+      });
+    });
+
+    test('updated suggestions resets cursor stops', () => {
+      resetStopsSpy = sandbox.spy(element, '_resetCursorStops');
+      element.suggestions = [];
+      assert.isTrue(resetStopsSpy.called);
+    });
+  });
+
+  suite('gr-autocomplete-dropdown to root', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      fixture('move').open();
+      // The element was moved to the body, so look for it there.
+      element = Polymer.dom(document.root)
+        .querySelector('gr-autocomplete-dropdown');
+      element.suggestions = [
+        {dataValue: 'test value 1', name: 'test name 1', text: 1},
+        {dataValue: 'test value 2', name: 'test name 2', text: 2}];
+    });
+
+    teardown(() => {
+      sandbox.restore();
+      if (!element.hidden) element.close();
+    });
+
+    test('dropdown has been moved from the text fixture to the body', () => {
+      assert.equal(Polymer.dom(document.root)
+          .querySelectorAll('gr-autocomplete-dropdown').length, 1);
+      const dropdown = Polymer.dom(document.root)
+        .querySelector('gr-autocomplete-dropdown');
+      assert.isOk(dropdown);
+      assert.deepEqual(dropdown.parentElement, Polymer.dom(document.root)
+          .querySelector('body'));
+    });
+
+    test('closing removes from body and adding adds to body', () => {
+      element.close();
+      assert.equal(Polymer.dom(document.root)
+          .querySelectorAll('gr-autocomplete-dropdown').length, 0);
+      element.open();
+      assert.equal(Polymer.dom(document.root)
+          .querySelectorAll('gr-autocomplete-dropdown').length, 1);
+    });
+
+    test('setPosition', () => {
+      const top = '10px';
+      const left = '20px';
+      element.setPosition(top, left);
+      assert.equal(getComputedStyle(element).top, top);
+      assert.equal(getComputedStyle(element).left, left);
+    });
+
+    test('getCurrentText', () => {
+      assert.equal(element.getCurrentText(), 'test value 1');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
index aeb7e5f..df4df05 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
@@ -16,6 +16,7 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">
 <link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html">
 <link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
 
 <dom-module id="gr-autocomplete">
@@ -31,25 +32,6 @@
         border: none;
         outline: none;
       }
-      #suggestions {
-        background-color: #fff;
-        box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
-        position: absolute;
-        z-index: 10;
-      }
-      ul {
-        list-style: none;
-      }
-      li {
-        cursor: pointer;
-        padding: .5em .75em;
-      }
-      li:focus {
-        outline: none;
-      }
-      li.selected {
-        background-color: #eee;
-      }
     </style>
     <input
         id="input"
@@ -61,29 +43,16 @@
         on-keydown="_handleKeydown"
         on-focus="_onInputFocus"
         autocomplete="off" />
-    <div
-        id="suggestions"
-        role="listbox"
-        hidden$="[[_computeSuggestionsHidden(_suggestions, _focused)]]">
-      <ul>
-        <template is="dom-repeat" items="[[_suggestions]]">
-          <li
-              data-index$="[[index]]"
-              tabindex="-1"
-              aria-label$="[[item.name]]"
-              on-keydown="_handleKeydown"
-              role="option"
-              on-tap="_handleSuggestionTap">[[item.name]]</li>
-        </template>
-      </ul>
+    <!-- This container is needed for Safari and Firefox -->
+    <div id="suggestionContainer">
+      <gr-autocomplete-dropdown id="suggestions"
+          on-item-selected="_handleItemSelect"
+          suggestions="[[_suggestions]]"
+          role="listbox"
+          index="[[_index]]"
+          hidden$="[[_computeSuggestionsHidden(_suggestions, _focused)]]">
+      </gr-autocomplete-dropdown>
     </div>
-    <gr-cursor-manager
-        id="cursor"
-        index="{{_index}}"
-        cursor-target-class="selected"
-        scroll-behavior="keep-visible"
-        focus-on-move
-        stops="[[_getSuggestionElems(_suggestions)]]"></gr-cursor-manager>
   </template>
   <script src="gr-autocomplete.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
index 9ebb794..292c25d 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
@@ -14,7 +14,7 @@
 (function() {
   'use strict';
 
-  var TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
+  const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
 
   Polymer({
     is: 'gr-autocomplete',
@@ -52,7 +52,7 @@
        */
       query: {
         type: Function,
-        value: function() {
+        value() {
           return function() {
             return Promise.resolve([]);
           };
@@ -107,28 +107,31 @@
 
       _suggestions: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
+      },
+
+      _suggestionEls: {
+        type: Array,
+        value() { return []; },
       },
 
       _index: Number,
-
       _disableSuggestions: {
         type: Boolean,
         value: false,
       },
-
       _focused: {
         type: Boolean,
         value: false,
       },
-
+      _selected: Object,
     },
 
-    attached: function() {
+    attached() {
       this.listen(document.body, 'tap', '_handleBodyTap');
     },
 
-    detached: function() {
+    detached() {
       this.unlisten(document.body, 'tap', '_handleBodyTap');
     },
 
@@ -136,82 +139,90 @@
       return this.$.input;
     },
 
-    focus: function() {
+    focus() {
       this.$.input.focus();
     },
 
-    selectAll: function() {
+    selectAll() {
       this.$.input.setSelectionRange(0, this.$.input.value.length);
     },
 
-    clear: function() {
+    clear() {
       this.text = '';
     },
 
+    _handleItemSelect(e) {
+      let silent = false;
+      if (e.detail.trigger === 'tab' && this.tabCompleteWithoutCommit) {
+        silent = true;
+      }
+      this._selected = e.detail.selected;
+      this._commit(silent);
+      this.focus();
+    },
+
     /**
      * Set the text of the input without triggering the suggestion dropdown.
      * @param {String} text The new text for the input.
      */
-    setText: function(text) {
+    setText(text) {
       this._disableSuggestions = true;
       this.text = text;
       this._disableSuggestions = false;
     },
 
-    _onInputFocus: function() {
+    _onInputFocus() {
       this._focused = true;
       this._updateSuggestions();
     },
 
-    _updateSuggestions: function() {
+    _updateSuggestions() {
       if (!this.text || this._disableSuggestions) { return; }
       if (this.text.length < this.threshold) {
         this._suggestions = [];
         this.value = null;
         return;
       }
-      var text = this.text;
+      const text = this.text;
 
-      this.query(text).then(function(suggestions) {
+      this.query(text).then(suggestions => {
         if (text !== this.text) {
           // Late response.
           return;
         }
+        for (const suggestion of suggestions) {
+          suggestion.text = suggestion.name;
+        }
         this._suggestions = suggestions;
-        this.$.cursor.moveToStart();
+        Polymer.dom.flush();
         if (this._index === -1) {
           this.value = null;
         }
-      }.bind(this));
+      });
     },
 
-    _computeSuggestionsHidden: function(suggestions, focused) {
+    _computeSuggestionsHidden(suggestions, focused) {
       return !(suggestions.length && focused);
     },
 
-    _computeClass: function(borderless) {
+    _computeClass(borderless) {
       return borderless ? 'borderless' : '';
     },
 
-    _getSuggestionElems: function() {
-      Polymer.dom.flush();
-      return this.$.suggestions.querySelectorAll('li');
-    },
-
     /**
      * _handleKeydown used for key handling in the this.$.input AND all child
      * autocomplete options.
      */
-    _handleKeydown: function(e) {
+    _handleKeydown(e) {
       this._focused = true;
       switch (e.keyCode) {
         case 38: // Up
           e.preventDefault();
-          this.$.cursor.previous();
+          this.$.suggestions.cursorUp();
           break;
         case 40: // Down
           e.preventDefault();
-          this.$.cursor.next();
+          this.$.suggestions.cursorDown();
           break;
         case 27: // Escape
           e.preventDefault();
@@ -220,12 +231,12 @@
         case 9: // Tab
           if (this._suggestions.length > 0) {
             e.preventDefault();
-            this._commit(this.tabCompleteWithoutCommit);
+            this._handleEnter(this.tabCompleteWithoutCommit);
           }
           break;
         case 13: // Enter
           e.preventDefault();
-          this._commit();
+          this._handleEnter();
           break;
         default:
           // For any normal keypress, return focus to the input to allow for
@@ -235,18 +246,27 @@
       this.fire('input-keydown', {keyCode: e.keyCode, input: this.$.input});
     },
 
-    _cancel: function() {
-      this._suggestions = [];
-      this.fire('cancel');
+    _cancel() {
+      if (this._suggestions.length) {
+        this._suggestions = [];
+      } else {
+        this.fire('cancel');
+      }
     },
 
-    _updateValue: function(suggestions, index) {
-      if (!suggestions.length || index === -1) { return; }
-      var completed = suggestions[index].value;
+    _handleEnter(opt_tabCompleteWithoutCommit) {
+      this._selected = this.$.suggestions.getCursorTarget();
+      this._commit(opt_tabCompleteWithoutCommit);
+      this.focus();
+    },
+
+    _updateValue(suggestion, suggestions) {
+      if (!suggestion) { return; }
+      const completed = suggestions[suggestion.dataset.index].value;
       if (this.multi) {
         // Append the completed text to the end of the string.
         // Allow spaces within quoted terms.
-        var tokens = this.text.match(TOKENIZE_REGEX);
+        const tokens = this.text.match(TOKENIZE_REGEX);
         tokens[tokens.length - 1] = completed;
         this.value = tokens.join(' ');
       } else {
@@ -254,9 +274,9 @@
       }
     },
 
-    _handleBodyTap: function(e) {
-      var eventPath = Polymer.dom(e).path;
-      for (var i = 0; i < eventPath.length; i++) {
+    _handleBodyTap(e) {
+      const eventPath = Polymer.dom(e).path;
+      for (let i = 0; i < eventPath.length; i++) {
         if (eventPath[i] === this) {
           return;
         }
@@ -264,7 +284,7 @@
       this._focused = false;
     },
 
-    _handleSuggestionTap: function(e) {
+    _handleSuggestionTap(e) {
       e.stopPropagation();
       this.$.cursor.setCursor(e.target);
       this._commit();
@@ -278,22 +298,22 @@
      *     suggestion in order to handle cases like tab-to-complete without
      *     firing the commit event.
      */
-    _commit: function(silent) {
+    _commit(silent) {
       // Allow values that are not in suggestion list iff suggestions are empty.
       if (this._suggestions.length > 0) {
-        this._updateValue(this._suggestions, this._index);
+        this._updateValue(this._selected, this._suggestions);
       } else {
         this.value = this.text || '';
       }
 
-      var value = this.value;
+      const value = this.value;
 
       // Value and text are mirrors of each other in multi mode.
       if (this.multi) {
         this.setText(this.value);
       } else {
-        if (!this.clearOnCommit && this._suggestions[this._index]) {
-          this.setText(this._suggestions[this._index].name);
+        if (!this.clearOnCommit && this._selected) {
+          this.setText(this._suggestions[this._selected.dataset.index].name);
         } else {
           this.clear();
         }
@@ -301,7 +321,7 @@
 
       this._suggestions = [];
       if (!silent) {
-        this.fire('commit', {value: value});
+        this.fire('commit', {value});
       }
     },
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
index 4234388..137e5f1 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
@@ -33,16 +33,22 @@
 </test-fixture>
 
 <script>
-  suite('gr-autocomplete tests', function() {
-    var element;
+  suite('gr-autocomplete tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
+      sandbox = sinon.sandbox.create();
     });
 
-    test('renders', function(done) {
-      var promise;
-      var queryStub = sinon.spy(function(input) {
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('renders', done => {
+      let promise;
+      const queryStub = sandbox.spy(input => {
         return promise = Promise.resolve([
           {name: input + ' 0', value: 0},
           {name: input + ' 1', value: 1},
@@ -52,61 +58,63 @@
         ]);
       });
       element.query = queryStub;
-
-      assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
-      assert.equal(element.$.cursor.index, -1);
+      assert.isTrue(element.$.suggestions.hidden);
+      assert.equal(element.$.suggestions.$.cursor.index, -1);
 
       element.text = 'blah';
 
       assert.isTrue(queryStub.called);
       element._focused = true;
 
-      promise.then(function() {
+      promise.then(() => {
         assert.isFalse(element.$.suggestions.hasAttribute('hidden'));
-
-        var suggestions = element.$.suggestions.querySelectorAll('li');
+        const suggestions =
+            Polymer.dom(element.$.suggestions.root).querySelectorAll('li');
         assert.equal(suggestions.length, 5);
 
-        for (var i = 0; i < 5; i++) {
+        for (let i = 0; i < 5; i++) {
           assert.equal(suggestions[i].textContent, 'blah ' + i);
         }
 
-        assert.notEqual(element.$.cursor.index, -1);
+        assert.notEqual(element.$.suggestions.$.cursor.index, -1);
         done();
       });
     });
 
-    test('emits cancel', function(done) {
-      var promise;
-      var queryStub = sinon.spy(function() {
+    test('esc key behavior', done => {
+      let promise;
+      const queryStub = sandbox.spy(() => {
         return promise = Promise.resolve([
           {name: 'blah', value: 123},
         ]);
       });
       element.query = queryStub;
 
-      assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
+      assert.isTrue(element.$.suggestions.hidden);
 
       element._focused = true;
       element.text = 'blah';
 
-      promise.then(function() {
-        assert.isFalse(element.$.suggestions.hasAttribute('hidden'));
+      promise.then(() => {
+        assert.isFalse(element.$.suggestions.hidden);
 
-        var cancelHandler = sinon.spy();
+        const cancelHandler = sandbox.spy();
         element.addEventListener('cancel', cancelHandler);
 
         MockInteractions.pressAndReleaseKeyOn(element.$.input, 27, null, 'esc');
-        assert.isTrue(cancelHandler.called);
-        assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
+        assert.isFalse(cancelHandler.called);
+        assert.isTrue(element.$.suggestions.hidden);
+        assert.equal(element._suggestions.length, 0);
 
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 27, null, 'esc');
+        assert.isTrue(cancelHandler.called);
         done();
       });
     });
 
-    test('emits commit and handles cursor movement', function(done) {
-      var promise;
-      var queryStub = sinon.spy(function(input) {
+    test('emits commit and handles cursor movement', done => {
+      let promise;
+      const queryStub = sandbox.spy(input => {
         return promise = Promise.resolve([
           {name: input + ' 0', value: 0},
           {name: input + ' 1', value: 1},
@@ -117,32 +125,32 @@
       });
       element.query = queryStub;
 
-      assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
-      assert.equal(element.$.cursor.index, -1);
+      assert.isTrue(element.$.suggestions.hidden);
+      assert.equal(element.$.suggestions.$.cursor.index, -1);
       element._focused = true;
       element.text = 'blah';
 
-      promise.then(function() {
-        assert.isFalse(element.$.suggestions.hasAttribute('hidden'));
+      promise.then(() => {
+        assert.isFalse(element.$.suggestions.hidden);
 
-        var commitHandler = sinon.spy();
+        const commitHandler = sandbox.spy();
         element.addEventListener('commit', commitHandler);
 
-        assert.equal(element.$.cursor.index, 0);
+        assert.equal(element.$.suggestions.$.cursor.index, 0);
 
         MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
             'down');
 
-        assert.equal(element.$.cursor.index, 1);
+        assert.equal(element.$.suggestions.$.cursor.index, 1);
 
         MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
             'down');
 
-        assert.equal(element.$.cursor.index, 2);
+        assert.equal(element.$.suggestions.$.cursor.index, 2);
 
         MockInteractions.pressAndReleaseKeyOn(element.$.input, 38, null, 'up');
 
-        assert.equal(element.$.cursor.index, 1);
+        assert.equal(element.$.suggestions.$.cursor.index, 1);
 
         MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
             'enter');
@@ -150,22 +158,22 @@
         assert.equal(element.value, 1);
         assert.isTrue(commitHandler.called);
         assert.equal(commitHandler.getCall(0).args[0].detail.value, 1);
-        assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
-
+        assert.isTrue(element.$.suggestions.hidden);
+        assert.isTrue(element._focused);
         done();
       });
     });
 
-    test('clear-on-commit behavior (off)', function(done) {
-      var promise;
-      var queryStub = sinon.spy(function() {
+    test('clear-on-commit behavior (off)', done => {
+      let promise;
+      const queryStub = sandbox.spy(() => {
         return promise = Promise.resolve([{name: 'suggestion', value: 0}]);
       });
       element.query = queryStub;
       element.text = 'blah';
 
-      promise.then(function() {
-        var commitHandler = sinon.spy();
+      promise.then(() => {
+        const commitHandler = sandbox.spy();
         element.addEventListener('commit', commitHandler);
 
         MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
@@ -177,17 +185,17 @@
       });
     });
 
-    test('clear-on-commit behavior (on)', function(done) {
-      var promise;
-      var queryStub = sinon.spy(function() {
+    test('clear-on-commit behavior (on)', done => {
+      let promise;
+      const queryStub = sandbox.spy(() => {
         return promise = Promise.resolve([{name: 'suggestion', value: 0}]);
       });
       element.query = queryStub;
       element.text = 'blah';
       element.clearOnCommit = true;
 
-      promise.then(function() {
-        var commitHandler = sinon.spy();
+      promise.then(() => {
+        const commitHandler = sandbox.spy();
         element.addEventListener('commit', commitHandler);
 
         MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
@@ -199,8 +207,8 @@
       });
     });
 
-    test('threshold guards the query', function() {
-      var queryStub = sinon.spy(function() {
+    test('threshold guards the query', () => {
+      const queryStub = sandbox.spy(() => {
         return Promise.resolve([]);
       });
       element.query = queryStub;
@@ -216,29 +224,29 @@
       assert.isTrue(queryStub.called);
     });
 
-    test('_computeClass respects border property', function() {
+    test('_computeClass respects border property', () => {
       assert.equal(element._computeClass(), '');
       assert.equal(element._computeClass(false), '');
       assert.equal(element._computeClass(true), 'borderless');
     });
 
-    test('undefined or empty text results in no suggestions', function() {
-      sinon.spy(element, '_updateSuggestions');
+    test('undefined or empty text results in no suggestions', () => {
+      sandbox.spy(element, '_updateSuggestions');
       element.text = undefined;
       assert(element._updateSuggestions.calledOnce);
       assert.equal(element._suggestions.length, 0);
     });
 
-    test('multi completes only the last part of the query', function(done) {
-      var promise;
-      var queryStub = sinon.stub()
+    test('multi completes only the last part of the query', done => {
+      let promise;
+      const queryStub = sandbox.stub()
           .returns(promise = Promise.resolve([{name: 'suggestion', value: 0}]));
       element.query = queryStub;
       element.text = 'blah blah';
       element.multi = true;
 
-      promise.then(function() {
-        var commitHandler = sinon.spy();
+      promise.then(() => {
+        const commitHandler = sandbox.spy();
         element.addEventListener('commit', commitHandler);
 
         MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
@@ -250,19 +258,19 @@
       });
     });
 
-    test('tab key completes only when suggestions exist', function() {
-      var commitStub = sinon.stub(element, '_commit');
+    test('tab key completes only when suggestions exist', () => {
+      const commitStub = sandbox.stub(element, '_commit');
       element._suggestions = [];
       MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
       assert.isFalse(commitStub.called);
       element._suggestions = ['tunnel snakes rule!'];
       MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
       assert.isTrue(commitStub.called);
-      commitStub.restore();
+      assert.isTrue(element._focused);
     });
 
-    test('tabCompleteWithoutCommit flag functions', function() {
-      var commitHandler = sinon.spy();
+    test('tabCompleteWithoutCommit flag functions', () => {
+      const commitHandler = sandbox.spy();
       element.addEventListener('commit', commitHandler);
       element._suggestions = ['tunnel snakes rule!'];
       element.tabCompleteWithoutCommit = true;
@@ -274,40 +282,39 @@
       assert.isTrue(commitHandler.called);
     });
 
-    test('_focused flag properly triggered', function(done) {
-      flush(function() {
+    test('_focused flag properly triggered', done => {
+      flush(() => {
         assert.isFalse(element._focused);
-        var input = element.$$('input');
+        const input = element.$$('input');
         MockInteractions.focus(input);
         assert.isTrue(element._focused);
         done();
       });
     });
 
-    test('_focused flag shows/hides the suggestions', function() {
-      var suggestions = ['hello', 'its me'];
+    test('_focused flag shows/hides the suggestions', () => {
+      const suggestions = ['hello', 'its me'];
       assert.isTrue(element._computeSuggestionsHidden(suggestions, false));
       assert.isFalse(element._computeSuggestionsHidden(suggestions, true));
     });
 
-    test('tap on suggestion commits and refocuses on input', function() {
-      var focusSpy = sinon.spy(element, 'focus');
-      var commitSpy = sinon.spy(element, '_commit');
+    test('tap on suggestion commits and refocuses on input', () => {
+      const focusSpy = sandbox.spy(element, 'focus');
+      const commitSpy = sandbox.spy(element, '_commit');
       element._focused = true;
       element._suggestions = [{name: 'first suggestion'}];
-      assert.isFalse(element.$.suggestions.hasAttribute('hidden'));
-      MockInteractions.tap(element.$$('#suggestions li:first-child'));
+      Polymer.dom.flush();
+      assert.isFalse(element.$.suggestions.hidden);
+      MockInteractions.tap(element.$.suggestions.$$('li:first-child'));
       flushAsynchronousOperations();
       assert.isTrue(focusSpy.called);
       assert.isTrue(commitSpy.called);
-      assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
+      assert.isTrue(element.$.suggestions.hidden);
       assert.isTrue(element._focused);
-      focusSpy.restore();
-      commitSpy.restore();
     });
 
-    test('input-keydown event fired', function() {
-      var listener = sinon.spy();
+    test('input-keydown event fired', () => {
+      const listener = sandbox.spy();
       element.addEventListener('input-keydown', listener);
       MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
       flushAsynchronousOperations();
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
index 38e9924..166204bf 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
@@ -28,38 +28,38 @@
       },
     },
 
-    created: function() {
+    created() {
       this.hidden = true;
     },
 
-    attached: function() {
-      this.$.restAPI.getConfig().then(function(cfg) {
-        var hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
+    attached() {
+      this.$.restAPI.getConfig().then(cfg => {
+        const hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
         if (hasAvatars) {
           this.hidden = false;
           // src needs to be set if avatar becomes visible
           this._updateAvatarURL(this.account);
         }
-      }.bind(this));
+      });
     },
 
-    _accountChanged: function(account) {
+    _accountChanged(account) {
       this._updateAvatarURL(account);
     },
 
-    _updateAvatarURL: function(account) {
+    _updateAvatarURL(account) {
       if (!this.hidden && account) {
-        var url = this._buildAvatarURL(this.account);
+        const url = this._buildAvatarURL(this.account);
         if (url) {
           this.style.backgroundImage = 'url("' + url + '")';
         }
       }
     },
 
-    _buildAvatarURL: function(account) {
+    _buildAvatarURL(account) {
       if (!account) { return ''; }
-      var avatars = account.avatars || [];
-      for (var i = 0; i < avatars.length; i++) {
+      const avatars = account.avatars || [];
+      for (let i = 0; i < avatars.length; i++) {
         if (avatars[i].height === this.imageSize) {
           return avatars[i].url;
         }
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
index fd05d62..0a1ac67 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
@@ -32,20 +32,20 @@
 </test-fixture>
 
 <script>
-  suite('gr-avatar tests', function() {
-    var element;
+  suite('gr-avatar tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
+        getConfig() { return Promise.resolve({}); },
       });
       element = fixture('basic');
     });
 
-    test('methods', function() {
+    test('methods', () => {
       assert.equal(element._buildAvatarURL(
           {
-            _account_id: 123
+            _account_id: 123,
           }),
           '/accounts/123/avatar?s=16');
       assert.equal(element._buildAvatarURL(
@@ -54,15 +54,15 @@
             avatars: [
               {
                 url: 'https://cdn.example.com/s12-p/photo.jpg',
-                height: 12
+                height: 12,
               },
               {
                 url: 'https://cdn.example.com/s16-p/photo.jpg',
-                height: 16
+                height: 16,
               },
               {
                 url: 'https://cdn.example.com/s100-p/photo.jpg',
-                height: 100
+                height: 100,
               },
             ],
           }),
@@ -73,32 +73,31 @@
             avatars: [
               {
                 url: 'https://cdn.example.com/s95-p/photo.jpg',
-                height: 95
+                height: 95,
               },
             ],
           }),
           '/accounts/123/avatar?s=16');
     });
 
-    test('dom for existing account', function() {
+    test('dom for existing account', () => {
       assert.isTrue(element.hasAttribute('hidden'),
           'element not hidden initially');
       element.hidden = false;
       element.imageSize = 64;
       element.account = {
-        _account_id: 123
+        _account_id: 123,
       };
       assert.isFalse(element.hasAttribute('hidden'), 'element hidden');
-      assert.isTrue(element.style.backgroundImage.indexOf(
-          '/accounts/123/avatar?s=64') > -1);
+      assert.isTrue(
+          element.style.backgroundImage.includes('/accounts/123/avatar?s=64'));
     });
 
-    test('dom for non available account', function() {
+    test('dom for non available account', () => {
       assert.isTrue(element.hasAttribute('hidden'),
           'element not hidden initially');
       element.account = undefined;
       assert.isTrue(element.hasAttribute('hidden'), 'element not hidden');
     });
-
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
index 800b1df..ddb2bc3 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
@@ -34,8 +34,8 @@
     },
 
     listeners: {
-      'tap': '_handleAction',
-      'click': '_handleAction',
+      tap: '_handleAction',
+      click: '_handleAction',
     },
 
     behaviors: [
@@ -52,21 +52,21 @@
       'space enter': '_handleCommitKey',
     },
 
-    _handleAction: function(e) {
+    _handleAction(e) {
       if (this.disabled) {
         e.preventDefault();
         e.stopImmediatePropagation();
       }
     },
 
-    _disabledChanged: function(disabled) {
+    _disabledChanged(disabled) {
       if (disabled) {
         this._enabledTabindex = this.getAttribute('tabindex');
       }
       this.setAttribute('tabindex', disabled ? '-1' : this._enabledTabindex);
     },
 
-    _handleCommitKey: function(e) {
+    _handleCommitKey(e) {
       e.preventDefault();
       this.click();
     },
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
index c269cb5..536b777 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
@@ -33,63 +33,65 @@
 </test-fixture>
 
 <script>
-  suite('gr-select tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-select tests', () => {
+    let element;
+    let sandbox;
 
-    var addSpyOn = function(eventName) {
-      var spy = sandbox.spy();
+    const addSpyOn = function(eventName) {
+      const spy = sandbox.spy();
       element.addEventListener(eventName, spy);
       return spy;
     };
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    ['tap', 'click'].forEach(function(eventName) {
-      test('dispatches ' + eventName + ' event', function() {
-        var spy = addSpyOn(eventName);
+    for (const eventName of ['tap', 'click']) {
+      test('dispatches ' + eventName + ' event', () => {
+        const spy = addSpyOn(eventName);
         MockInteractions.tap(element);
         assert.isTrue(spy.calledOnce);
       });
-    });
+    }
 
     // Keycodes: 32 for Space, 13 for Enter.
-    [32, 13].forEach(function(key) {
-      test('dispatches tap event on keycode ' + key, function() {
-        var tapSpy = sandbox.spy();
+    for (const key of [32, 13]) {
+      test('dispatches tap event on keycode ' + key, () => {
+        const tapSpy = sandbox.spy();
         element.addEventListener('tap', tapSpy);
         MockInteractions.pressAndReleaseKeyOn(element, key);
         assert.isTrue(tapSpy.calledOnce);
-      })});
+      });
+    }
 
-    suite('disabled', function() {
-      setup(function() {
+    suite('disabled', () => {
+      setup(() => {
         element.disabled = true;
       });
 
-      ['tap', 'click'].forEach(function(eventName) {
-        test('stops ' + eventName + ' event', function() {
-          var spy = addSpyOn(eventName);
+      for (const eventName of ['tap', 'click']) {
+        test('stops ' + eventName + ' event', () => {
+          const spy = addSpyOn(eventName);
           MockInteractions.tap(element);
           assert.isFalse(spy.called);
         });
-      });
+      }
 
       // Keycodes: 32 for Space, 13 for Enter.
-      [32, 13].forEach(function(key) {
-        test('stops tap event on keycode ' + key, function() {
-          var tapSpy = sandbox.spy();
+      for (const key of [32, 13]) {
+        test('stops tap event on keycode ' + key, () => {
+          const tapSpy = sandbox.spy();
           element.addEventListener('tap', tapSpy);
           MockInteractions.pressAndReleaseKeyOn(element, key);
           assert.isFalse(tapSpy.called);
-        })});
+        });
+      }
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
index beb0ff1..0e5ff04 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
@@ -26,16 +26,16 @@
       _xhrPromise: Object,  // Used for testing.
     },
 
-    _computeStarClass: function(starred) {
-      var classes = ['starButton'];
+    _computeStarClass(starred) {
+      const classes = ['starButton'];
       if (starred) {
         classes.push('starButton-active');
       }
       return classes.join(' ');
     },
 
-    toggleStar: function() {
-      var newVal = !this.change.starred;
+    toggleStar() {
+      const newVal = !this.change.starred;
       this.set('change.starred', newVal);
       this._xhrPromise = this.$.restAPI.saveChangeStarred(this.change._number,
           newVal);
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
index 460d860..6286efe 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
@@ -33,12 +33,12 @@
 </test-fixture>
 
 <script>
-  suite('gr-change-star tests', function() {
-    var element;
+  suite('gr-change-star tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        saveChangeStarred: function() { return Promise.resolve({ok: true}); },
+        saveChangeStarred() { return Promise.resolve({ok: true}); },
       });
       element = fixture('basic');
       element.change = {
@@ -47,7 +47,7 @@
       };
     });
 
-    test('star visibility states', function() {
+    test('star visibility states', () => {
       element.set('change.starred', true);
       assert.isTrue(element.$$('button').classList.contains('starButton'));
       assert.isTrue(
@@ -59,21 +59,21 @@
           element.$$('button').classList.contains('starButton-active'));
     });
 
-    test('starring', function(done) {
+    test('starring', done => {
       element.set('change.starred', false);
       MockInteractions.tap(element.$$('button'));
 
-      element._xhrPromise.then(function(req) {
+      element._xhrPromise.then(req => {
         assert.equal(element.change.starred, true);
         done();
       });
     });
 
-    test('unstarring', function(done) {
+    test('unstarring', done => {
       element.set('change.starred', true);
       MockInteractions.tap(element.$$('button'));
 
-      element._xhrPromise.then(function(req) {
+      element._xhrPromise.then(req => {
         assert.equal(element.change.starred, false);
         done();
       });
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js
index dbddb04..3d5e781 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js
@@ -44,12 +44,12 @@
       role: 'dialog',
     },
 
-    _handleConfirmTap: function(e) {
+    _handleConfirmTap(e) {
       e.preventDefault();
       this.fire('confirm', null, {bubbles: false});
     },
 
-    _handleCancelTap: function(e) {
+    _handleCancelTap(e) {
       e.preventDefault();
       this.fire('cancel', null, {bubbles: false});
     },
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog_test.html b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog_test.html
index 3eec979..cb5d688 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog_test.html
@@ -33,15 +33,15 @@
 </test-fixture>
 
 <script>
-  suite('gr-confirm-dialog tests', function() {
-    var element;
+  suite('gr-confirm-dialog tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
     });
 
-    test('events', function(done) {
-      var numEvents = 0;
+    test('events', done => {
+      let numEvents = 0;
       function handler() { if (++numEvents == 2) { done(); } }
 
       element.addEventListener('confirm', handler);
@@ -50,6 +50,5 @@
       MockInteractions.tap(element.$$('gr-button[primary]'));
       MockInteractions.tap(element.$$('gr-button:not([primary])'));
     });
-
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
index 9bfdcfb..6f03a3e 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
@@ -14,7 +14,7 @@
 (function() {
   'use strict';
 
-  var ScrollBehavior = {
+  const ScrollBehavior = {
     NEVER: 'never',
     KEEP_VISIBLE: 'keep-visible',
   };
@@ -25,7 +25,7 @@
     properties: {
       stops: {
         type: Array,
-        value: function() {
+        value() {
           return [];
         },
         observer: '_updateIndex',
@@ -76,15 +76,15 @@
       },
     },
 
-    detached: function() {
+    detached() {
       this.unsetCursor();
     },
 
-    next: function(opt_condition, opt_getTargetHeight) {
+    next(opt_condition, opt_getTargetHeight) {
       this._moveCursor(1, opt_condition, opt_getTargetHeight);
     },
 
-    previous: function(opt_condition) {
+    previous(opt_condition) {
       this._moveCursor(-1, opt_condition);
     },
 
@@ -94,8 +94,8 @@
      * @param {boolean} opt_noScroll prevent any potential scrolling in response
      *   setting the cursor.
      */
-    setCursor: function(element, opt_noScroll) {
-      var behavior;
+    setCursor(element, opt_noScroll) {
+      let behavior;
       if (opt_noScroll) {
         behavior = this.scrollBehavior;
         this.scrollBehavior = ScrollBehavior.NEVER;
@@ -109,28 +109,28 @@
       if (opt_noScroll) { this.scrollBehavior = behavior; }
     },
 
-    unsetCursor: function() {
+    unsetCursor() {
       this._unDecorateTarget();
       this.index = -1;
       this.target = null;
       this._targetHeight = null;
     },
 
-    isAtStart: function() {
+    isAtStart() {
       return this.index === 0;
     },
 
-    isAtEnd: function() {
+    isAtEnd() {
       return this.index === this.stops.length - 1;
     },
 
-    moveToStart: function() {
+    moveToStart() {
       if (this.stops.length) {
         this.setCursor(this.stops[0]);
       }
     },
 
-    setCursorAtIndex: function(index, opt_noScroll) {
+    setCursorAtIndex(index, opt_noScroll) {
       this.setCursor(this.stops[index], opt_noScroll);
     },
 
@@ -146,7 +146,7 @@
      *    sometimes different, used by the diff cursor.
      * @private
      */
-    _moveCursor: function(delta, opt_condition, opt_getTargetHeight) {
+    _moveCursor(delta, opt_condition, opt_getTargetHeight) {
       if (!this.stops.length) {
         this.unsetCursor();
         return;
@@ -154,9 +154,9 @@
 
       this._unDecorateTarget();
 
-      var newIndex = this._getNextindex(delta, opt_condition);
+      const newIndex = this._getNextindex(delta, opt_condition);
 
-      var newTarget = null;
+      let newTarget = null;
       if (newIndex != -1) {
         newTarget = this.stops[newIndex];
       }
@@ -175,13 +175,13 @@
       this._decorateTarget();
     },
 
-    _decorateTarget: function() {
+    _decorateTarget() {
       if (this.target && this.cursorTargetClass) {
         this.target.classList.add(this.cursorTargetClass);
       }
     },
 
-    _unDecorateTarget: function() {
+    _unDecorateTarget() {
       if (this.target && this.cursorTargetClass) {
         this.target.classList.remove(this.cursorTargetClass);
       }
@@ -194,12 +194,12 @@
      * @return {Number} the new index.
      * @private
      */
-    _getNextindex: function(delta, opt_condition) {
+    _getNextindex(delta, opt_condition) {
       if (!this.stops.length || this.index === -1) {
         return -1;
       }
 
-      var newIndex = this.index;
+      let newIndex = this.index;
       do {
         newIndex = newIndex + delta;
       } while (newIndex > 0 &&
@@ -221,13 +221,13 @@
       return newIndex;
     },
 
-    _updateIndex: function() {
+    _updateIndex() {
       if (!this.target) {
         this.index = -1;
         return;
       }
 
-      var newIndex = Array.prototype.indexOf.call(this.stops, this.target);
+      const newIndex = Array.prototype.indexOf.call(this.stops, this.target);
       if (newIndex === -1) {
         this.unsetCursor();
       } else {
@@ -240,9 +240,9 @@
      * @param {object} target Target to scroll to.
      * @return {number} Distance to top of the target.
      */
-    _getTop: function(target) {
-      var top = target.offsetTop;
-      for (var offsetParent = target.offsetParent;
+    _getTop(target) {
+      let top = target.offsetTop;
+      for (let offsetParent = target.offsetParent;
            offsetParent;
            offsetParent = offsetParent.offsetParent) {
         top += offsetParent.offsetTop;
@@ -253,25 +253,25 @@
     /**
      * @return {boolean}
      */
-    _targetIsVisible: function(top) {
+    _targetIsVisible(top) {
       return this.scrollBehavior === ScrollBehavior.KEEP_VISIBLE &&
           top > window.pageYOffset &&
           top < window.pageYOffset + window.innerHeight;
     },
 
-    _calculateScrollToValue: function(top, target) {
+    _calculateScrollToValue(top, target) {
       return top - (window.innerHeight / 3) + (target.offsetHeight / 2);
     },
 
-    _scrollToTarget: function() {
+    _scrollToTarget() {
       if (!this.target || this.scrollBehavior === ScrollBehavior.NEVER) {
         return;
       }
 
-      var top = this._getTop(this.target);
-      var bottomIsVisible = this._targetHeight ?
+      const top = this._getTop(this.target);
+      const bottomIsVisible = this._targetHeight ?
           this._targetIsVisible(top + this._targetHeight) : true;
-      var scrollToValue = this._calculateScrollToValue(top, this.target);
+      const scrollToValue = this._calculateScrollToValue(top, this.target);
 
       if (this._targetIsVisible(top)) {
         // Don't scroll if either the bottom is visible or if the position that
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
index 5d9af80..7ab0088 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
@@ -39,23 +39,23 @@
 </test-fixture>
 
 <script>
-  suite('gr-cursor-manager tests', function() {
-    var sandbox;
-    var element;
-    var list;
+  suite('gr-cursor-manager tests', () => {
+    let sandbox;
+    let element;
+    let list;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
-      var fixtureElements = fixture('basic');
+      const fixtureElements = fixture('basic');
       element = fixtureElements[0];
       list = fixtureElements[1];
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('core cursor functionality', function() {
+    test('core cursor functionality', () => {
       // The element is initialized into the proper state.
       assert.isArray(element.stops);
       assert.equal(element.stops.length, 0);
@@ -111,7 +111,7 @@
       assert.isTrue(element.isAtStart());
       assert.isTrue(list.children[0].classList.contains('targeted'));
 
-      var newLi = document.createElement('li');
+      const newLi = document.createElement('li');
       newLi.textContent = 'Z';
       list.insertBefore(newLi, list.children[0]);
       element.stops = list.querySelectorAll('li');
@@ -128,12 +128,12 @@
     });
 
 
-    test('_moveCursor', function() {
+    test('_moveCursor', () => {
       // Initialize the cursor with its stops.
       element.stops = list.querySelectorAll('li');
       // Select the first stop.
       element.setCursor(list.children[0]);
-      var getTargetHeight = sinon.stub();
+      const getTargetHeight = sinon.stub();
 
       // Move the cursor without an optional get target height function.
       element._moveCursor(1);
@@ -144,9 +144,9 @@
       assert.isTrue(getTargetHeight.called);
     });
 
-    test('opt_noScroll', function() {
-      sandbox.stub(element, '_targetIsVisible', function() { return false; });
-      var scrollStub = sandbox.stub(window, 'scrollTo');
+    test('opt_noScroll', () => {
+      sandbox.stub(element, '_targetIsVisible', () => false);
+      const scrollStub = sandbox.stub(window, 'scrollTo');
       element.stops = list.querySelectorAll('li');
       element.scrollBehavior = 'keep-visible';
 
@@ -157,8 +157,8 @@
       assert.isTrue(scrollStub.called);
     });
 
-    test('_getNextindex', function() {
-      var isLetterB = function(row) {
+    test('_getNextindex', () => {
+      const isLetterB = function(row) {
         return row.textContent === 'B';
       };
       element.stops = list.querySelectorAll('li');
@@ -185,9 +185,9 @@
       assert.equal(element._getNextindex(-1, isLetterB), 0);
     });
 
-    test('focusOnMove prop', function() {
-      var listEls = list.querySelectorAll('li');
-      for (var i = 0; i < listEls.length; i++) {
+    test('focusOnMove prop', () => {
+      const listEls = list.querySelectorAll('li');
+      for (let i = 0; i < listEls.length; i++) {
         sandbox.spy(listEls[i], 'focus');
       }
       element.stops = listEls;
@@ -202,9 +202,9 @@
       assert.isTrue(element.target.focus.called);
     });
 
-    suite('_scrollToTarget', function() {
-      var scrollStub;
-      setup(function() {
+    suite('_scrollToTarget', () => {
+      let scrollStub;
+      setup(() => {
         element.stops = list.querySelectorAll('li');
         element.scrollBehavior = 'keep-visible';
 
@@ -215,29 +215,27 @@
         window.innerHeight = 60;
       });
 
-      test('Called when top and bottom not visible', function() {
-        sandbox.stub(element, '_targetIsVisible', function() {
+      test('Called when top and bottom not visible', () => {
+        sandbox.stub(element, '_targetIsVisible', () => {
           return false;
         });
         element._scrollToTarget();
         assert.isTrue(scrollStub.called);
       });
 
-      test('Not called when top and bottom visible', function() {
-        sandbox.stub(element, '_targetIsVisible', function() {
+      test('Not called when top and bottom visible', () => {
+        sandbox.stub(element, '_targetIsVisible', () => {
           return true;
         });
         element._scrollToTarget();
         assert.isFalse(scrollStub.called);
       });
 
-      test('Called when top is visible, bottom is not, and scroll is lower',
-          function() {
-        var visibleStub = sandbox.stub(element, '_targetIsVisible', function() {
-          return visibleStub.callCount == 2;
-        });
+      test('Called when top is visible, bottom is not, scroll is lower', () => {
+        const visibleStub = sandbox.stub(element, '_targetIsVisible',
+            () => visibleStub.callCount === 2);
         window.scrollY = 15;
-        sandbox.stub(element, '_calculateScrollToValue', function() {
+        sandbox.stub(element, '_calculateScrollToValue', () => {
           return 20;
         });
         element._scrollToTarget();
@@ -245,13 +243,11 @@
         assert.equal(visibleStub.callCount, 2);
       });
 
-      test('Called when top is visible, bottom is not, and scroll is higher',
-          function() {
-        var visibleStub = sandbox.stub(element, '_targetIsVisible', function() {
-          return visibleStub.callCount == 2;
-        });
+      test('Called when top is visible, bottom not, scroll is higher', () => {
+        const visibleStub = sandbox.stub(element, '_targetIsVisible',
+            () => visibleStub.callCount === 2);
         window.scrollY = 25;
-        sandbox.stub(element, '_calculateScrollToValue', function() {
+        sandbox.stub(element, '_calculateScrollToValue', () => {
           return 20;
         });
         element._scrollToTarget();
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
index 6f4cd3d..65a4c68 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
@@ -14,12 +14,12 @@
 (function() {
   'use strict';
 
-  var Duration = {
+  const Duration = {
     HOUR: 1000 * 60 * 60,
     DAY: 1000 * 60 * 60 * 24,
   };
 
-  var TimeFormats = {
+  const TimeFormats = {
     TIME_12: 'h:mm A', // 2:14 PM
     TIME_12_WITH_SEC: 'h:mm:ss A', // 2:14:00 PM
     TIME_24: 'HH:mm', // 14:14
@@ -61,16 +61,16 @@
       Gerrit.TooltipBehavior,
     ],
 
-    attached: function() {
+    attached() {
       this._loadPreferences();
     },
 
-    _getUtcOffsetString: function() {
+    _getUtcOffsetString() {
       return ' UTC' + moment().format('Z');
     },
 
-    _loadPreferences: function() {
-      return this._getLoggedIn().then(function(loggedIn) {
+    _loadPreferences() {
+      return this._getLoggedIn().then(loggedIn => {
         if (!loggedIn) {
           this._timeFormat = TimeFormats.TIME_24;
           this._relative = false;
@@ -80,12 +80,12 @@
           this._loadTimeFormat(),
           this._loadRelative(),
         ]);
-      }.bind(this));
+      });
     },
 
-    _loadTimeFormat: function() {
-      return this._getPreferences().then(function(preferences) {
-        var timeFormat = preferences && preferences.time_format;
+    _loadTimeFormat() {
+      return this._getPreferences().then(preferences => {
+        const timeFormat = preferences && preferences.time_format;
         switch (timeFormat) {
           case 'HHMM_12':
             this._timeFormat = TimeFormats.TIME_12;
@@ -96,55 +96,55 @@
           default:
             throw Error('Invalid time format: ' + timeFormat);
         }
-      }.bind(this));
+      });
     },
 
-    _loadRelative: function() {
-      return this._getPreferences().then(function(prefs) {
+    _loadRelative() {
+      return this._getPreferences().then(prefs => {
         // prefs.relative_date_in_change_table is not set when false.
         this._relative = !!(prefs && prefs.relative_date_in_change_table);
-      }.bind(this));
+      });
     },
 
-    _getLoggedIn: function() {
+    _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
     },
 
-    _getPreferences: function() {
+    _getPreferences() {
       return this.$.restAPI.getPreferences();
     },
 
     /**
      * Return true if date is within 24 hours and on the same day.
      */
-    _isWithinDay: function(now, date) {
-      var diff = -date.diff(now);
+    _isWithinDay(now, date) {
+      const diff = -date.diff(now);
       return diff < Duration.DAY && date.day() === now.getDay();
     },
 
     /**
      * Returns true if date is from one to six months.
      */
-    _isWithinHalfYear: function(now, date) {
-      var diff = -date.diff(now);
+    _isWithinHalfYear(now, date) {
+      const diff = -date.diff(now);
       return (date.day() !== now.getDay() || diff >= Duration.DAY) &&
           diff < 180 * Duration.DAY;
     },
 
-    _computeDateStr: function(dateStr, timeFormat, relative) {
+    _computeDateStr(dateStr, timeFormat, relative) {
       if (!dateStr) { return ''; }
-      var date = moment(util.parseDate(dateStr));
+      const date = moment(util.parseDate(dateStr));
       if (!date.isValid()) { return ''; }
       if (relative) {
-        var dateFromNow = date.fromNow();
+        const dateFromNow = date.fromNow();
         if (dateFromNow === 'a few seconds ago') {
           return 'just now';
         } else {
           return dateFromNow;
         }
       }
-      var now = new Date();
-      var format = TimeFormats.MONTH_DAY_YEAR;
+      const now = new Date();
+      let format = TimeFormats.MONTH_DAY_YEAR;
       if (this._isWithinDay(now, date)) {
         format = timeFormat;
       } else if (this._isWithinHalfYear(now, date)) {
@@ -153,17 +153,17 @@
       return date.format(format);
     },
 
-    _timeToSecondsFormat: function(timeFormat) {
+    _timeToSecondsFormat(timeFormat) {
       return timeFormat === TimeFormats.TIME_12 ?
           TimeFormats.TIME_12_WITH_SEC :
           TimeFormats.TIME_24_WITH_SEC;
     },
 
-    _computeFullDateStr: function(dateStr, timeFormat) {
+    _computeFullDateStr(dateStr, timeFormat) {
       if (!dateStr) { return ''; }
-      var date = moment(util.parseDate(dateStr));
+      const date = moment(util.parseDate(dateStr));
       if (!date.isValid()) { return ''; }
-      var format = TimeFormats.MONTH_DAY_YEAR + ', ';
+      let format = TimeFormats.MONTH_DAY_YEAR + ', ';
       format += this._timeToSecondsFormat(timeFormat);
       return date.format(format) + this._getUtcOffsetString();
     },
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
index 4c6dfdf..8a3ea3a 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
@@ -33,15 +33,15 @@
 </test-fixture>
 
 <script>
-  suite('gr-date-formatter tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-date-formatter tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
@@ -49,7 +49,7 @@
      * Parse server-formatter date and normalize into current timezone.
      */
     function normalizedDate(dateStr) {
-      var d = util.parseDate(dateStr);
+      const d = util.parseDate(dateStr);
       d.setMinutes(d.getMinutes() + d.getTimezoneOffset());
       return d;
     }
@@ -60,8 +60,8 @@
           .toJSON().replace('T', ' ').slice(0, -1);
       sandbox.useFakeTimers(normalizedDate(nowStr).getTime());
       element.dateStr = dateStr;
-      flush(function() {
-        var span = element.$$('span');
+      flush(() => {
+        const span = element.$$('span');
         assert.equal(span.textContent.trim(), expected);
         assert.equal(element.title, expectedTooltip);
         done();
@@ -69,8 +69,8 @@
     }
 
     function stubRestAPI(preferences) {
-      var loggedInPromise = Promise.resolve(preferences !== null);
-      var preferencesPromise = Promise.resolve(preferences);
+      const loggedInPromise = Promise.resolve(preferences !== null);
+      const preferencesPromise = Promise.resolve(preferences);
       stub('gr-rest-api-interface', {
         getLoggedIn: sinon.stub().returns(loggedInPromise),
         getPreferences: sinon.stub().returns(preferencesPromise),
@@ -78,115 +78,115 @@
       return Promise.all([loggedInPromise, preferencesPromise]);
     }
 
-    suite('24 hours time format preference', function() {
-      setup(function() {
+    suite('24 hours time format preference', () => {
+      setup(() => {
         return stubRestAPI(
           {time_format: 'HHMM_24', relative_date_in_change_table: false}
-        ).then(function() {
+        ).then(() => {
           element = fixture('basic');
           sandbox.stub(element, '_getUtcOffsetString').returns('');
           return element._loadPreferences();
         });
       });
 
-      test('invalid dates are quietly rejected', function() {
+      test('invalid dates are quietly rejected', () => {
         assert.notOk((new Date('foo')).valueOf());
         assert.equal(element._computeDateStr('foo', 'h:mm A'), '');
       });
 
-      test('Within 24 hours on same day', function(done) {
+      test('Within 24 hours on same day', done => {
         testDates('2015-07-29 20:34:14.985000000',
             '2015-07-29 15:34:14.985000000',
             '15:34', 'Jul 29, 2015, 15:34:14', done);
       });
 
-      test('Within 24 hours on different days', function(done) {
+      test('Within 24 hours on different days', done => {
         testDates('2015-07-29 03:34:14.985000000',
             '2015-07-28 20:25:14.985000000',
             'Jul 28', 'Jul 28, 2015, 20:25:14', done);
       });
 
-      test('More than 24 hours but less than six months', function(done) {
+      test('More than 24 hours but less than six months', done => {
         testDates('2015-07-29 20:34:14.985000000',
             '2015-06-15 03:25:14.985000000',
             'Jun 15', 'Jun 15, 2015, 03:25:14', done);
       });
 
-      test('More than six months', function(done) {
+      test('More than six months', done => {
         testDates('2015-09-15 20:34:00.000000000',
             '2015-01-15 03:25:00.000000000',
             'Jan 15, 2015', 'Jan 15, 2015, 03:25:00', done);
       });
     });
 
-    suite('12 hours time format preference', function() {
-      setup(function() {
+    suite('12 hours time format preference', () => {
+      setup(() => {
         // relative_date_in_change_table is not set when false.
         return stubRestAPI(
           {time_format: 'HHMM_12'}
-        ).then(function() {
+        ).then(() => {
           element = fixture('basic');
           sandbox.stub(element, '_getUtcOffsetString').returns('');
           return element._loadPreferences();
         });
       });
 
-      test('Within 24 hours on same day', function(done) {
+      test('Within 24 hours on same day', done => {
         testDates('2015-07-29 20:34:14.985000000',
             '2015-07-29 15:34:14.985000000',
             '3:34 PM', 'Jul 29, 2015, 3:34:14 PM', done);
       });
     });
 
-    suite('relative date preference', function() {
-      setup(function() {
+    suite('relative date preference', () => {
+      setup(() => {
         return stubRestAPI(
           {time_format: 'HHMM_12', relative_date_in_change_table: true}
-        ).then(function() {
+        ).then(() => {
           element = fixture('basic');
           sandbox.stub(element, '_getUtcOffsetString').returns('');
           return element._loadPreferences();
         });
       });
 
-      test('Within 24 hours on same day', function(done) {
+      test('Within 24 hours on same day', done => {
         testDates('2015-07-29 20:34:14.985000000',
             '2015-07-29 15:34:14.985000000',
             '5 hours ago', 'Jul 29, 2015, 3:34:14 PM', done);
       });
 
-      test('More than six months', function(done) {
+      test('More than six months', done => {
         testDates('2015-09-15 20:34:00.000000000',
             '2015-01-15 03:25:00.000000000',
             '8 months ago', 'Jan 15, 2015, 3:25:00 AM', done);
       });
     });
 
-    suite('logged in', function() {
-      setup(function() {
+    suite('logged in', () => {
+      setup(() => {
         return stubRestAPI(
           {time_format: 'HHMM_12', relative_date_in_change_table: true}
-        ).then(function() {
+        ).then(() => {
           element = fixture('basic');
           return element._loadPreferences();
         });
       });
 
-      test('Preferences are respected', function() {
+      test('Preferences are respected', () => {
         assert.equal(element._timeFormat, 'h:mm A');
         assert.isTrue(element._relative);
       });
     });
 
-    suite('logged out', function() {
-      setup(function() {
-        return stubRestAPI(null).then(function() {
+    suite('logged out', () => {
+      setup(() => {
+        return stubRestAPI(null).then(() => {
           element = fixture('basic');
           return element._loadPreferences();
         });
       });
 
-      test('Default preferences are respected', function() {
+      test('Default preferences are respected', () => {
         assert.equal(element._timeFormat, 'HH:mm');
         assert.isFalse(element._relative);
       });
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
index d4df97d..42ae98b 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
@@ -23,6 +23,12 @@
      * @event tap-item-<id>
      */
 
+    /**
+     * Fired when a non-link dropdown item is tapped.
+     *
+     * @event tap-item
+     */
+
     properties: {
       items: Array,
       topContent: Object,
@@ -50,7 +56,7 @@
        */
       disabledIds: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
 
       _hasAvatars: String,
@@ -60,53 +66,63 @@
       Gerrit.BaseUrlBehavior,
     ],
 
-    attached: function() {
-      this.$.restAPI.getConfig().then(function(cfg) {
+    attached() {
+      this.$.restAPI.getConfig().then(cfg => {
         this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
-      }.bind(this));
+      });
     },
 
-    _handleDropdownTap: function(e) {
-      this.$.dropdown.close();
+    _handleDropdownTap(e) {
+      // async is needed so that that the click event is fired before the
+      // dropdown closes (This was a bug for touch devices).
+      this.async(() => {
+        this.$.dropdown.close();
+      }, 1);
     },
 
-    _showDropdownTapHandler: function(e) {
+    _showDropdownTapHandler(e) {
       this.$.dropdown.open();
     },
 
-    _getClassIfBold: function(bold) {
+    _getClassIfBold(bold) {
       return bold ? 'bold-text' : '';
     },
 
-    _computeURLHelper: function(host, path) {
+    _computeURLHelper(host, path) {
       return '//' + host + this.getBaseUrl() + path;
     },
 
-    _computeRelativeURL: function(path) {
-      var host = window.location.host;
+    _computeRelativeURL(path) {
+      const host = window.location.host;
       return this._computeURLHelper(host, path);
     },
 
-    _computeLinkURL: function(link) {
+    _computeLinkURL(link) {
       if (link.target) {
         return link.url;
       }
       return this._computeRelativeURL(link.url);
     },
 
-    _computeLinkRel: function(link) {
+    _computeLinkRel(link) {
       return link.target ? 'noopener' : null;
     },
 
-    _handleItemTap: function(e) {
-      var id = e.target.getAttribute('data-id');
-      if (id && this.disabledIds.indexOf(id) === -1) {
+    _handleItemTap(e) {
+      const id = e.target.getAttribute('data-id');
+      const item = this.items.find(item => {
+        return item.id === id;
+      });
+      if (id && !this.disabledIds.includes(id)) {
+        if (item) {
+          this.dispatchEvent(new CustomEvent('tap-item', {detail: item}));
+        }
         this.dispatchEvent(new CustomEvent('tap-item-' + id));
       }
     },
 
-    _computeDisabledClass: function(id, disabledIdsRecord) {
-      return disabledIdsRecord.base.indexOf(id) === -1 ? '' : 'disabled';
+    _computeDisabledClass(id, disabledIdsRecord) {
+      return disabledIdsRecord.base.includes(id) ? 'disabled' : '';
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
index 74ad85e..1815cca 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
@@ -33,30 +33,30 @@
 </test-fixture>
 
 <script>
-  suite('gr-dropdown tests', function() {
-    var element;
+  suite('gr-dropdown tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
+        getConfig() { return Promise.resolve({}); },
       });
       element = fixture('basic');
     });
 
-    test('tap on trigger opens menu', function() {
+    test('tap on trigger opens menu', () => {
       assert.isFalse(element.$.dropdown.opened);
       MockInteractions.tap(element.$.trigger);
       assert.isTrue(element.$.dropdown.opened);
     });
 
-    test('_computeURLHelper', function() {
-      var path = '/test';
-      var host = 'http://www.testsite.com';
-      var computedPath = element._computeURLHelper(host, path);
+    test('_computeURLHelper', () => {
+      const path = '/test';
+      const host = 'http://www.testsite.com';
+      const computedPath = element._computeURLHelper(host, path);
       assert.equal(computedPath, '//http://www.testsite.com/test');
     });
 
-    test('link URLs', function() {
+    test('link URLs', () => {
       assert.equal(
           element._computeLinkURL({url: '/test'}),
           '//' + window.location.host + '/test');
@@ -65,49 +65,56 @@
           '/test');
     });
 
-    test('link rel', function() {
+    test('link rel', () => {
       assert.isNull(element._computeLinkRel({url: '/test'}));
       assert.equal(
           element._computeLinkRel({url: '/test', target: '_blank'}),
           'noopener');
     });
 
-    test('_getClassIfBold', function() {
-      var bold = true;
+    test('_getClassIfBold', () => {
+      let bold = true;
       assert.equal(element._getClassIfBold(bold), 'bold-text');
 
       bold = false;
       assert.equal(element._getClassIfBold(bold), '');
     });
 
-    test('Top text exists and is bolded correctly', function() {
+    test('Top text exists and is bolded correctly', () => {
       element.topContent = [{text: 'User', bold: true}, {text: 'email'}];
       flushAsynchronousOperations();
-      var topItems = Polymer.dom(element.root).querySelectorAll('.top-item');
+      const topItems = Polymer.dom(element.root).querySelectorAll('.top-item');
       assert.equal(topItems.length, 2);
       assert.isTrue(topItems[0].classList.contains('bold-text'));
       assert.isFalse(topItems[1].classList.contains('bold-text'));
     });
 
-    test('non link items', function() {
-      element.items = [
-          {name: 'item one', id: 'foo'}, {name: 'item two', id: 'bar'}];
-      var stub = sinon.stub();
-      element.addEventListener('tap-item-foo', stub);
+    test('non link items', () => {
+      const item0 = {name: 'item one', id: 'foo'};
+      element.items = [item0, {name: 'item two', id: 'bar'}];
+      const fooTapped = sinon.stub();
+      const tapped = sinon.stub();
+      element.addEventListener('tap-item-foo', fooTapped);
+      element.addEventListener('tap-item', tapped);
       flushAsynchronousOperations();
       MockInteractions.tap(element.$$('.itemAction'));
-      assert.isTrue(stub.called);
+      assert.isTrue(fooTapped.called);
+      assert.isTrue(tapped.called);
+      assert.deepEqual(tapped.lastCall.args[0].detail, item0);
     });
 
-    test('disabled non link item', function() {
+    test('disabled non link item', () => {
       element.items = [{name: 'item one', id: 'foo'}];
       element.disabledIds = ['foo'];
 
-      var stub = sinon.stub();
+      const stub = sinon.stub();
+      const tapped = sinon.stub();
       element.addEventListener('tap-item-foo', stub);
+      element.addEventListener('tap-item', tapped);
       flushAsynchronousOperations();
       MockInteractions.tap(element.$$('.itemAction'));
       assert.isFalse(stub.called);
+      assert.isFalse(tapped.called);
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
index 86211a4..e6ea72d 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
@@ -53,11 +53,11 @@
       _newContent: String,
     },
 
-    focusTextarea: function() {
+    focusTextarea() {
       this.$$('iron-autogrow-textarea').textarea.focus();
     },
 
-    _editingChanged: function(editing) {
+    _editingChanged(editing) {
       if (!editing) { return; }
 
       // TODO(wyatta) switch linkify sequence, see issue 5526.
@@ -65,16 +65,16 @@
           this.content.replace(/^R=\u200B/gm, 'R=') : this.content;
     },
 
-    _computeSaveDisabled: function(disabled, content, newContent) {
+    _computeSaveDisabled(disabled, content, newContent) {
       return disabled || (content === newContent);
     },
 
-    _handleSave: function(e) {
+    _handleSave(e) {
       e.preventDefault();
       this.fire('editable-content-save', {content: this._newContent});
     },
 
-    _handleCancel: function(e) {
+    _handleCancel(e) {
       e.preventDefault();
       this.editing = false;
       this.fire('editable-content-cancel');
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
index b25e815..df98d5a 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
@@ -33,37 +33,37 @@
 </test-fixture>
 
 <script>
-  suite('gr-editable-content tests', function() {
-    var element;
+  suite('gr-editable-content tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
     });
 
-    test('save event', function(done) {
+    test('save event', done => {
       element._newContent = 'foo';
-      element.addEventListener('editable-content-save', function(e) {
+      element.addEventListener('editable-content-save', e => {
         assert.equal(e.detail.content, 'foo');
         done();
       });
       MockInteractions.tap(element.$$('gr-button[primary]'));
     });
 
-    test('cancel event', function(done) {
-      element.addEventListener('editable-content-cancel', function() {
+    test('cancel event', done => {
+      element.addEventListener('editable-content-cancel', () => {
         done();
       });
       MockInteractions.tap(element.$$('gr-button:not([primary])'));
     });
 
-    test('enabling editing updates edit field contents', function() {
+    test('enabling editing updates edit field contents', () => {
       element.content = 'current content';
       element._newContent = 'stale content';
       element.editing = true;
       assert.equal(element._newContent, 'current content');
     });
 
-    test('disabling editing does not update edit field contents', function() {
+    test('disabling editing does not update edit field contents', () => {
       element.content = 'current content';
       element.editing = true;
       element._newContent = 'stale content';
@@ -71,24 +71,24 @@
       assert.equal(element._newContent, 'stale content');
     });
 
-    test('zero width spaces are removed properly', function() {
+    test('zero width spaces are removed properly', () => {
       element.removeZeroWidthSpace = true;
       element.content = 'R=\u200Btest@google.com';
       element.editing = true;
       assert.equal(element._newContent, 'R=test@google.com');
     });
 
-    suite('editing', function() {
-      setup(function() {
+    suite('editing', () => {
+      setup(() => {
         element.content = 'current content';
         element.editing = true;
       });
 
-      test('save button is disabled initially', function() {
+      test('save button is disabled initially', () => {
         assert.isTrue(element.$$('gr-button[primary]').disabled);
       });
 
-      test('save button is enabled when content changes', function() {
+      test('save button is enabled when content changes', () => {
         element._newContent = 'new content';
         assert.isFalse(element.$$('gr-button[primary]').disabled);
       });
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
index f3e83f9..1839479 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
@@ -49,30 +49,30 @@
       tabindex: '0',
     },
 
-    _usePlaceholder: function(value, placeholder) {
+    _usePlaceholder(value, placeholder) {
       return (!value || !value.length) && placeholder;
     },
 
-    _computeLabel: function(value, placeholder) {
+    _computeLabel(value, placeholder) {
       if (this._usePlaceholder(value, placeholder)) {
         return placeholder;
       }
       return value;
     },
 
-    _open: function() {
+    _open() {
       if (this.readOnly || this.editing) { return; }
 
       this._inputText = this.value;
       this.editing = true;
 
-      this.async(function() {
+      this.async(() => {
         this.$.input.focus();
         this.$.input.setSelectionRange(0, this.$.input.value.length);
       });
     },
 
-    _save: function() {
+    _save() {
       if (!this.editing) { return; }
 
       this.value = this._inputText;
@@ -80,14 +80,14 @@
       this.fire('changed', this.value);
     },
 
-    _cancel: function() {
+    _cancel() {
       if (!this.editing) { return; }
 
       this.editing = false;
       this._inputText = this.value;
     },
 
-    _handleInputKeydown: function(e) {
+    _handleInputKeydown(e) {
       if (e.keyCode === 13) {  // Enter key
         e.preventDefault();
         this._save();
@@ -97,8 +97,8 @@
       }
     },
 
-    _computeLabelClass: function(readOnly, value, placeholder) {
-      var classes = [];
+    _computeLabelClass(readOnly, value, placeholder) {
+      const classes = [];
       if (!readOnly) { classes.push('editable'); }
       if (this._usePlaceholder(value, placeholder)) {
         classes.push('placeholder');
@@ -106,7 +106,7 @@
       return classes.join(' ');
     },
 
-    _updateTitle: function(value) {
+    _updateTitle(value) {
       this.setAttribute('title', (value && value.length) ? value : null);
     },
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
index 42770aa..ffd1f81 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
@@ -44,19 +44,19 @@
 </test-fixture>
 
 <script>
-  suite('gr-editable-label tests', function() {
-    var element;
-    var input;
-    var label;
+  suite('gr-editable-label tests', () => {
+    let element;
+    let input;
+    let label;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
 
       input = element.$$('input');
       label = element.$$('label');
     });
 
-    test('element render', function() {
+    test('element render', () => {
       // The input is hidden and the label is visible:
       assert.isNotNull(input.getAttribute('hidden'));
       assert.isNull(label.getAttribute('hidden'));
@@ -76,8 +76,8 @@
       assert.equal(input.value, 'value text');
     });
 
-    test('edit value', function(done) {
-      var editedStub = sinon.stub();
+    test('edit value', done => {
+      const editedStub = sinon.stub();
       element.addEventListener('changed', editedStub);
 
       MockInteractions.tap(label);
@@ -88,7 +88,7 @@
 
       assert.isFalse(editedStub.called);
 
-      element.async(function() {
+      element.async(() => {
         assert.isTrue(editedStub.called);
         assert.equal(input.value, 'new text');
         done();
@@ -99,19 +99,19 @@
     });
   });
 
-  suite('gr-editable-label read-only tests', function() {
-    var element;
-    var input;
-    var label;
+  suite('gr-editable-label read-only tests', () => {
+    let element;
+    let input;
+    let label;
 
-    setup(function() {
+    setup(() => {
       element = fixture('read-only');
 
       input = element.$$('input');
       label = element.$$('label');
     });
 
-    test('disallows edit when read-only', function() {
+    test('disallows edit when read-only', () => {
       // The input is hidden and the label is visible:
       assert.isNotNull(input.getAttribute('hidden'));
       assert.isNull(label.getAttribute('hidden'));
@@ -125,7 +125,7 @@
       assert.isNull(label.getAttribute('hidden'));
     });
 
-    test('label is not marked as editable', function() {
+    test('label is not marked as editable', () => {
       assert.isFalse(label.classList.contains('editable'));
     });
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html
index d719f70..7939175 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html
@@ -29,6 +29,11 @@
       gr-linked-text.pre {
         margin: 0 0 1.4em 0;
       }
+      p,
+      ul,
+      blockquote {
+        max-width: var(--gr-formatted-text-prose-max-width, none);
+      }
       :host.noTrailingMargin p:last-child,
       :host.noTrailingMargin ul:last-child,
       :host.noTrailingMargin blockquote:last-child,
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
index a2130b2..b26226e 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
@@ -14,7 +14,8 @@
 (function() {
   'use strict';
 
-  var QUOTE_MARKER_PATTERN = /\n\s?>\s/g;
+  // eslint-disable-next-line no-unused-vars
+  const QUOTE_MARKER_PATTERN = /\n\s?>\s/g;
 
   Polymer({
     is: 'gr-formatted-text',
@@ -35,7 +36,7 @@
       '_contentOrConfigChanged(content, config)',
     ],
 
-    ready: function() {
+    ready() {
       if (this.noTrailingMargin) {
         this.classList.add('noTrailingMargin');
       }
@@ -50,11 +51,11 @@
      *
      * @return {string}
      */
-    getTextContent: function() {
+    getTextContent() {
       return this._blocksToText(this._computeBlocks(this.content));
     },
 
-    _contentChanged: function(content) {
+    _contentChanged(content) {
       // In the case where the config may not be set (perhaps due to the
       // request for it still being in flight), set the content anyway to
       // prevent waiting on the config to display the text.
@@ -65,8 +66,8 @@
     /**
      * Given a source string, update the DOM inside #container.
      */
-    _contentOrConfigChanged: function(content) {
-      var container = Polymer.dom(this.$.container);
+    _contentOrConfigChanged(content) {
+      const container = Polymer.dom(this.$.container);
 
       // Remove existing content.
       while (container.firstChild) {
@@ -74,10 +75,9 @@
       }
 
       // Add new content.
-      this._computeNodes(this._computeBlocks(content))
-          .forEach(function(node) {
+      for (const node of this._computeNodes(this._computeBlocks(content))) {
         container.appendChild(node);
-      });
+      }
     },
 
     /**
@@ -102,14 +102,14 @@
      * @param {string} content
      * @return {!Array<!Object>}
      */
-    _computeBlocks: function(content) {
+    _computeBlocks(content) {
       if (!content) { return []; }
 
-      var result = [];
-      var split = content.split('\n\n');
-      var p;
+      const result = [];
+      const split = content.split('\n\n');
+      let p;
 
-      for (var i = 0; i < split.length; i++) {
+      for (let i = 0; i < split.length; i++) {
         p = split[i];
         if (!p.length) { continue; }
 
@@ -153,14 +153,14 @@
      *   potential paragraph).
      * @param {!Array<!Object>} out The list of blocks to append to.
      */
-    _makeList: function(p, out) {
-      var block = null;
-      var inList = false;
-      var inParagraph = false;
-      var lines = p.split('\n');
-      var line;
+    _makeList(p, out) {
+      let block = null;
+      let inList = false;
+      let inParagraph = false;
+      const lines = p.split('\n');
+      let line;
 
-      for (var i = 0; i < lines.length; i++) {
+      for (let i = 0; i < lines.length; i++) {
         line = lines[i];
 
         if (line[0] === '-' || line[0] === '*') {
@@ -198,10 +198,10 @@
       }
     },
 
-    _makeQuote: function(p) {
-      var quotedLines = p
+    _makeQuote(p) {
+      const quotedLines = p
           .split('\n')
-          .map(function(l) { return l.replace(/^[ ]?>[ ]?/, ''); })
+          .map(l => l.replace(/^[ ]?>[ ]?/, ''))
           .join('\n');
       return {
         type: 'quote',
@@ -209,22 +209,22 @@
       };
     },
 
-    _isQuote: function(p) {
-      return p.indexOf('> ') === 0 || p.indexOf(' > ') === 0;
+    _isQuote(p) {
+      return p.startsWith('> ') || p.startsWith(' > ');
     },
 
-    _isPreFormat: function(p) {
-      return p.indexOf('\n ') !== -1 || p.indexOf('\n\t') !== -1 ||
-          p.indexOf(' ') === 0 || p.indexOf('\t') === 0;
+    _isPreFormat(p) {
+      return p.includes('\n ') || p.includes('\n\t') ||
+          p.startsWith(' ') || p.startsWith('\t');
     },
 
-    _isList: function(p) {
-      return p.indexOf('\n- ') !== -1 || p.indexOf('\n* ') !== -1 ||
-          p.indexOf('- ') === 0 || p.indexOf('* ') === 0;
+    _isList(p) {
+      return p.includes('\n- ') || p.includes('\n* ') ||
+          p.startsWith('- ') || p.startsWith('* ');
     },
 
-    _makeLinkedText: function(content, isPre) {
-      var text = document.createElement('gr-linked-text');
+    _makeLinkedText(content, isPre) {
+      const text = document.createElement('gr-linked-text');
       text.config = this.config;
       text.content = content;
       text.pre = true;
@@ -239,19 +239,19 @@
      * @param  {!Array<!Object>} blocks
      * @return {!Array<!HTMLElement>}
      */
-    _computeNodes: function(blocks) {
-      return blocks.map(function(block) {
+    _computeNodes(blocks) {
+      return blocks.map(block => {
         if (block.type === 'paragraph') {
-          var p = document.createElement('p');
+          const p = document.createElement('p');
           p.appendChild(this._makeLinkedText(block.text));
           return p;
         }
 
         if (block.type === 'quote') {
-          var bq = document.createElement('blockquote');
-          this._computeNodes(block.blocks).forEach(function(node) {
+          const bq = document.createElement('blockquote');
+          for (const node of this._computeNodes(block.blocks)) {
             bq.appendChild(node);
-          });
+          }
           return bq;
         }
 
@@ -260,19 +260,19 @@
         }
 
         if (block.type === 'list') {
-          var ul = document.createElement('ul');
-          block.items.forEach(function(item) {
-            var li = document.createElement('li');
+          const ul = document.createElement('ul');
+          for (const item of block.items) {
+            const li = document.createElement('li');
             li.appendChild(this._makeLinkedText(item));
             ul.appendChild(li);
-          }.bind(this));
+          }
           return ul;
         }
-      }.bind(this));
+      });
     },
 
-    _blocksToText: function(blocks) {
-      return blocks.map(function(block) {
+    _blocksToText(blocks) {
+      return blocks.map(block => {
         if (block.type === 'paragraph' || block.type === 'pre') {
           return block.text;
         }
@@ -282,7 +282,7 @@
         if (block.type === 'list') {
           return block.items.join('\n');
         }
-      }.bind(this)).join('\n\n');
+      }).join('\n\n');
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html
index 5afe60a..0192ea9 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html
@@ -32,9 +32,9 @@
 </test-fixture>
 
 <script>
-  suite('gr-formatted-text tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-formatted-text tests', () => {
+    let element;
+    let sandbox;
 
     function assertBlock(result, index, type, text) {
       assert.equal(result[index].type, type);
@@ -46,73 +46,73 @@
       assert.equal(result[resultIndex].items[itemIndex], text);
     }
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('parse null undefined and empty', function() {
+    test('parse null undefined and empty', () => {
       assert.lengthOf(element._computeBlocks(null), 0);
       assert.lengthOf(element._computeBlocks(undefined), 0);
       assert.lengthOf(element._computeBlocks(''), 0);
     });
 
-    test('parse simple', function() {
-      var comment = 'Para1';
-      var result = element._computeBlocks(comment);
+    test('parse simple', () => {
+      const comment = 'Para1';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 1);
       assertBlock(result, 0, 'paragraph', comment);
     });
 
-    test('parse multiline para', function() {
-      var comment = 'Para 1\nStill para 1';
-      var result = element._computeBlocks(comment);
+    test('parse multiline para', () => {
+      const comment = 'Para 1\nStill para 1';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 1);
       assertBlock(result, 0, 'paragraph', comment);
     });
 
-    test('parse para break', function() {
-      var comment = 'Para 1\n\nPara 2\n\nPara 3';
-      var result = element._computeBlocks(comment);
+    test('parse para break', () => {
+      const comment = 'Para 1\n\nPara 2\n\nPara 3';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 3);
       assertBlock(result, 0, 'paragraph', 'Para 1');
       assertBlock(result, 1, 'paragraph', 'Para 2');
       assertBlock(result, 2, 'paragraph', 'Para 3');
     });
 
-    test('parse quote', function() {
-      var comment = '> Quote text';
-      var result = element._computeBlocks(comment);
+    test('parse quote', () => {
+      const comment = '> Quote text';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 1);
       assert.equal(result[0].type, 'quote');
       assert.lengthOf(result[0].blocks, 1);
       assertBlock(result[0].blocks, 0, 'paragraph', 'Quote text');
     });
 
-    test('parse quote lead space', function() {
-      var comment = ' > Quote text';
-      var result = element._computeBlocks(comment);
+    test('parse quote lead space', () => {
+      const comment = ' > Quote text';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 1);
       assert.equal(result[0].type, 'quote');
       assert.lengthOf(result[0].blocks, 1);
       assertBlock(result[0].blocks, 0, 'paragraph', 'Quote text');
     });
 
-    test('parse excludes empty', function() {
-      var comment = 'Para 1\n\n\n\nPara 2';
-      var result = element._computeBlocks(comment);
+    test('parse excludes empty', () => {
+      const comment = 'Para 1\n\n\n\nPara 2';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 2);
       assertBlock(result, 0, 'paragraph', 'Para 1');
       assertBlock(result, 1, 'paragraph', 'Para 2');
     });
 
-    test('parse multiline quote', function() {
-      var comment = '> Quote line 1\n> Quote line 2\n > Quote line 3\n';
-      var result = element._computeBlocks(comment);
+    test('parse multiline quote', () => {
+      const comment = '> Quote line 1\n> Quote line 2\n > Quote line 3\n';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 1);
       assert.equal(result[0].type, 'quote');
       assert.lengthOf(result[0].blocks, 1);
@@ -120,55 +120,55 @@
           'Quote line 1\nQuote line 2\nQuote line 3\n');
     });
 
-    test('parse pre', function() {
-      var comment = '    Four space indent.';
-      var result = element._computeBlocks(comment);
+    test('parse pre', () => {
+      const comment = '    Four space indent.';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 1);
       assertBlock(result, 0, 'pre', comment);
     });
 
-    test('parse one space pre', function() {
-      var comment = ' One space indent.\n Another line.';
-      var result = element._computeBlocks(comment);
+    test('parse one space pre', () => {
+      const comment = ' One space indent.\n Another line.';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 1);
       assertBlock(result, 0, 'pre', comment);
     });
 
-    test('parse tab pre', function() {
-      var comment = '\tOne tab indent.\n\tAnother line.\n  Yet another!';
-      var result = element._computeBlocks(comment);
+    test('parse tab pre', () => {
+      const comment = '\tOne tab indent.\n\tAnother line.\n  Yet another!';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 1);
       assertBlock(result, 0, 'pre', comment);
     });
 
-    test('parse intermediate leading whitespace pre', function() {
-      var comment = 'No indent.\n\tNonzero indent.\nNo indent again.';
-      var result = element._computeBlocks(comment);
+    test('parse intermediate leading whitespace pre', () => {
+      const comment = 'No indent.\n\tNonzero indent.\nNo indent again.';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 1);
       assertBlock(result, 0, 'pre', comment);
     });
 
-    test('parse star list', function() {
-      var comment = '* Item 1\n* Item 2\n* Item 3';
-      var result = element._computeBlocks(comment);
+    test('parse star list', () => {
+      const comment = '* Item 1\n* Item 2\n* Item 3';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 1);
       assertListBlock(result, 0, 0, 'Item 1');
       assertListBlock(result, 0, 1, 'Item 2');
       assertListBlock(result, 0, 2, 'Item 3');
     });
 
-    test('parse dash list', function() {
-      var comment = '- Item 1\n- Item 2\n- Item 3';
-      var result = element._computeBlocks(comment);
+    test('parse dash list', () => {
+      const comment = '- Item 1\n- Item 2\n- Item 3';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 1);
       assertListBlock(result, 0, 0, 'Item 1');
       assertListBlock(result, 0, 1, 'Item 2');
       assertListBlock(result, 0, 2, 'Item 3');
     });
 
-    test('parse mixed list', function() {
-      var comment = '- Item 1\n* Item 2\n- Item 3\n* Item 4';
-      var result = element._computeBlocks(comment);
+    test('parse mixed list', () => {
+      const comment = '- Item 1\n* Item 2\n- Item 3\n* Item 4';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 1);
       assertListBlock(result, 0, 0, 'Item 1');
       assertListBlock(result, 0, 1, 'Item 2');
@@ -176,8 +176,8 @@
       assertListBlock(result, 0, 3, 'Item 4');
     });
 
-    test('parse mixed block types', function() {
-      var comment = 'Paragraph\nacross\na\nfew\nlines.' +
+    test('parse mixed block types', () => {
+      const comment = 'Paragraph\nacross\na\nfew\nlines.' +
           '\n\n' +
           '> Quote\n> across\n> not many lines.' +
           '\n\n' +
@@ -190,7 +190,7 @@
           '\tPreformatted text.' +
           '\n\n' +
           'Parting words.';
-      var result = element._computeBlocks(comment);
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 7);
       assertBlock(result, 0, 'paragraph', 'Paragraph\nacross\na\nfew\nlines.');
 
@@ -209,18 +209,18 @@
       assertBlock(result, 6, 'paragraph', 'Parting words.');
     });
 
-    test('bullet list 1', function() {
-      var comment = 'A\n\n* line 1\n* 2nd line';
-      var result = element._computeBlocks(comment);
+    test('bullet list 1', () => {
+      const comment = 'A\n\n* line 1\n* 2nd line';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 2);
       assertBlock(result, 0, 'paragraph', 'A');
       assertListBlock(result, 1, 0, 'line 1');
       assertListBlock(result, 1, 1, '2nd line');
     });
 
-    test('bullet list 2', function() {
-      var comment = 'A\n\n* line 1\n* 2nd line\n\nB';
-      var result = element._computeBlocks(comment);
+    test('bullet list 2', () => {
+      const comment = 'A\n\n* line 1\n* 2nd line\n\nB';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 3);
       assertBlock(result, 0, 'paragraph', 'A');
       assertListBlock(result, 1, 0, 'line 1');
@@ -228,50 +228,50 @@
       assertBlock(result, 2, 'paragraph', 'B');
     });
 
-    test('bullet list 3', function() {
-      var comment = '* line 1\n* 2nd line\n\nB';
-      var result = element._computeBlocks(comment);
+    test('bullet list 3', () => {
+      const comment = '* line 1\n* 2nd line\n\nB';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 2);
       assertListBlock(result, 0, 0, 'line 1');
       assertListBlock(result, 0, 1, '2nd line');
       assertBlock(result, 1, 'paragraph', 'B');
     });
 
-    test('bullet list 4', function() {
-      var comment = 'To see this bug, you have to:\n' +
+    test('bullet list 4', () => {
+      const comment = 'To see this bug, you have to:\n' +
           '* Be on IMAP or EAS (not on POP)\n' +
           '* Be very unlucky\n';
-      var result = element._computeBlocks(comment);
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 2);
       assertBlock(result, 0, 'paragraph', 'To see this bug, you have to:');
       assertListBlock(result, 1, 0, 'Be on IMAP or EAS (not on POP)');
       assertListBlock(result, 1, 1, 'Be very unlucky');
     });
 
-    test('bullet list 5', function() {
-      var comment = 'To see this bug,\n' +
+    test('bullet list 5', () => {
+      const comment = 'To see this bug,\n' +
           'you have to:\n' +
           '* Be on IMAP or EAS (not on POP)\n' +
           '* Be very unlucky\n';
-      var result = element._computeBlocks(comment);
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 2);
       assertBlock(result, 0, 'paragraph', 'To see this bug, you have to:');
       assertListBlock(result, 1, 0, 'Be on IMAP or EAS (not on POP)');
       assertListBlock(result, 1, 1, 'Be very unlucky');
     });
 
-    test('dash list 1', function() {
-      var comment = 'A\n\n- line 1\n- 2nd line';
-      var result = element._computeBlocks(comment);
+    test('dash list 1', () => {
+      const comment = 'A\n\n- line 1\n- 2nd line';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 2);
       assertBlock(result, 0, 'paragraph', 'A');
       assertListBlock(result, 1, 0, 'line 1');
       assertListBlock(result, 1, 1, '2nd line');
     });
 
-    test('dash list 2', function() {
-      var comment = 'A\n\n- line 1\n- 2nd line\n\nB';
-      var result = element._computeBlocks(comment);
+    test('dash list 2', () => {
+      const comment = 'A\n\n- line 1\n- 2nd line\n\nB';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 3);
       assertBlock(result, 0, 'paragraph', 'A');
       assertListBlock(result, 1, 0, 'line 1');
@@ -279,52 +279,52 @@
       assertBlock(result, 2, 'paragraph', 'B');
     });
 
-    test('dash list 3', function() {
-      var comment = '- line 1\n- 2nd line\n\nB';
-      var result = element._computeBlocks(comment);
+    test('dash list 3', () => {
+      const comment = '- line 1\n- 2nd line\n\nB';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 2);
       assertListBlock(result, 0, 0, 'line 1');
       assertListBlock(result, 0, 1, '2nd line');
       assertBlock(result, 1, 'paragraph', 'B');
     });
 
-    test('pre format 1', function() {
-      var comment = 'A\n\n  This is pre\n  formatted';
-      var result = element._computeBlocks(comment);
+    test('pre format 1', () => {
+      const comment = 'A\n\n  This is pre\n  formatted';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 2);
       assertBlock(result, 0, 'paragraph', 'A');
       assertBlock(result, 1, 'pre', '  This is pre\n  formatted');
     });
 
-    test('pre format 2', function() {
-      var comment = 'A\n\n  This is pre\n  formatted\n\nbut this is not';
-      var result = element._computeBlocks(comment);
+    test('pre format 2', () => {
+      const comment = 'A\n\n  This is pre\n  formatted\n\nbut this is not';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 3);
       assertBlock(result, 0, 'paragraph', 'A');
       assertBlock(result, 1, 'pre', '  This is pre\n  formatted');
       assertBlock(result, 2, 'paragraph', 'but this is not');
     });
 
-    test('pre format 3', function() {
-      var comment = 'A\n\n  Q\n    <R>\n  S\n\nB';
-      var result = element._computeBlocks(comment);
+    test('pre format 3', () => {
+      const comment = 'A\n\n  Q\n    <R>\n  S\n\nB';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 3);
       assertBlock(result, 0, 'paragraph', 'A');
       assertBlock(result, 1, 'pre', '  Q\n    <R>\n  S');
       assertBlock(result, 2, 'paragraph', 'B');
     });
 
-    test('pre format 4', function() {
-      var comment = '  Q\n    <R>\n  S\n\nB';
-      var result = element._computeBlocks(comment);
+    test('pre format 4', () => {
+      const comment = '  Q\n    <R>\n  S\n\nB';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 2);
       assertBlock(result, 0, 'pre', '  Q\n    <R>\n  S');
       assertBlock(result, 1, 'paragraph', 'B');
     });
 
-    test('quote 1', function() {
-      var comment = '> I\'m happy\n > with quotes!\n\nSee above.';
-      var result = element._computeBlocks(comment);
+    test('quote 1', () => {
+      const comment = '> I\'m happy\n > with quotes!\n\nSee above.';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 2);
       assert.equal(result[0].type, 'quote');
       assert.lengthOf(result[0].blocks, 1);
@@ -332,9 +332,9 @@
       assertBlock(result, 1, 'paragraph', 'See above.');
     });
 
-    test('quote 2', function() {
-      var comment = 'See this said:\n\n > a quoted\n > string block\n\nOK?';
-      var result = element._computeBlocks(comment);
+    test('quote 2', () => {
+      const comment = 'See this said:\n\n > a quoted\n > string block\n\nOK?';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 3);
       assertBlock(result, 0, 'paragraph', 'See this said:');
       assert.equal(result[1].type, 'quote');
@@ -343,9 +343,9 @@
       assertBlock(result, 2, 'paragraph', 'OK?');
     });
 
-    test('nested quotes', function() {
-      var comment = ' > > prior\n > \n > next\n';
-      var result = element._computeBlocks(comment);
+    test('nested quotes', () => {
+      const comment = ' > > prior\n > \n > next\n';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 1);
       assert.equal(result[0].type, 'quote');
       assert.lengthOf(result[0].blocks, 2);
@@ -355,25 +355,25 @@
       assertBlock(result[0].blocks, 1, 'paragraph', 'next\n');
     });
 
-    test('getTextContent', function() {
-      var comment = 'Paragraph\n\n  pre\n\n* List\n* Of\n* Items\n\n> Quote';
+    test('getTextContent', () => {
+      const comment = 'Paragraph\n\n  pre\n\n* List\n* Of\n* Items\n\n> Quote';
       element.content = comment;
-      var result = element.getTextContent();
-      var expected = 'Paragraph\n\n  pre\n\nList\nOf\nItems\n\nQuote';
+      const result = element.getTextContent();
+      const expected = 'Paragraph\n\n  pre\n\nList\nOf\nItems\n\nQuote';
       assert.equal(result, expected);
     });
 
-    test('_contentOrConfigChanged not called without config', function() {
-      var contentStub = sandbox.stub(element, '_contentChanged');
-      var contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
+    test('_contentOrConfigChanged not called without config', () => {
+      const contentStub = sandbox.stub(element, '_contentChanged');
+      const contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
       element.content = 'some text';
       assert.isTrue(contentStub.called);
       assert.isFalse(contentConfigStub.called);
     });
 
-    test('_contentOrConfigChanged called with config', function() {
-      var contentStub = sandbox.stub(element, '_contentChanged');
-      var contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
+    test('_contentOrConfigChanged called with config', () => {
+      const contentStub = sandbox.stub(element, '_contentChanged');
+      const contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
       element.content = 'some text';
       element.config = {};
       assert.isTrue(contentStub.called);
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 72c7f6e..99c4fed 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
@@ -22,17 +22,27 @@
   }
 
   GrChangeActionsInterface.prototype.addPrimaryActionKey = function(key) {
-    if (this._el.primaryActionKeys.indexOf(key) !== -1) { return; }
+    if (this._el.primaryActionKeys.includes(key)) { return; }
 
     this._el.push('primaryActionKeys', key);
   };
 
   GrChangeActionsInterface.prototype.removePrimaryActionKey = function(key) {
-    this._el.primaryActionKeys = this._el.primaryActionKeys.filter(function(k) {
+    this._el.primaryActionKeys = this._el.primaryActionKeys.filter(k => {
       return k !== key;
     });
   };
 
+  GrChangeActionsInterface.prototype.setActionOverflow = function(type, key,
+      overflow) {
+    return this._el.setActionOverflow(type, key, overflow);
+  };
+
+  GrChangeActionsInterface.prototype.setActionPriority = function(type, key,
+      priority) {
+    return this._el.setActionPriority(type, key, priority);
+  };
+
   GrChangeActionsInterface.prototype.setActionHidden = function(type, key,
       hidden) {
     return this._el.setActionHidden(type, key, hidden);
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 20b5dcb..24fee39 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
@@ -37,43 +37,44 @@
 </test-fixture>
 
 <script>
-  suite('gr-js-api-interface tests', function() {
-    var element;
-    var changeActions;
+  suite('gr-js-api-interface tests', () => {
+    let element;
+    let changeActions;
 
     // Because deepEqual doesn’t behave in Safari.
     function assertArraysEqual(actual, expected) {
       assert.equal(actual.length, expected.length);
-      for (var i = 0; i < actual.length; i++) {
+      for (let i = 0; i < actual.length; i++) {
         assert.equal(actual[i], expected[i]);
       }
     }
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       element.change = {};
       element._hasKnownChainState = false;
-      var plugin;
-      Gerrit.install(function(p) { plugin = p; }, '0.1',
+      let plugin;
+      Gerrit.install(p => { plugin = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/test.js');
       changeActions = plugin.changeActions();
     });
 
-    teardown(function() {
+    teardown(() => {
       changeActions = null;
     });
 
-    test('property existence', function() {
-      [
+    test('property existence', () => {
+      const properties = [
         'ActionType',
         'ChangeActions',
         'RevisionActions',
-      ].forEach(function(p) {
+      ];
+      for (const p of properties) {
         assertArraysEqual(changeActions[p], element[p]);
-      });
+      }
     });
 
-    test('add/remove primary action keys', function() {
+    test('add/remove primary action keys', () => {
       element.primaryActionKeys = [];
       changeActions.addPrimaryActionKey('foo');
       assertArraysEqual(element.primaryActionKeys, ['foo']);
@@ -89,34 +90,34 @@
       assertArraysEqual(element.primaryActionKeys, []);
     });
 
-    test('action buttons', function(done) {
-      var key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      var handler = sinon.spy();
+    test('action buttons', done => {
+      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      const handler = sinon.spy();
       changeActions.addTapListener(key, handler);
-      flush(function() {
+      flush(() => {
         MockInteractions.tap(element.$$('[data-action-key="' + key + '"]'));
         assert(handler.calledOnce);
         changeActions.removeTapListener(key, handler);
         MockInteractions.tap(element.$$('[data-action-key="' + key + '"]'));
         assert(handler.calledOnce);
         changeActions.remove(key);
-        flush(function() {
+        flush(() => {
           assert.isNull(element.$$('[data-action-key="' + key + '"]'));
           done();
         });
       });
     });
 
-    test('action button properties', function(done) {
-      var key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      flush(function() {
-        var button = element.$$('[data-action-key="' + key + '"]');
+    test('action button properties', done => {
+      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      flush(() => {
+        const button = element.$$('[data-action-key="' + key + '"]');
         assert.isOk(button);
         assert.equal(button.getAttribute('data-label'), 'Bork!');
         assert.isNotOk(button.disabled);
         changeActions.setLabel(key, 'Yo');
         changeActions.setEnabled(key, false);
-        flush(function() {
+        flush(() => {
           assert.equal(button.getAttribute('data-label'), 'Yo');
           assert.isTrue(button.disabled);
           done();
@@ -124,20 +125,58 @@
       });
     });
 
-    test('hide action buttons', function(done) {
-      var key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      flush(function() {
-        var button = element.$$('[data-action-key="' + key + '"]');
+    test('hide action buttons', done => {
+      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      flush(() => {
+        const 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 + '"]');
+        changeActions.setActionHidden(
+            changeActions.ActionType.REVISION, key, true);
+        flush(() => {
+          const button = element.$$('[data-action-key="' + key + '"]');
           assert.isNotOk(button);
           done();
         });
       });
     });
+
+    test('move action button to overflow', done => {
+      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      flush(() => {
+        assert.isTrue(element.$.moreActions.hidden);
+        assert.isOk(element.$$('[data-action-key="' + key + '"]'));
+        changeActions.setActionOverflow(
+            changeActions.ActionType.REVISION, key, true);
+        flush(() => {
+          assert.isNotOk(element.$$('[data-action-key="' + key + '"]'));
+          assert.isFalse(element.$.moreActions.hidden);
+          assert.strictEqual(element.$.moreActions.items[0].name, 'Bork!');
+          done();
+        });
+      });
+    });
+
+    test('change actions priority', done => {
+      const key1 =
+          changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      const key2 =
+          changeActions.add(changeActions.ActionType.CHANGE, 'Squanch?');
+      flush(() => {
+        let buttons =
+            Polymer.dom(element.root).querySelectorAll('[data-action-key]');
+        assert.equal(buttons[0].getAttribute('data-action-key'), key1);
+        assert.equal(buttons[1].getAttribute('data-action-key'), key2);
+        changeActions.setActionPriority(
+            changeActions.ActionType.REVISION, key1, 10);
+        flush(() => {
+          buttons =
+              Polymer.dom(element.root).querySelectorAll('[data-action-key]');
+          assert.equal(buttons[0].getAttribute('data-action-key'), key2);
+          assert.equal(buttons[1].getAttribute('data-action-key'), key1);
+          done();
+        });
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
index 9d6b83b..aeb9607 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
@@ -22,8 +22,8 @@
     this._el.setLabelValue(label, value);
   };
 
-  GrChangeReplyInterface.prototype.send = function() {
-    return this._el.send();
+  GrChangeReplyInterface.prototype.send = function(opt_includeComments) {
+    return this._el.send(opt_includeComments);
   };
 
   window.GrChangeReplyInterface = GrChangeReplyInterface;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
index d7d5cfe..73f3479 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
@@ -37,37 +37,37 @@
 </test-fixture>
 
 <script>
-  suite('gr-change-reply-js-api tests', function() {
-    var element;
-    var sandbox;
-    var changeReply;
+  suite('gr-change-reply-js-api tests', () => {
+    let element;
+    let sandbox;
+    let changeReply;
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
-        getAccount: function() { return Promise.resolve(null); },
+        getConfig() { return Promise.resolve({}); },
+        getAccount() { return Promise.resolve(null); },
       });
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
-      var plugin;
-      Gerrit.install(function(p) { plugin = p; }, '0.1',
+      let plugin;
+      Gerrit.install(p => { plugin = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/test.js');
       changeReply = plugin.changeReply();
     });
 
-    teardown(function() {
+    teardown(() => {
       changeReply = null;
       sandbox.restore();
     });
 
-    test('calls', function() {
-      var setLabelValueStub = sinon.stub(element, 'setLabelValue');
+    test('calls', () => {
+      const setLabelValueStub = sinon.stub(element, 'setLabelValue');
       changeReply.setLabelValue('My-Label', '+1337');
       assert(setLabelValueStub.calledWithExactly('My-Label', '+1337'));
 
-      var sendStub = sinon.stub(element, 'send');
-      changeReply.send();
-      assert(sendStub.calledWithExactly());
+      const sendStub = sinon.stub(element, 'send');
+      changeReply.send(false);
+      assert(sendStub.calledWithExactly(false));
     });
   });
 </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 5c0535b..3cda49b 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
@@ -15,10 +15,10 @@
 -->
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-js-api-interface">
-  <template></template>
   <script src="gr-change-actions-js-api.js"></script>
   <script src="gr-change-reply-js-api.js"></script>
   <script src="gr-js-api-interface.js"></script>
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 34ca728..70a9bf5 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
@@ -14,7 +14,7 @@
 (function() {
   'use strict';
 
-  var EventType = {
+  const EventType = {
     HISTORY: 'history',
     LABEL_CHANGE: 'labelchange',
     SHOW_CHANGE: 'showchange',
@@ -25,7 +25,7 @@
     POST_REVERT: 'postrevert',
   };
 
-  var Element = {
+  const Element = {
     CHANGE_ACTIONS: 'changeactions',
     REPLY_DIALOG: 'replydialog',
   };
@@ -44,11 +44,11 @@
       },
     },
 
-    Element: Element,
-    EventType: EventType,
+    Element,
+    EventType,
 
-    handleEvent: function(type, detail) {
-      Gerrit.awaitPluginsLoaded().then(function() {
+    handleEvent(type, detail) {
+      Gerrit.awaitPluginsLoaded().then(() => {
         switch (type) {
           case EventType.HISTORY:
             this._handleHistory(detail);
@@ -67,27 +67,27 @@
                 type);
             break;
         }
-      }.bind(this));
+      });
     },
 
-    addElement: function(key, el) {
+    addElement(key, el) {
       this._elements[key] = el;
     },
 
-    getElement: function(key) {
+    getElement(key) {
       return this._elements[key];
     },
 
-    addEventCallback: function(eventName, callback) {
+    addEventCallback(eventName, callback) {
       if (!this._eventCallbacks[eventName]) {
         this._eventCallbacks[eventName] = [];
       }
       this._eventCallbacks[eventName].push(callback);
     },
 
-    canSubmitChange: function(change, revision) {
-      var submitCallbacks = this._getEventCallbacks(EventType.SUBMIT_CHANGE);
-      var cancelSubmit = submitCallbacks.some(function(callback) {
+    canSubmitChange(change, revision) {
+      const submitCallbacks = this._getEventCallbacks(EventType.SUBMIT_CHANGE);
+      const cancelSubmit = submitCallbacks.some(callback => {
         try {
           return callback(change, revision) === false;
         } catch (err) {
@@ -99,28 +99,29 @@
       return !cancelSubmit;
     },
 
-    _removeEventCallbacks: function() {
-      for (var k in EventType) {
+    _removeEventCallbacks() {
+      for (const k in EventType) {
+        if (!EventType.hasOwnProperty(k)) { continue; }
         this._eventCallbacks[EventType[k]] = [];
       }
     },
 
-    _handleHistory: function(detail) {
-      this._getEventCallbacks(EventType.HISTORY).forEach(function(cb) {
+    _handleHistory(detail) {
+      for (const cb of this._getEventCallbacks(EventType.HISTORY)) {
         try {
           cb(detail.path);
         } catch (err) {
           console.error(err);
         }
-      });
+      }
     },
 
-    _handleShowChange: function(detail) {
-      this._getEventCallbacks(EventType.SHOW_CHANGE).forEach(function(cb) {
-        var change = detail.change;
-        var patchNum = detail.patchNum;
-        var revision;
-        for (var rev in change.revisions) {
+    _handleShowChange(detail) {
+      for (const cb of this._getEventCallbacks(EventType.SHOW_CHANGE)) {
+        const change = detail.change;
+        const patchNum = detail.patchNum;
+        let revision;
+        for (const rev in change.revisions) {
           if (change.revisions[rev]._number == patchNum) {
             revision = change.revisions[rev];
             break;
@@ -131,67 +132,63 @@
         } catch (err) {
           console.error(err);
         }
-      });
+      }
     },
 
-    handleCommitMessage: function(change, msg) {
-      this._getEventCallbacks(EventType.COMMIT_MSG_EDIT).forEach(
-          function(cb) {
-            try {
-              cb(change, msg);
-            } catch (err) {
-              console.error(err);
-            }
-          }
-      );
+    handleCommitMessage(change, msg) {
+      for (const cb of this._getEventCallbacks(EventType.COMMIT_MSG_EDIT)) {
+        try {
+          cb(change, msg);
+        } catch (err) {
+          console.error(err);
+        }
+      }
     },
 
-    _handleComment: function(detail) {
-      this._getEventCallbacks(EventType.COMMENT).forEach(function(cb) {
+    _handleComment(detail) {
+      for (const cb of this._getEventCallbacks(EventType.COMMENT)) {
         try {
           cb(detail.node);
         } catch (err) {
           console.error(err);
         }
-      });
+      }
     },
 
-    _handleLabelChange: function(detail) {
-      this._getEventCallbacks(EventType.LABEL_CHANGE).forEach(function(cb) {
+    _handleLabelChange(detail) {
+      for (const cb of this._getEventCallbacks(EventType.LABEL_CHANGE)) {
         try {
           cb(detail.change);
         } catch (err) {
           console.error(err);
         }
-      });
+      }
     },
 
-    modifyRevertMsg: function(change, revertMsg, origMsg) {
-      this._getEventCallbacks(EventType.REVERT).forEach(function(callback) {
+    modifyRevertMsg(change, revertMsg, origMsg) {
+      for (const cb of this._getEventCallbacks(EventType.REVERT)) {
         try {
-          revertMsg = callback(change, revertMsg, origMsg);
+          revertMsg = cb(change, revertMsg, origMsg);
         } catch (err) {
           console.error(err);
         }
-      });
+      }
       return revertMsg;
     },
 
-    getLabelValuesPostRevert: function(change) {
-      var labels = {};
-      this._getEventCallbacks(EventType.POST_REVERT).forEach(
-          function(callback) {
-            try {
-              labels = callback(change);
-            } catch (err) {
-              console.error(err);
-            }
-          }
-      );
+    getLabelValuesPostRevert(change) {
+      let labels = {};
+      for (const cb of this._getEventCallbacks(EventType.POST_REVERT)) {
+        try {
+          labels = cb(change);
+        } catch (err) {
+          console.error(err);
+        }
+      }
       return labels;
     },
 
-    _getEventCallbacks: function(type) {
+    _getEventCallbacks(type) {
       return this._eventCallbacks[type] || [];
     },
   });
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 d62bdd8..40810b1 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
@@ -31,45 +31,74 @@
 </test-fixture>
 
 <script>
-  suite('gr-js-api-interface tests', function() {
-    var element;
-    var plugin;
-    var errorStub;
-    var sandbox;
+  suite('gr-js-api-interface tests', () => {
+    let element;
+    let plugin;
+    let errorStub;
+    let sandbox;
+    let getResponseObjectStub;
+    let sendStub;
 
-    var throwErrFn = function() {
+    const throwErrFn = function() {
       throw Error('Unfortunately, this handler has stopped');
     };
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
+      getResponseObjectStub = sandbox.stub().returns(Promise.resolve());
+      sendStub = sandbox.stub().returns(Promise.resolve());
       stub('gr-rest-api-interface', {
-        getAccount: function() {
+        getAccount() {
           return Promise.resolve({name: 'Judy Hopps'});
         },
+        getResponseObject: getResponseObjectStub,
+        send(...args) {
+          return sendStub(...args);
+        },
       });
       element = fixture('basic');
       errorStub = sandbox.stub(console, 'error');
       Gerrit._setPluginsCount(1);
-      Gerrit.install(function(p) { plugin = p; }, '0.1',
+      Gerrit.install(p => { plugin = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/test.js');
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
       element._removeEventCallbacks();
       plugin = null;
     });
 
-    test('url', function() {
+    test('url', () => {
       assert.equal(plugin.url(), 'http://test.com/plugins/testplugin/');
       assert.equal(plugin.url('/static/test.js'),
           'http://test.com/plugins/testplugin/static/test.js');
     });
 
-    test('history event', function(done) {
+    test('get', done => {
+      const response = {foo: 'foo'};
+      getResponseObjectStub.returns(Promise.resolve(response));
+      plugin.get('/url', r => {
+        assert.isTrue(sendStub.calledWith('GET', '/url'));
+        assert.strictEqual(r, response);
+        done();
+      });
+    });
+
+    test('post', done => {
+      const payload = {foo: 'foo'};
+      const response = {bar: 'bar'};
+      getResponseObjectStub.returns(Promise.resolve(response));
+      plugin.post('/url', payload, r => {
+        assert.isTrue(sendStub.calledWith('POST', '/url', payload));
+        assert.strictEqual(r, response);
+        done();
+      });
+    });
+
+    test('history event', done => {
       plugin.on(element.EventType.HISTORY, throwErrFn);
-      plugin.on(element.EventType.HISTORY, function(path) {
+      plugin.on(element.EventType.HISTORY, path => {
         assert.equal(path, '/path/to/awesomesauce');
         assert.isTrue(errorStub.calledOnce);
         done();
@@ -78,13 +107,13 @@
           {path: '/path/to/awesomesauce'});
     });
 
-    test('showchange event', function(done) {
-      var testChange = {
+    test('showchange event', done => {
+      const testChange = {
         _number: 42,
         revisions: {def: {_number: 2}, abc: {_number: 1}},
       };
       plugin.on(element.EventType.SHOW_CHANGE, throwErrFn);
-      plugin.on(element.EventType.SHOW_CHANGE, function(change, revision) {
+      plugin.on(element.EventType.SHOW_CHANGE, (change, revision) => {
         assert.deepEqual(change, testChange);
         assert.deepEqual(revision, testChange.revisions.abc);
         assert.isTrue(errorStub.calledOnce);
@@ -94,28 +123,28 @@
           {change: testChange, patchNum: 1});
     });
 
-    test('handleEvent awaits plugins load', function(done) {
-      var testChange = {
+    test('handleEvent awaits plugins load', done => {
+      const testChange = {
         _number: 42,
         revisions: {def: {_number: 2}, abc: {_number: 1}},
       };
-      var spy = sandbox.spy();
+      const spy = sandbox.spy();
       Gerrit._setPluginsCount(1);
       plugin.on(element.EventType.SHOW_CHANGE, spy);
       element.handleEvent(element.EventType.SHOW_CHANGE,
           {change: testChange, patchNum: 1});
       assert.isFalse(spy.called);
       Gerrit._setPluginsCount(0);
-      flush(function() {
+      flush(() => {
         assert.isTrue(spy.called);
         done();
       });
     });
 
-    test('comment event', function(done) {
-      var testCommentNode = {foo: 'bar'};
+    test('comment event', done => {
+      const testCommentNode = {foo: 'bar'};
       plugin.on(element.EventType.COMMENT, throwErrFn);
-      plugin.on(element.EventType.COMMENT, function(commentNode) {
+      plugin.on(element.EventType.COMMENT, commentNode => {
         assert.deepEqual(commentNode, testCommentNode);
         assert.isTrue(errorStub.calledOnce);
         done();
@@ -123,7 +152,7 @@
       element.handleEvent(element.EventType.COMMENT, {node: testCommentNode});
     });
 
-    test('revert event', function() {
+    test('revert event', () => {
       function appendToRevertMsg(c, revertMsg, originalMsg) {
         return revertMsg + '\n' + originalMsg.replace(/^/gm, '> ') + '\ninfo';
       }
@@ -134,16 +163,16 @@
       plugin.on(element.EventType.REVERT, throwErrFn);
       plugin.on(element.EventType.REVERT, appendToRevertMsg);
       assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
-                   'test\n> origTest\ninfo');
+          'test\n> origTest\ninfo');
       assert.isTrue(errorStub.calledOnce);
 
       plugin.on(element.EventType.REVERT, appendToRevertMsg);
       assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
-                   'test\n> origTest\ninfo\n> origTest\ninfo');
+          'test\n> origTest\ninfo\n> origTest\ninfo');
       assert.isTrue(errorStub.calledTwice);
     });
 
-    test('postrevert event', function() {
+    test('postrevert event', () => {
       function getLabels(c) {
         return {'Code-Review': 1};
       }
@@ -158,10 +187,10 @@
       assert.isTrue(errorStub.calledOnce);
     });
 
-    test('commitmsgedit event', function(done) {
-      var testMsg = 'Test CL commit message';
+    test('commitmsgedit event', done => {
+      const testMsg = 'Test CL commit message';
       plugin.on(element.EventType.COMMIT_MSG_EDIT, throwErrFn);
-      plugin.on(element.EventType.COMMIT_MSG_EDIT, function(change, msg) {
+      plugin.on(element.EventType.COMMIT_MSG_EDIT, (change, msg) => {
         assert.deepEqual(msg, testMsg);
         assert.isTrue(errorStub.calledOnce);
         done();
@@ -169,10 +198,10 @@
       element.handleCommitMessage(null, testMsg);
     });
 
-    test('labelchange event', function(done) {
-      var testChange = {_number: 42};
+    test('labelchange event', done => {
+      const testChange = {_number: 42};
       plugin.on(element.EventType.LABEL_CHANGE, throwErrFn);
-      plugin.on(element.EventType.LABEL_CHANGE, function(change) {
+      plugin.on(element.EventType.LABEL_CHANGE, change => {
         assert.deepEqual(change, testChange);
         assert.isTrue(errorStub.calledOnce);
         done();
@@ -180,41 +209,41 @@
       element.handleEvent(element.EventType.LABEL_CHANGE, {change: testChange});
     });
 
-    test('submitchange', function() {
+    test('submitchange', () => {
       plugin.on(element.EventType.SUBMIT_CHANGE, throwErrFn);
-      plugin.on(element.EventType.SUBMIT_CHANGE, function() { return true; });
+      plugin.on(element.EventType.SUBMIT_CHANGE, () => { return true; });
       assert.isTrue(element.canSubmitChange());
       assert.isTrue(errorStub.calledOnce);
-      plugin.on(element.EventType.SUBMIT_CHANGE, function() { return false; });
-      plugin.on(element.EventType.SUBMIT_CHANGE, function() { return true; });
+      plugin.on(element.EventType.SUBMIT_CHANGE, () => { return false; });
+      plugin.on(element.EventType.SUBMIT_CHANGE, () => { return true; });
       assert.isFalse(element.canSubmitChange());
       assert.isTrue(errorStub.calledTwice);
     });
 
-    test('versioning', function() {
-      var callback = sandbox.spy();
+    test('versioning', () => {
+      const callback = sandbox.spy();
       Gerrit.install(callback, '0.0pre-alpha');
       assert(callback.notCalled);
     });
 
-    test('getAccount', function(done) {
-      Gerrit.getLoggedIn().then(function(loggedIn) {
+    test('getAccount', done => {
+      Gerrit.getLoggedIn().then(loggedIn => {
         assert.isTrue(loggedIn);
         done();
       });
     });
 
-    test('_setPluginsCount', function(done) {
+    test('_setPluginsCount', done => {
       stub('gr-reporting', {
-        pluginsLoaded: function() {
+        pluginsLoaded() {
           assert.equal(Gerrit._pluginsPending, 0);
           done();
-        }
+        },
       });
       Gerrit._setPluginsCount(0);
     });
 
-    test('_arePluginsLoaded', function() {
+    test('_arePluginsLoaded', () => {
       assert.isTrue(Gerrit._arePluginsLoaded());
       Gerrit._setPluginsCount(1);
       assert.isFalse(Gerrit._arePluginsLoaded());
@@ -222,12 +251,12 @@
       assert.isTrue(Gerrit._arePluginsLoaded());
     });
 
-    test('_pluginInstalled', function(done) {
+    test('_pluginInstalled', done => {
       stub('gr-reporting', {
-        pluginsLoaded: function() {
+        pluginsLoaded() {
           assert.equal(Gerrit._pluginsPending, 0);
           done();
-        }
+        },
       });
       Gerrit._setPluginsCount(2);
       Gerrit._pluginInstalled();
@@ -235,34 +264,34 @@
       Gerrit._pluginInstalled();
     });
 
-    test('install calls _pluginInstalled', function() {
+    test('install calls _pluginInstalled', () => {
       sandbox.stub(Gerrit, '_pluginInstalled');
-      Gerrit.install(function(p) { plugin = p; }, '0.1',
+      Gerrit.install(p => { plugin = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/test.js');
       assert.isTrue(Gerrit._pluginInstalled.calledOnce);
     });
 
-    test('install calls _pluginInstalled on error', function() {
+    test('install calls _pluginInstalled on error', () => {
       sandbox.stub(Gerrit, '_pluginInstalled');
-      Gerrit.install(function() {}, '0.0pre-alpha');
+      Gerrit.install(() => {}, '0.0pre-alpha');
       assert.isTrue(Gerrit._pluginInstalled.calledOnce);
     });
 
-    test('installGwt calls _pluginInstalled', function() {
+    test('installGwt calls _pluginInstalled', () => {
       sandbox.stub(Gerrit, '_pluginInstalled');
       Gerrit.installGwt();
       assert.isTrue(Gerrit._pluginInstalled.calledOnce);
     });
 
-    test('installGwt returns a stub object', function() {
-      var plugin = Gerrit.installGwt();
+    test('installGwt returns a stub object', () => {
+      const plugin = Gerrit.installGwt();
       sandbox.stub(console, 'warn');
       assert.isAbove(Object.keys(plugin).length, 0);
-      Object.keys(plugin).forEach(function(name) {
+      for (const name of Object.keys(plugin)) {
         console.warn.reset();
         plugin[name]();
         assert.isTrue(console.warn.calledOnce);
-      });
+      }
     });
   });
 </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 b3ae649..0811935 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
@@ -14,17 +14,25 @@
 (function(window) {
   'use strict';
 
-  var warnNotSupported = function(opt_name) {
+  const warnNotSupported = function(opt_name) {
     console.warn('Plugin API method ' + (opt_name || '') + ' is not supported');
   };
 
-  var stubbedMethods = ['_loadedGwt', 'screen', 'settingsScreen', 'panel'];
-  var GWT_PLUGIN_STUB = {};
-  stubbedMethods.forEach(function(name) {
+  const stubbedMethods = ['_loadedGwt', 'screen', 'settingsScreen', 'panel'];
+  const GWT_PLUGIN_STUB = {};
+  for (const name of stubbedMethods) {
     GWT_PLUGIN_STUB[name] = warnNotSupported.bind(null, name);
-  });
+  }
 
-  var API_VERSION = '0.1';
+  let _restAPI;
+  const getRestAPI = () => {
+    if (!_restAPI) {
+      _restAPI = document.createElement('gr-rest-api-interface');
+    }
+    return _restAPI;
+  };
+
+  const API_VERSION = '0.1';
 
   // GWT JSNI uses $wnd to refer to window.
   // http://www.gwtproject.org/doc/latest/DevGuideCodingBasicsJSNI.html
@@ -38,7 +46,7 @@
     }
 
     this._url = new URL(opt_url);
-    if (this._url.pathname.indexOf('/plugins') !== 0) {
+    if (!this._url.pathname.startsWith('/plugins')) {
       console.warn('Plugin not being loaded from /plugins base path:',
           this._url.href, '— Unable to determine name.');
       return;
@@ -54,6 +62,17 @@
     return this._name;
   };
 
+  Plugin.prototype.registerStyleModule = function(stylingEndpointName,
+      moduleName) {
+    if (!Gerrit._styleModules[stylingEndpointName]) {
+      Gerrit._styleModules[stylingEndpointName] = [];
+    }
+    Gerrit._styleModules[stylingEndpointName].push({
+      pluginUrl: this._url,
+      moduleName,
+    });
+  };
+
   Plugin.prototype.getServerInfo = function() {
     return document.createElement('gr-rest-api-interface').getConfig();
   };
@@ -66,6 +85,20 @@
     return this._url.origin + '/plugins/' + this._name + (opt_path || '/');
   };
 
+  Plugin.prototype._send = function(method, url, callback, opt_payload) {
+    return getRestAPI().send(method, url, opt_payload)
+        .then(getRestAPI().getResponseObject)
+        .then(callback);
+  };
+
+  Plugin.prototype.get = function(url, callback) {
+    return this._send('GET', url, callback);
+  },
+
+  Plugin.prototype.post = function(url, payload, callback) {
+    return this._send('POST', url, callback, payload);
+  },
+
   Plugin.prototype.changeActions = function() {
     return new GrChangeActionsInterface(Plugin._sharedAPIElement.getElement(
         Plugin._sharedAPIElement.Element.CHANGE_ACTIONS));
@@ -76,11 +109,14 @@
         Plugin._sharedAPIElement.Element.REPLY_DIALOG));
   };
 
-  var Gerrit = window.Gerrit || {};
+  const Gerrit = window.Gerrit || {};
 
   // Number of plugins to initialize, -1 means 'not yet known'.
   Gerrit._pluginsPending = -1;
 
+  // Hash of style modules to be applied, insertion point to shared style name.
+  Gerrit._styleModules = {};
+
   Gerrit.getPluginName = function() {
     console.warn('Gerrit.getPluginName is not supported in PolyGerrit.',
         'Please use self.getPluginName() instead.');
@@ -88,12 +124,13 @@
 
   Gerrit.css = function(rulesStr) {
     if (!Gerrit._customStyleSheet) {
-      var styleEl = document.createElement('style');
+      const styleEl = document.createElement('style');
       document.head.appendChild(styleEl);
       Gerrit._customStyleSheet = styleEl.sheet;
     }
 
-    var name = '__pg_js_api_class_' + Gerrit._customStyleSheet.cssRules.length;
+    const name = '__pg_js_api_class_' +
+        Gerrit._customStyleSheet.cssRules.length;
     Gerrit._customStyleSheet.insertRule('.' + name + '{' + rulesStr + '}', 0);
     return name;
   };
@@ -107,8 +144,9 @@
     }
 
     // TODO(andybons): Polyfill currentScript for IE10/11 (edge supports it).
-    var src = opt_src || (document.currentScript && document.currentScript.src);
-    var plugin = new Plugin(src);
+    const src = opt_src || (document.currentScript &&
+         document.currentScript.src || document.currentScript.baseURI);
+    const plugin = new Plugin(src);
     try {
       callback(plugin);
     } catch (e) {
@@ -140,7 +178,7 @@
       if (Gerrit._arePluginsLoaded()) {
         Gerrit._allPluginsPromise = Promise.resolve();
       } else {
-        Gerrit._allPluginsPromise = new Promise(function(resolve) {
+        Gerrit._allPluginsPromise = new Promise(resolve => {
           Gerrit._resolveAllPluginsLoaded = resolve;
         });
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
index c6a5e4e..bfba1c4 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
@@ -30,11 +30,11 @@
       },
     },
 
-    _getBackgroundClass: function(transparent) {
+    _getBackgroundClass(transparent) {
       return transparent ? 'transparentBackground' : '';
     },
 
-    _handleRemoveTap: function(e) {
+    _handleRemoveTap(e) {
       e.preventDefault();
       this.fire('remove');
     },
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html
index eefc79d..3182653 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html
@@ -34,21 +34,21 @@
 </test-fixture>
 
 <script>
-  suite('gr-linked-chip tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-linked-chip tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('remove fired', function() {
-      var spy = sandbox.spy();
+    test('remove fired', () => {
+      const spy = sandbox.spy();
       element.addEventListener('remove', spy);
       flushAsynchronousOperations();
       MockInteractions.tap(element.$.remove);
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
index 9f271ed..8db8004 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
@@ -14,8 +14,8 @@
 (function() {
   'use strict';
 
-  var AWAIT_MAX_ITERS = 10;
-  var AWAIT_STEP = 5;
+  const AWAIT_MAX_ITERS = 10;
+  const AWAIT_STEP = 5;
 
   Polymer({
     is: 'gr-overlay',
@@ -24,17 +24,17 @@
       Polymer.IronOverlayBehavior,
     ],
 
-    open: function() {
-      return new Promise(function(resolve) {
-        Polymer.IronOverlayBehaviorImpl.open.apply(this, arguments);
+    open(...args) {
+      return new Promise(resolve => {
+        Polymer.IronOverlayBehaviorImpl.open.apply(this, args);
         this._awaitOpen(resolve);
-      }.bind(this));
+      });
     },
 
     /**
      * Override the focus stops that iron-overlay-behavior tries to find.
      */
-    setFocusStops: function(stops) {
+    setFocusStops(stops) {
       this.__firstFocusableNode = stops.start;
       this.__lastFocusableNode = stops.end;
     },
@@ -43,21 +43,21 @@
      * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
      * opening. Eventually replace with a direct way to listen to the overlay.
      */
-    _awaitOpen: function(fn) {
-      var iters = 0;
-      var step = function() {
-        this.async(function() {
+    _awaitOpen(fn) {
+      let iters = 0;
+      const step = () => {
+        this.async(() => {
           if (this.style.display !== 'none') {
             fn.call(this);
           } else if (iters++ < AWAIT_MAX_ITERS) {
             step.call(this);
           }
-        }.bind(this), AWAIT_STEP);
-      }.bind(this);
+        }, AWAIT_STEP);
+      };
       step.call(this);
     },
 
-    _id: function() {
+    _id() {
       return this.getAttribute('id') || 'global';
     },
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index 512c055..e371201 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -14,20 +14,20 @@
 (function() {
   'use strict';
 
-  var DiffViewMode = {
+  const DiffViewMode = {
     SIDE_BY_SIDE: 'SIDE_BY_SIDE',
     UNIFIED: 'UNIFIED_DIFF',
   };
-  var JSON_PREFIX = ')]}\'';
-  var MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 900;
-  var PARENT_PATCH_NUM = 'PARENT';
+  const JSON_PREFIX = ')]}\'';
+  const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 900;
+  const PARENT_PATCH_NUM = 'PARENT';
 
-  var Requests = {
+  const Requests = {
     SEND_DIFF_DRAFT: 'sendDiffDraft',
   };
 
   // Must be kept in sync with the ListChangesOption enum and protobuf.
-  var ListChangesOption = {
+  const ListChangesOption = {
     LABELS: 0,
     DETAILED_LABELS: 8,
 
@@ -115,20 +115,20 @@
       },
     },
 
-    fetchJSON: function(url, opt_errFn, opt_cancelCondition, opt_params,
+    fetchJSON(url, opt_errFn, opt_cancelCondition, opt_params,
         opt_opts) {
       opt_opts = opt_opts || {};
       // Issue 5715, This can be reverted back once
       // iOS 10.3 and mac os 10.12.4 has the fetch api fix.
-      var fetchOptions = {
-        credentials: 'same-origin'
+      const fetchOptions = {
+        credentials: 'same-origin',
       };
       if (opt_opts.headers !== undefined) {
         fetchOptions['headers'] = opt_opts.headers;
       }
 
-      var urlWithParams = this._urlWithParams(url, opt_params);
-      return fetch(urlWithParams, fetchOptions).then(function(response) {
+      const urlWithParams = this._urlWithParams(url, opt_params);
+      return fetch(urlWithParams, fetchOptions).then(response => {
         if (opt_cancelCondition && opt_cancelCondition()) {
           response.body.cancel();
           return;
@@ -139,12 +139,12 @@
             opt_errFn.call(null, response);
             return;
           }
-          this.fire('server-error', {response: response});
+          this.fire('server-error', {response});
           return;
         }
 
         return this.getResponseObject(response);
-      }.bind(this)).catch(function(err) {
+      }).catch(err => {
         if (opt_errFn) {
           opt_errFn.call(null, null, err);
         } else {
@@ -152,31 +152,28 @@
           throw err;
         }
         throw err;
-      }.bind(this));
+      });
     },
 
-    _urlWithParams: function(url, opt_params) {
+    _urlWithParams(url, opt_params) {
       if (!opt_params) { return this.getBaseUrl() + url; }
 
-      var params = [];
-      for (var p in opt_params) {
+      const params = [];
+      for (const p in opt_params) {
         if (opt_params[p] == null) {
           params.push(encodeURIComponent(p));
           continue;
         }
-        var values = [].concat(opt_params[p]);
-        for (var i = 0; i < values.length; i++) {
-          params.push(
-            encodeURIComponent(p) + '=' +
-            encodeURIComponent(values[i]));
+        for (const value of [].concat(opt_params[p])) {
+          params.push(`${encodeURIComponent(p)}=${encodeURIComponent(value)}`);
         }
       }
       return this.getBaseUrl() + url + '?' + params.join('&');
     },
 
-    getResponseObject: function(response) {
-      return response.text().then(function(text) {
-        var result;
+    getResponseObject(response) {
+      return response.text().then(text => {
+        let result;
         try {
           result = JSON.parse(text.substring(JSON_PREFIX.length));
         } catch (_) {
@@ -186,21 +183,21 @@
       });
     },
 
-    getConfig: function() {
+    getConfig() {
       return this._fetchSharedCacheURL('/config/server/info');
     },
 
-    getProjectConfig: function(project) {
+    getProjectConfig(project) {
       return this._fetchSharedCacheURL(
           '/projects/' + encodeURIComponent(project) + '/config');
     },
 
-    getVersion: function() {
+    getVersion() {
       return this._fetchSharedCacheURL('/config/server/version');
     },
 
-    getDiffPreferences: function() {
-      return this.getLoggedIn().then(function(loggedIn) {
+    getDiffPreferences() {
+      return this.getLoggedIn().then(loggedIn => {
         if (loggedIn) {
           return this._fetchSharedCacheURL('/accounts/self/preferences.diff');
         }
@@ -224,10 +221,10 @@
           tab_size: 8,
           theme: 'DEFAULT',
         });
-      }.bind(this));
+      });
     },
 
-    savePreferences: function(prefs, opt_errFn, opt_ctx) {
+    savePreferences(prefs, opt_errFn, opt_ctx) {
       // Note (Issue 5142): normalize the download scheme with lower case before
       // saving.
       if (prefs.download_scheme) {
@@ -238,127 +235,139 @@
           opt_ctx);
     },
 
-    saveDiffPreferences: function(prefs, opt_errFn, opt_ctx) {
+    saveDiffPreferences(prefs, opt_errFn, opt_ctx) {
       // Invalidate the cache.
       this._cache['/accounts/self/preferences.diff'] = undefined;
       return this.send('PUT', '/accounts/self/preferences.diff', prefs,
           opt_errFn, opt_ctx);
     },
 
-    getAccount: function() {
-      return this._fetchSharedCacheURL('/accounts/self/detail', function(resp) {
+    getAccount() {
+      return this._fetchSharedCacheURL('/accounts/self/detail', resp => {
         if (resp.status === 403) {
           this._cache['/accounts/self/detail'] = null;
         }
-      }.bind(this));
+      });
     },
 
-    getAccountEmails: function() {
+    getAccountEmails() {
       return this._fetchSharedCacheURL('/accounts/self/emails');
     },
 
-    addAccountEmail: function(email, opt_errFn, opt_ctx) {
+    addAccountEmail(email, opt_errFn, opt_ctx) {
       return this.send('PUT', '/accounts/self/emails/' +
           encodeURIComponent(email), null, opt_errFn, opt_ctx);
     },
 
-    deleteAccountEmail: function(email, opt_errFn, opt_ctx) {
+    deleteAccountEmail(email, opt_errFn, opt_ctx) {
       return this.send('DELETE', '/accounts/self/emails/' +
           encodeURIComponent(email), null, opt_errFn, opt_ctx);
     },
 
-    setPreferredAccountEmail: function(email, opt_errFn, opt_ctx) {
+    setPreferredAccountEmail(email, opt_errFn, opt_ctx) {
       return this.send('PUT', '/accounts/self/emails/' +
           encodeURIComponent(email) + '/preferred', null,
-          opt_errFn, opt_ctx).then(function() {
-        // If result of getAccountEmails is in cache, update it in the cache
-        // so we don't have to invalidate it.
-        var cachedEmails = this._cache['/accounts/self/emails'];
-        if (cachedEmails) {
-          var emails = cachedEmails.map(function(entry) {
-            if (entry.email === email) {
-              return {email: email, preferred: true};
-            } else {
-              return {email: email};
+          opt_errFn, opt_ctx).then(() => {
+            // If result of getAccountEmails is in cache, update it in the cache
+            // so we don't have to invalidate it.
+            const cachedEmails = this._cache['/accounts/self/emails'];
+            if (cachedEmails) {
+              const emails = cachedEmails.map(entry => {
+                if (entry.email === email) {
+                  return {email, preferred: true};
+                } else {
+                  return {email};
+                }
+              });
+              this._cache['/accounts/self/emails'] = emails;
             }
           });
-          this._cache['/accounts/self/emails'] = emails;
-        }
-      }.bind(this));
     },
 
-    setAccountName: function(name, opt_errFn, opt_ctx) {
-      return this.send('PUT', '/accounts/self/name', {name: name}, opt_errFn,
-          opt_ctx).then(function(response) {
+    setAccountName(name, opt_errFn, opt_ctx) {
+      return this.send('PUT', '/accounts/self/name', {name}, opt_errFn,
+          opt_ctx).then(response => {
             // If result of getAccount is in cache, update it in the cache
             // so we don't have to invalidate it.
-            var cachedAccount = this._cache['/accounts/self/detail'];
+            const cachedAccount = this._cache['/accounts/self/detail'];
             if (cachedAccount) {
-              return this.getResponseObject(response).then(function(newName) {
+              return this.getResponseObject(response).then(newName => {
                 // Replace object in cache with new object to force UI updates.
                 // TODO(logan): Polyfill for Object.assign in IE
                 this._cache['/accounts/self/detail'] = Object.assign(
                     {}, cachedAccount, {name: newName});
-              }.bind(this));
+              });
             }
-          }.bind(this));
+          });
     },
 
-    setAccountStatus: function(status, opt_errFn, opt_ctx) {
-      return this.send('PUT', '/accounts/self/status', {status: status},
-          opt_errFn, opt_ctx).then(function(response) {
+    setAccountStatus(status, opt_errFn, opt_ctx) {
+      return this.send('PUT', '/accounts/self/status', {status},
+          opt_errFn, opt_ctx).then(response => {
             // If result of getAccount is in cache, update it in the cache
             // so we don't have to invalidate it.
-            var cachedAccount = this._cache['/accounts/self/detail'];
+            const cachedAccount = this._cache['/accounts/self/detail'];
             if (cachedAccount) {
-              return this.getResponseObject(response).then(function(newStatus) {
+              return this.getResponseObject(response).then(newStatus => {
                 // Replace object in cache with new object to force UI updates.
                 // TODO(logan): Polyfill for Object.assign in IE
                 this._cache['/accounts/self/detail'] = Object.assign(
                     {}, cachedAccount, {status: newStatus});
-              }.bind(this));
+              });
             }
-          }.bind(this));
+          });
     },
 
-    getAccountGroups: function() {
+    getAccountGroups() {
       return this._fetchSharedCacheURL('/accounts/self/groups');
     },
 
-    getAccountCapabilities: function(opt_params) {
-      var queryString = '';
+    getAccountCapabilities(opt_params) {
+      let queryString = '';
       if (opt_params) {
         queryString = '?q=' + opt_params
-            .map(function(param) { return encodeURIComponent(param); })
+            .map(param => { return encodeURIComponent(param); })
             .join('&q=');
       }
       return this._fetchSharedCacheURL('/accounts/self/capabilities' +
           queryString);
     },
 
-    getLoggedIn: function() {
-      return this.getAccount().then(function(account) {
+    getLoggedIn() {
+      return this.getAccount().then(account => {
         return account != null;
       });
     },
 
-    checkCredentials: function() {
+    getIsAdmin() {
+      return this.getLoggedIn().then(isLoggedIn => {
+        if (isLoggedIn) {
+          return this.getAccountCapabilities();
+        } else {
+          return Promise.resolve();
+        }
+      }).then(capabilities => {
+        return capabilities && capabilities.administrateServer;
+      });
+    },
+
+    checkCredentials() {
       // Skip the REST response cache.
       return this.fetchJSON('/accounts/self/detail');
     },
 
-    getPreferences: function() {
-      return this.getLoggedIn().then(function(loggedIn) {
+    getPreferences() {
+      return this.getLoggedIn().then(loggedIn => {
         if (loggedIn) {
           return this._fetchSharedCacheURL('/accounts/self/preferences').then(
-              function(res) {
-            if (this._isNarrowScreen()) {
-              res.default_diff_view = DiffViewMode.UNIFIED;
-            } else {
-              res.default_diff_view = res.diff_view;
-            }
-            return Promise.resolve(res);
-          }.bind(this));
+              res => {
+                if (this._isNarrowScreen()) {
+                  res.default_diff_view = DiffViewMode.UNIFIED;
+                } else {
+                  res.default_diff_view = res.diff_view;
+                }
+                return Promise.resolve(res);
+              });
         }
 
         return Promise.resolve({
@@ -367,27 +376,27 @@
               DiffViewMode.UNIFIED : DiffViewMode.SIDE_BY_SIDE,
           diff_view: 'SIDE_BY_SIDE',
         });
-      }.bind(this));
+      });
     },
 
-    getWatchedProjects: function() {
+    getWatchedProjects() {
       return this._fetchSharedCacheURL('/accounts/self/watched.projects');
     },
 
-    saveWatchedProjects: function(projects, opt_errFn, opt_ctx) {
+    saveWatchedProjects(projects, opt_errFn, opt_ctx) {
       return this.send('POST', '/accounts/self/watched.projects', projects,
           opt_errFn, opt_ctx)
-          .then(function(response) {
+          .then(response => {
             return this.getResponseObject(response);
-          }.bind(this));
+          });
     },
 
-    deleteWatchedProjects: function(projects, opt_errFn, opt_ctx) {
+    deleteWatchedProjects(projects, opt_errFn, opt_ctx) {
       return this.send('POST', '/accounts/self/watched.projects:delete',
           projects, opt_errFn, opt_ctx);
     },
 
-    _fetchSharedCacheURL: function(url, opt_errFn) {
+    _fetchSharedCacheURL(url, opt_errFn) {
       if (this._sharedFetchPromises[url]) {
         return this._sharedFetchPromises[url];
       }
@@ -396,25 +405,25 @@
         return Promise.resolve(this._cache[url]);
       }
       this._sharedFetchPromises[url] = this.fetchJSON(url, opt_errFn).then(
-        function(response) {
-          if (response !== undefined) {
-            this._cache[url] = response;
-          }
-          this._sharedFetchPromises[url] = undefined;
-          return response;
-        }.bind(this)).catch(function(err) {
-          this._sharedFetchPromises[url] = undefined;
-          throw err;
-        }.bind(this));
+          response => {
+            if (response !== undefined) {
+              this._cache[url] = response;
+            }
+            this._sharedFetchPromises[url] = undefined;
+            return response;
+          }).catch(err => {
+            this._sharedFetchPromises[url] = undefined;
+            throw err;
+          });
       return this._sharedFetchPromises[url];
     },
 
-    _isNarrowScreen: function() {
+    _isNarrowScreen() {
       return window.innerWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX;
     },
 
-    getChanges: function(changesPerPage, opt_query, opt_offset) {
-      var options = this._listChangesOptionsToHex(
+    getChanges(changesPerPage, opt_query, opt_offset) {
+      const options = this._listChangesOptionsToHex(
           ListChangesOption.LABELS,
           ListChangesOption.DETAILED_ACCOUNTS
       );
@@ -422,7 +431,7 @@
       if (opt_offset === 'n,z') {
         opt_offset = 0;
       }
-      var params = {
+      const params = {
         n: changesPerPage,
         O: options,
         S: opt_offset || 0,
@@ -433,17 +442,17 @@
       return this.fetchJSON('/changes/', null, null, params);
     },
 
-    getDashboardChanges: function() {
-      var options = this._listChangesOptionsToHex(
+    getDashboardChanges() {
+      const options = this._listChangesOptionsToHex(
           ListChangesOption.LABELS,
           ListChangesOption.DETAILED_ACCOUNTS,
           ListChangesOption.REVIEWED
       );
-      var params = {
+      const params = {
         O: options,
         q: [
           'is:open owner:self',
-          'is:open ((reviewer:self -owner:self -star:ignore) OR assignee:self)',
+          'is:open ((reviewer:self -owner:self -is:ignored) OR assignee:self)',
           'is:closed (owner:self OR reviewer:self OR assignee:self) -age:4w ' +
             'limit:10',
         ],
@@ -451,12 +460,12 @@
       return this.fetchJSON('/changes/', null, null, params);
     },
 
-    getChangeActionURL: function(changeNum, opt_patchNum, endpoint) {
+    getChangeActionURL(changeNum, opt_patchNum, endpoint) {
       return this._changeBaseURL(changeNum, opt_patchNum) + endpoint;
     },
 
-    getChangeDetail: function(changeNum, opt_errFn, opt_cancelCondition) {
-      var options = this._listChangesOptionsToHex(
+    getChangeDetail(changeNum, opt_errFn, opt_cancelCondition) {
+      const options = this._listChangesOptionsToHex(
           ListChangesOption.ALL_REVISIONS,
           ListChangesOption.CHANGE_ACTIONS,
           ListChangesOption.CURRENT_ACTIONS,
@@ -467,18 +476,18 @@
       );
       return this._getChangeDetail(
           changeNum, options, opt_errFn, opt_cancelCondition)
-            .then(GrReviewerUpdatesParser.parse);
+          .then(GrReviewerUpdatesParser.parse);
     },
 
-    getDiffChangeDetail: function(changeNum, opt_errFn, opt_cancelCondition) {
-      var options = this._listChangesOptionsToHex(
+    getDiffChangeDetail(changeNum, opt_errFn, opt_cancelCondition) {
+      const options = this._listChangesOptionsToHex(
           ListChangesOption.ALL_REVISIONS
       );
       return this._getChangeDetail(changeNum, options, opt_errFn,
           opt_cancelCondition);
     },
 
-    _getChangeDetail: function(changeNum, options, opt_errFn,
+    _getChangeDetail(changeNum, options, opt_errFn,
         opt_cancelCondition) {
       return this.fetchJSON(
           this.getChangeActionURL(changeNum, null, '/detail'),
@@ -487,13 +496,13 @@
           {O: options});
     },
 
-    getChangeCommitInfo: function(changeNum, patchNum) {
+    getChangeCommitInfo(changeNum, patchNum) {
       return this.fetchJSON(
           this.getChangeActionURL(changeNum, patchNum, '/commit?links'));
     },
 
-    getChangeFiles: function(changeNum, patchRange) {
-      var endpoint = '/files';
+    getChangeFiles(changeNum, patchRange) {
+      let endpoint = '/files';
       if (patchRange.basePatchNum !== 'PARENT') {
         endpoint += '?base=' + encodeURIComponent(patchRange.basePatchNum);
       }
@@ -501,23 +510,23 @@
           this.getChangeActionURL(changeNum, patchRange.patchNum, endpoint));
     },
 
-    getChangeFilesAsSpeciallySortedArray: function(changeNum, patchRange) {
+    getChangeFilesAsSpeciallySortedArray(changeNum, patchRange) {
       return this.getChangeFiles(changeNum, patchRange).then(
           this._normalizeChangeFilesResponse.bind(this));
     },
 
-    getChangeFilePathsAsSpeciallySortedArray: function(changeNum, patchRange) {
-      return this.getChangeFiles(changeNum, patchRange).then(function(files) {
+    getChangeFilePathsAsSpeciallySortedArray(changeNum, patchRange) {
+      return this.getChangeFiles(changeNum, patchRange).then(files => {
         return Object.keys(files).sort(this.specialFilePathCompare);
-      }.bind(this));
+      });
     },
 
-    _normalizeChangeFilesResponse: function(response) {
+    _normalizeChangeFilesResponse(response) {
       if (!response) { return []; }
-      var paths = Object.keys(response).sort(this.specialFilePathCompare);
-      var files = [];
-      for (var i = 0; i < paths.length; i++) {
-        var info = response[paths[i]];
+      const paths = Object.keys(response).sort(this.specialFilePathCompare);
+      const files = [];
+      for (let i = 0; i < paths.length; i++) {
+        const info = response[paths[i]];
         info.__path = paths[i];
         info.lines_inserted = info.lines_inserted || 0;
         info.lines_deleted = info.lines_deleted || 0;
@@ -526,64 +535,72 @@
       return files;
     },
 
-    getChangeRevisionActions: function(changeNum, patchNum) {
+    getChangeRevisionActions(changeNum, patchNum) {
       return this.fetchJSON(
           this.getChangeActionURL(changeNum, patchNum, '/actions')).then(
-              function(revisionActions) {
+          revisionActions => {
                 // The rebase button on change screen is always enabled.
-                if (revisionActions.rebase) {
-                  revisionActions.rebase.rebaseOnCurrent =
+            if (revisionActions.rebase) {
+              revisionActions.rebase.rebaseOnCurrent =
                       !!revisionActions.rebase.enabled;
-                  revisionActions.rebase.enabled = true;
-                }
-                return revisionActions;
-              });
+              revisionActions.rebase.enabled = true;
+            }
+            return revisionActions;
+          });
     },
 
-    getChangeSuggestedReviewers: function(changeNum, inputVal, opt_errFn,
+    getChangeSuggestedReviewers(changeNum, inputVal, opt_errFn,
         opt_ctx) {
-      var url = this.getChangeActionURL(changeNum, null, '/suggest_reviewers');
+      const url =
+          this.getChangeActionURL(changeNum, null, '/suggest_reviewers');
       return this.fetchJSON(url, opt_errFn, opt_ctx, {
         n: 10,  // Return max 10 results
         q: inputVal,
       });
     },
 
-    getSuggestedGroups: function(inputVal, opt_n, opt_errFn, opt_ctx) {
-      var params = {s: inputVal};
+    getProjects(projectsPerPage, opt_offset) {
+      const offset = opt_offset || 0;
+      return this._fetchSharedCacheURL(
+          `/projects/?d&n=${projectsPerPage + 1}&S=${offset}`
+      );
+    },
+
+    getSuggestedGroups(inputVal, opt_n, opt_errFn, opt_ctx) {
+      const params = {s: inputVal};
       if (opt_n) { params.n = opt_n; }
       return this.fetchJSON('/groups/', opt_errFn, opt_ctx, params);
     },
 
-    getSuggestedProjects: function(inputVal, opt_n, opt_errFn, opt_ctx) {
-      var params = {p: inputVal};
+    getSuggestedProjects(inputVal, opt_n, opt_errFn, opt_ctx) {
+      const params = {p: inputVal};
       if (opt_n) { params.n = opt_n; }
       return this.fetchJSON('/projects/', opt_errFn, opt_ctx, params);
     },
 
-    getSuggestedAccounts: function(inputVal, opt_n, opt_errFn, opt_ctx) {
-      var params = {q: inputVal, suggest: null};
+    getSuggestedAccounts(inputVal, opt_n, opt_errFn, opt_ctx) {
+      const params = {q: inputVal, suggest: null};
       if (opt_n) { params.n = opt_n; }
       return this.fetchJSON('/accounts/', opt_errFn, opt_ctx, params);
     },
 
-    addChangeReviewer: function(changeNum, reviewerID) {
+    addChangeReviewer(changeNum, reviewerID) {
       return this._sendChangeReviewerRequest('POST', changeNum, reviewerID);
     },
 
-    removeChangeReviewer: function(changeNum, reviewerID) {
+    removeChangeReviewer(changeNum, reviewerID) {
       return this._sendChangeReviewerRequest('DELETE', changeNum, reviewerID);
     },
 
-    _sendChangeReviewerRequest: function(method, changeNum, reviewerID) {
-      var url = this.getChangeActionURL(changeNum, null, '/reviewers');
-      var body;
+    _sendChangeReviewerRequest(method, changeNum, reviewerID) {
+      let url = this.getChangeActionURL(changeNum, null, '/reviewers');
+      let body;
       switch (method) {
         case 'POST':
           body = {reviewer: reviewerID};
           break;
         case 'DELETE':
-          url += '/' + reviewerID;
+          url += '/' + encodeURIComponent(reviewerID);
           break;
         default:
           throw Error('Unsupported HTTP method: ' + method);
@@ -592,124 +609,122 @@
       return this.send(method, url, body);
     },
 
-    getRelatedChanges: function(changeNum, patchNum) {
+    getRelatedChanges(changeNum, patchNum) {
       return this.fetchJSON(
           this.getChangeActionURL(changeNum, patchNum, '/related'));
     },
 
-    getChangesSubmittedTogether: function(changeNum) {
+    getChangesSubmittedTogether(changeNum) {
       return this.fetchJSON(
           this.getChangeActionURL(changeNum, null, '/submitted_together'));
     },
 
-    getChangeConflicts: function(changeNum) {
-      var options = this._listChangesOptionsToHex(
+    getChangeConflicts(changeNum) {
+      const options = this._listChangesOptionsToHex(
           ListChangesOption.CURRENT_REVISION,
           ListChangesOption.CURRENT_COMMIT
       );
-      var params = {
+      const params = {
         O: options,
         q: 'status:open is:mergeable conflicts:' + changeNum,
       };
       return this.fetchJSON('/changes/', null, null, params);
     },
 
-    getChangeCherryPicks: function(project, changeID, changeNum) {
-      var options = this._listChangesOptionsToHex(
+    getChangeCherryPicks(project, changeID, changeNum) {
+      const options = this._listChangesOptionsToHex(
           ListChangesOption.CURRENT_REVISION,
           ListChangesOption.CURRENT_COMMIT
       );
-      var query = [
+      const query = [
         'project:' + project,
         'change:' + changeID,
         '-change:' + changeNum,
         '-is:abandoned',
       ].join(' ');
-      var params = {
+      const params = {
         O: options,
         q: query,
       };
       return this.fetchJSON('/changes/', null, null, params);
     },
 
-    getChangesWithSameTopic: function(topic) {
-      var options = this._listChangesOptionsToHex(
+    getChangesWithSameTopic(topic) {
+      const options = this._listChangesOptionsToHex(
           ListChangesOption.LABELS,
           ListChangesOption.CURRENT_REVISION,
           ListChangesOption.CURRENT_COMMIT,
           ListChangesOption.DETAILED_LABELS
       );
-      var params = {
+      const params = {
         O: options,
         q: 'status:open topic:' + topic,
       };
       return this.fetchJSON('/changes/', null, null, params);
     },
 
-    getReviewedFiles: function(changeNum, patchNum) {
+    getReviewedFiles(changeNum, patchNum) {
       return this.fetchJSON(
           this.getChangeActionURL(changeNum, patchNum, '/files?reviewed'));
     },
 
-    saveFileReviewed: function(changeNum, patchNum, path, reviewed, opt_errFn,
-        opt_ctx) {
-      var method = reviewed ? 'PUT' : 'DELETE';
-      var url = this.getChangeActionURL(changeNum, patchNum,
+    saveFileReviewed(changeNum, patchNum, path, reviewed, opt_errFn, opt_ctx) {
+      const method = reviewed ? 'PUT' : 'DELETE';
+      const url = this.getChangeActionURL(changeNum, patchNum,
           '/files/' + encodeURIComponent(path) + '/reviewed');
 
       return this.send(method, url, null, opt_errFn, opt_ctx);
     },
 
-    saveChangeReview: function(changeNum, patchNum, review, opt_errFn,
-        opt_ctx) {
-      var url = this.getChangeActionURL(changeNum, patchNum, '/review');
+    saveChangeReview(changeNum, patchNum, review, opt_errFn, opt_ctx) {
+      const url = this.getChangeActionURL(changeNum, patchNum, '/review');
       return this.send('POST', url, review, opt_errFn, opt_ctx);
     },
 
-    getFileInChangeEdit: function(changeNum, path) {
+    getFileInChangeEdit(changeNum, path) {
       return this.send('GET',
           this.getChangeActionURL(changeNum, null,
               '/edit/' + encodeURIComponent(path)
           ));
     },
 
-    rebaseChangeEdit: function(changeNum) {
+    rebaseChangeEdit(changeNum) {
       return this.send('POST',
           this.getChangeActionURL(changeNum, null,
               '/edit:rebase'
           ));
     },
 
-    deleteChangeEdit: function(changeNum) {
+    deleteChangeEdit(changeNum) {
       return this.send('DELETE',
           this.getChangeActionURL(changeNum, null,
               '/edit'
           ));
     },
 
-    restoreFileInChangeEdit: function(changeNum, restore_path) {
+    restoreFileInChangeEdit(changeNum, restore_path) {
       return this.send('POST',
           this.getChangeActionURL(changeNum, null, '/edit'),
-          {restore_path: restore_path}
+          {restore_path}
       );
     },
 
-    renameFileInChangeEdit: function(changeNum, old_path, new_path) {
+    renameFileInChangeEdit(changeNum, old_path, new_path) {
       return this.send('POST',
           this.getChangeActionURL(changeNum, null, '/edit'),
-          {old_path: old_path},
-          {new_path: new_path}
+          {old_path},
+          {new_path}
       );
     },
 
-    deleteFileInChangeEdit: function(changeNum, path) {
+    deleteFileInChangeEdit(changeNum, path) {
       return this.send('DELETE',
           this.getChangeActionURL(changeNum, null,
               '/edit/' + encodeURIComponent(path)
           ));
     },
 
-    saveChangeEdit: function(changeNum, path, contents) {
+    saveChangeEdit(changeNum, path, contents) {
       return this.send('PUT',
           this.getChangeActionURL(changeNum, null,
               '/edit/' + encodeURIComponent(path)
@@ -718,29 +733,29 @@
       );
     },
 
-    saveChangeCommitMessageEdit: function(changeNum, message) {
-      var url = this.getChangeActionURL(changeNum, null, '/edit:message');
-      return this.send('PUT', url, {message: message});
+    saveChangeCommitMessageEdit(changeNum, message) {
+      const url = this.getChangeActionURL(changeNum, null, '/edit:message');
+      return this.send('PUT', url, {message});
     },
 
-    publishChangeEdit: function(changeNum) {
+    publishChangeEdit(changeNum) {
       return this.send('POST',
           this.getChangeActionURL(changeNum, null, '/edit:publish'));
     },
 
-    saveChangeStarred: function(changeNum, starred) {
-      var url = '/accounts/self/starred.changes/' + changeNum;
-      var method = starred ? 'PUT' : 'DELETE';
+    saveChangeStarred(changeNum, starred) {
+      const url = '/accounts/self/starred.changes/' + changeNum;
+      const method = starred ? 'PUT' : 'DELETE';
       return this.send(method, url);
     },
 
-    send: function(method, url, opt_body, opt_errFn, opt_ctx, opt_contentType) {
-      var headers = new Headers({
+    send(method, url, opt_body, opt_errFn, opt_ctx, opt_contentType) {
+      const headers = new Headers({
         'X-Gerrit-Auth': this._getCookie('XSRF_TOKEN'),
       });
-      var options = {
-        method: method,
-        headers: headers,
+      const options = {
+        method,
+        headers,
         credentials: 'same-origin',
       };
       if (opt_body) {
@@ -750,30 +765,30 @@
         }
         options.body = opt_body;
       }
-      return fetch(this.getBaseUrl() + url, options).then(function(response) {
+      return fetch(this.getBaseUrl() + url, options).then(response => {
         if (!response.ok) {
           if (opt_errFn) {
             opt_errFn.call(opt_ctx || null, response);
             return undefined;
           }
-          this.fire('server-error', {response: response});
+          this.fire('server-error', {response});
         }
 
         return response;
-      }.bind(this)).catch(function(err) {
+      }).catch(err => {
         this.fire('network-error', {error: err});
         if (opt_errFn) {
           opt_errFn.call(opt_ctx, null, err);
         } else {
           throw err;
         }
-      }.bind(this));
+      });
     },
 
-    getDiff: function(changeNum, basePatchNum, patchNum, path,
+    getDiff(changeNum, basePatchNum, patchNum, path,
         opt_errFn, opt_cancelCondition) {
-      var url = this._getDiffFetchURL(changeNum, patchNum, path);
-      var params = {
+      const url = this._getDiffFetchURL(changeNum, patchNum, path);
+      const params = {
         context: 'ALL',
         intraline: null,
         whitespace: 'IGNORE_NONE',
@@ -785,32 +800,29 @@
       return this.fetchJSON(url, opt_errFn, opt_cancelCondition, params);
     },
 
-    _getDiffFetchURL: function(changeNum, patchNum, path) {
+    _getDiffFetchURL(changeNum, patchNum, path) {
       return this._changeBaseURL(changeNum, patchNum) + '/files/' +
           encodeURIComponent(path) + '/diff';
     },
 
-    getDiffComments: function(changeNum, opt_basePatchNum, opt_patchNum,
-        opt_path) {
+    getDiffComments(changeNum, opt_basePatchNum, opt_patchNum, opt_path) {
       return this._getDiffComments(changeNum, '/comments', opt_basePatchNum,
           opt_patchNum, opt_path);
     },
 
-    getDiffRobotComments: function(changeNum, basePatchNum, patchNum,
-        opt_path) {
+    getDiffRobotComments(changeNum, basePatchNum, patchNum, opt_path) {
       return this._getDiffComments(changeNum, '/robotcomments', basePatchNum,
           patchNum, opt_path);
     },
 
-    getDiffDrafts: function(changeNum, opt_basePatchNum, opt_patchNum,
-        opt_path) {
+    getDiffDrafts(changeNum, opt_basePatchNum, opt_patchNum, opt_path) {
       return this._getDiffComments(changeNum, '/drafts', opt_basePatchNum,
           opt_patchNum, opt_path);
     },
 
-    _setRange: function(comments, comment) {
+    _setRange(comments, comment) {
       if (comment.in_reply_to && !comment.range) {
-        for (var i = 0; i < comments.length; i++) {
+        for (let i = 0; i < comments.length; i++) {
           if (comments[i].id === comment.in_reply_to) {
             comment.range = comments[i].range;
             break;
@@ -820,18 +832,18 @@
       return comment;
     },
 
-    _setRanges: function(comments) {
+    _setRanges(comments) {
       comments = comments || [];
-      comments.sort(function(a, b) {
+      comments.sort((a, b) => {
         return util.parseDate(a.updated) - util.parseDate(b.updated);
       });
-      comments.forEach(function(comment) {
+      for (const comment of comments) {
         this._setRange(comments, comment);
-      }.bind(this));
+      }
       return comments;
     },
 
-    _getDiffComments: function(changeNum, endpoint, opt_basePatchNum,
+    _getDiffComments(changeNum, endpoint, opt_basePatchNum,
         opt_patchNum, opt_path) {
       if (!opt_basePatchNum && !opt_patchNum && !opt_path) {
         return this.fetchJSON(
@@ -842,12 +854,12 @@
       function withoutParent(c) { return c.side != PARENT_PATCH_NUM; }
       function setPath(c) { c.path = opt_path; }
 
-      var promises = [];
-      var comments;
-      var baseComments;
-      var url =
+      const promises = [];
+      let comments;
+      let baseComments;
+      const url =
           this._getDiffCommentsFetchURL(changeNum, endpoint, opt_patchNum);
-      promises.push(this.fetchJSON(url).then(function(response) {
+      promises.push(this.fetchJSON(url).then(response => {
         comments = response[opt_path] || [];
 
         // TODO(kaspern): Implement this on in the backend so this can be
@@ -864,50 +876,50 @@
         comments = comments.filter(withoutParent);
 
         comments.forEach(setPath);
-      }.bind(this)));
+      }));
 
       if (opt_basePatchNum != PARENT_PATCH_NUM) {
-        var baseURL = this._getDiffCommentsFetchURL(changeNum, endpoint,
+        const baseURL = this._getDiffCommentsFetchURL(changeNum, endpoint,
             opt_basePatchNum);
-        promises.push(this.fetchJSON(baseURL).then(function(response) {
+        promises.push(this.fetchJSON(baseURL).then(response => {
           baseComments = (response[opt_path] || []).filter(withoutParent);
 
           baseComments = this._setRanges(baseComments);
 
           baseComments.forEach(setPath);
-        }.bind(this)));
+        }));
       }
 
-      return Promise.all(promises).then(function() {
+      return Promise.all(promises).then(() => {
         return Promise.resolve({
-          baseComments: baseComments,
-          comments: comments,
+          baseComments,
+          comments,
         });
       });
     },
 
-    _getDiffCommentsFetchURL: function(changeNum, endpoint, opt_patchNum) {
+    _getDiffCommentsFetchURL(changeNum, endpoint, opt_patchNum) {
       return this._changeBaseURL(changeNum, opt_patchNum) + endpoint;
     },
 
-    saveDiffDraft: function(changeNum, patchNum, draft) {
+    saveDiffDraft(changeNum, patchNum, draft) {
       return this._sendDiffDraftRequest('PUT', changeNum, patchNum, draft);
     },
 
-    deleteDiffDraft: function(changeNum, patchNum, draft) {
+    deleteDiffDraft(changeNum, patchNum, draft) {
       return this._sendDiffDraftRequest('DELETE', changeNum, patchNum, draft);
     },
 
-    hasPendingDiffDrafts: function() {
+    hasPendingDiffDrafts() {
       return !!this._pendingRequests[Requests.SEND_DIFF_DRAFT];
     },
 
-    _sendDiffDraftRequest: function(method, changeNum, patchNum, draft) {
-      var url = this.getChangeActionURL(changeNum, patchNum, '/drafts');
+    _sendDiffDraftRequest(method, changeNum, patchNum, draft) {
+      let url = this.getChangeActionURL(changeNum, patchNum, '/drafts');
       if (draft.id) {
         url += '/' + draft.id;
       }
-      var body;
+      let body;
       if (method === 'PUT') {
         body = draft;
       }
@@ -917,14 +929,14 @@
       }
       this._pendingRequests[Requests.SEND_DIFF_DRAFT]++;
 
-      return this.send(method, url, body).then(function(res) {
+      return this.send(method, url, body).then(res => {
         this._pendingRequests[Requests.SEND_DIFF_DRAFT]--;
         return res;
-      }.bind(this));
+      });
     },
 
-    _changeBaseURL: function(changeNum, opt_patchNum) {
-      var v = '/changes/' + changeNum;
+    _changeBaseURL(changeNum, opt_patchNum) {
+      let v = '/changes/' + changeNum;
       if (opt_patchNum) {
         v += '/revisions/' + opt_patchNum;
       }
@@ -933,77 +945,66 @@
 
     // Derived from
     // gerrit-extension-api/src/main/j/c/g/gerrit/extensions/client/ListChangesOption.java
-    _listChangesOptionsToHex: function() {
-      var v = 0;
-      for (var i = 0; i < arguments.length; i++) {
-        v |= 1 << arguments[i];
+    _listChangesOptionsToHex(...args) {
+      let v = 0;
+      for (let i = 0; i < args.length; i++) {
+        v |= 1 << args[i];
       }
       return v.toString(16);
     },
 
-    _getCookie: function(name) {
-      var key = name + '=';
-      var cookies = document.cookie.split(';');
-      for (var i = 0; i < cookies.length; i++) {
-        var c = cookies[i];
+    _getCookie(name) {
+      const key = name + '=';
+      const cookies = document.cookie.split(';');
+      for (let i = 0; i < cookies.length; i++) {
+        let c = cookies[i];
         while (c.charAt(0) == ' ') {
           c = c.substring(1);
         }
-        if (c.indexOf(key) == 0) {
+        if (c.startsWith(key)) {
           return c.substring(key.length, c.length);
         }
       }
       return '';
     },
 
-    getCommitInfo: function(project, commit) {
+    getCommitInfo(project, commit) {
       return this.fetchJSON(
           '/projects/' + encodeURIComponent(project) +
           '/commits/' + encodeURIComponent(commit));
     },
 
-    _fetchB64File: function(url) {
-      return fetch(this.getBaseUrl() + url, {credentials: 'same-origin'}).then(function(response) {
-        var type = response.headers.get('X-FYI-Content-Type');
-        return response.text()
-          .then(function(text) {
-            return {body: text, type: type};
+    _fetchB64File(url) {
+      return fetch(this.getBaseUrl() + url, {credentials: 'same-origin'})
+          .then(response => {
+            if (!response.ok) { return Promise.reject(response.statusText); }
+            const type = response.headers.get('X-FYI-Content-Type');
+            return response.text()
+                .then(text => {
+                  return {body: text, type};
+                });
           });
-      });
     },
 
-    getChangeFileContents: function(changeId, patchNum, path) {
+    getChangeFileContents(changeId, patchNum, path, opt_parentIndex) {
+      const parent = typeof opt_parentIndex === 'number' ?
+          '?parent=' + opt_parentIndex : '';
       return this._fetchB64File(
           '/changes/' + encodeURIComponent(changeId) +
           '/revisions/' + encodeURIComponent(patchNum) +
           '/files/' + encodeURIComponent(path) +
-          '/content');
+          '/content' + parent);
     },
 
-    getCommitFileContents: function(projectName, commit, path) {
-      return this._fetchB64File(
-          '/projects/' + encodeURIComponent(projectName) +
-          '/commits/' + encodeURIComponent(commit) +
-          '/files/' + encodeURIComponent(path) +
-          '/content');
-    },
+    getImagesForDiff(changeNum, diff, patchRange) {
+      let promiseA;
+      let promiseB;
 
-    getImagesForDiff: function(project, commit, changeNum, diff, patchRange) {
-      var promiseA;
-      var promiseB;
-
-      if (diff.meta_a && diff.meta_a.content_type.indexOf('image/') === 0) {
+      if (diff.meta_a && diff.meta_a.content_type.startsWith('image/')) {
         if (patchRange.basePatchNum === 'PARENT') {
-          // Need the commit info know the parent SHA.
-          promiseA = this.getCommitInfo(project, commit).then(function(info) {
-            if (info.parents.length !== 1) {
-              return Promise.reject('Change commit has multiple parents.');
-            }
-            var parent = info.parents[0].commit;
-            return this.getCommitFileContents(project, parent,
-                diff.meta_a.name);
-          }.bind(this));
-
+          // Note: we only attempt to get the image from the first parent.
+          promiseA = this.getChangeFileContents(changeNum, patchRange.patchNum,
+              diff.meta_a.name, 1);
         } else {
           promiseA = this.getChangeFileContents(changeNum,
               patchRange.basePatchNum, diff.meta_a.name);
@@ -1012,81 +1013,82 @@
         promiseA = Promise.resolve(null);
       }
 
-      if (diff.meta_b && diff.meta_b.content_type.indexOf('image/') === 0) {
+      if (diff.meta_b && diff.meta_b.content_type.startsWith('image/')) {
         promiseB = this.getChangeFileContents(changeNum, patchRange.patchNum,
             diff.meta_b.name);
       } else {
         promiseB = Promise.resolve(null);
       }
 
-      return Promise.all([promiseA, promiseB])
-        .then(function(results) {
-          var baseImage = results[0];
-          var revisionImage = results[1];
+      return Promise.all([promiseA, promiseB]).then(results => {
+        const baseImage = results[0];
+        const revisionImage = results[1];
 
-          // Sometimes the server doesn't send back the content type.
-          if (baseImage) {
-            baseImage._expectedType = diff.meta_a.content_type;
-          }
-          if (revisionImage) {
-            revisionImage._expectedType = diff.meta_b.content_type;
-          }
+        // Sometimes the server doesn't send back the content type.
+        if (baseImage) {
+          baseImage._expectedType = diff.meta_a.content_type;
+          baseImage._name = diff.meta_a.name;
+        }
+        if (revisionImage) {
+          revisionImage._expectedType = diff.meta_b.content_type;
+          revisionImage._name = diff.meta_b.name;
+        }
 
-          return {baseImage: baseImage, revisionImage: revisionImage};
-        }.bind(this));
+        return {baseImage, revisionImage};
+      });
     },
 
-    setChangeTopic: function(changeNum, topic) {
+    setChangeTopic(changeNum, topic) {
       return this.send('PUT', '/changes/' + encodeURIComponent(changeNum) +
-          '/topic', {topic: topic});
+          '/topic', {topic});
     },
 
-    deleteAccountHttpPassword: function() {
+    deleteAccountHttpPassword() {
       return this.send('DELETE', '/accounts/self/password.http');
     },
 
-    generateAccountHttpPassword: function() {
+    generateAccountHttpPassword() {
       return this.send('PUT', '/accounts/self/password.http', {generate: true})
           .then(this.getResponseObject);
     },
 
-    getAccountSSHKeys: function() {
+    getAccountSSHKeys() {
       return this._fetchSharedCacheURL('/accounts/self/sshkeys');
     },
 
-    addAccountSSHKey: function(key) {
+    addAccountSSHKey(key) {
       return this.send('POST', '/accounts/self/sshkeys', key, null, null,
           'plain/text')
-          .then(function(response) {
+          .then(response => {
             if (response.status < 200 && response.status >= 300) {
               return Promise.reject();
             }
             return this.getResponseObject(response);
-          }.bind(this))
-          .then(function(obj) {
+          })
+          .then(obj => {
             if (!obj.valid) { return Promise.reject(); }
             return obj;
           });
     },
 
-    deleteAccountSSHKey: function(id) {
+    deleteAccountSSHKey(id) {
       return this.send('DELETE', '/accounts/self/sshkeys/' + id);
     },
 
-    deleteVote: function(changeID, account, label) {
+    deleteVote(changeID, account, label) {
       return this.send('DELETE', '/changes/' + changeID +
           '/reviewers/' + account + '/votes/' + encodeURIComponent(label));
     },
 
-    setDescription: function(changeNum, patchNum, desc) {
+    setDescription(changeNum, patchNum, desc) {
       return this.send('PUT',
           this.getChangeActionURL(changeNum, patchNum, '/description'),
           {description: desc});
     },
 
-    confirmEmail: function(token) {
-      return this.send('PUT', '/config/server/email.confirm', {token: token})
-          .then(function(response) {
+    confirmEmail(token) {
+      return this.send('PUT', '/config/server/email.confirm', {token})
+          .then(response => {
             if (response.status === 204) {
               return 'Email confirmed successfully.';
             }
@@ -1094,22 +1096,48 @@
           });
     },
 
-    setAssignee: function(changeNum, assignee) {
+    setAssignee(changeNum, assignee) {
       return this.send('PUT',
           this.getChangeActionURL(changeNum, null, '/assignee'),
-          {assignee: assignee});
+          {assignee});
     },
 
-    deleteAssignee: function(changeNum) {
+    deleteAssignee(changeNum) {
       return this.send('DELETE',
           this.getChangeActionURL(changeNum, null, '/assignee'));
     },
 
-    probePath: function(path) {
+    probePath(path) {
       return fetch(new Request(path, {method: 'HEAD'}))
-        .then(function(response) {
-          return response.ok;
-        });
+          .then(response => {
+            return response.ok;
+          });
+    },
+
+    startWorkInProgress(changeNum, opt_message) {
+      const payload = {};
+      if (opt_message) {
+        payload.message = opt_message;
+      }
+      const url = this.getChangeActionURL(changeNum, null, '/wip');
+      return this.send('POST', url, payload)
+          .then(response => {
+            if (response.status === 204) {
+              return 'Change marked as Work In Progress.';
+            }
+          });
+    },
+
+    startReview(changeNum, review) {
+      return this.send(
+          'POST', this.getChangeActionURL(changeNum, null, '/ready'), review);
+    },
+
+    deleteComment(changeNum, patchNum, commentID, reason) {
+      const url = this._changeBaseURL(changeNum, patchNum) +
+          '/comments/' + commentID + '/delete';
+      return this.send('POST', url, {reason}).then(response =>
+        this.getResponseObject(response));
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
index 0ff162d..9b8820a 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
@@ -34,63 +34,63 @@
 </test-fixture>
 
 <script>
-  suite('gr-rest-api-interface tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-rest-api-interface tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
       element = fixture('basic');
-      var testJSON = ')]}\'\n{"hello": "bonjour"}';
+      const testJSON = ')]}\'\n{"hello": "bonjour"}';
       sandbox.stub(window, 'fetch').returns(Promise.resolve({
         ok: true,
-        text: function() {
+        text() {
           return Promise.resolve(testJSON);
         },
       }));
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('JSON prefix is properly removed', function(done) {
-      element.fetchJSON('/dummy/url').then(function(obj) {
+    test('JSON prefix is properly removed', done => {
+      element.fetchJSON('/dummy/url').then(obj => {
         assert.deepEqual(obj, {hello: 'bonjour'});
         done();
       });
     });
 
-    test('cached results', function(done) {
-      var n = 0;
-      sandbox.stub(element, 'fetchJSON', function() {
+    test('cached results', done => {
+      let n = 0;
+      sandbox.stub(element, 'fetchJSON', () => {
         return Promise.resolve(++n);
       });
-      var promises = [];
+      const promises = [];
       promises.push(element._fetchSharedCacheURL('/foo'));
       promises.push(element._fetchSharedCacheURL('/foo'));
       promises.push(element._fetchSharedCacheURL('/foo'));
 
-      Promise.all(promises).then(function(results) {
+      Promise.all(promises).then(results => {
         assert.deepEqual(results, [1, 1, 1]);
-        element._fetchSharedCacheURL('/foo').then(function(foo) {
+        element._fetchSharedCacheURL('/foo').then(foo => {
           assert.equal(foo, 1);
           done();
         });
       });
     });
 
-    test('cached promise', function(done) {
-      var promise = Promise.reject('foo');
+    test('cached promise', done => {
+      const promise = Promise.reject('foo');
       element._cache['/foo'] = promise;
-      element._fetchSharedCacheURL('/foo').catch(function(p) {
+      element._fetchSharedCacheURL('/foo').catch(p => {
         assert.equal(p, 'foo');
         done();
       });
     });
 
-    test('params are properly encoded', function() {
-      var url = element._urlWithParams('/path/', {
+    test('params are properly encoded', () => {
+      let url = element._urlWithParams('/path/', {
         sp: 'hola',
         gr: 'guten tag',
         noval: null,
@@ -110,23 +110,23 @@
       assert.equal(url, '/path/?l=c&l=b&l=a');
     });
 
-    test('request callbacks can be canceled', function(done) {
-      var cancelCalled = false;
+    test('request callbacks can be canceled', done => {
+      let cancelCalled = false;
       window.fetch.returns(Promise.resolve({
         body: {
-          cancel: function() { cancelCalled = true; },
+          cancel() { cancelCalled = true; },
         },
       }));
-      element.fetchJSON('/dummy/url', null, function() { return true; }).then(
-        function(obj) {
-          assert.isUndefined(obj);
-          assert.isTrue(cancelCalled);
-          done();
-        });
+      element.fetchJSON('/dummy/url', null, () => { return true; }).then(
+          obj => {
+            assert.isUndefined(obj);
+            assert.isTrue(cancelCalled);
+            done();
+          });
     });
 
-    test('parent diff comments are properly grouped', function(done) {
-      sandbox.stub(element, 'fetchJSON', function() {
+    test('parent diff comments are properly grouped', done => {
+      sandbox.stub(element, 'fetchJSON', () => {
         return Promise.resolve({
           '/COMMIT_MSG': [],
           'sieve.go': [
@@ -143,26 +143,26 @@
         });
       });
       element._getDiffComments('42', '', 'PARENT', 1, 'sieve.go').then(
-        function(obj) {
-          assert.equal(obj.baseComments.length, 1);
-          assert.deepEqual(obj.baseComments[0], {
-            side: 'PARENT',
-            message: 'how did this work in the first place?',
-            path: 'sieve.go',
-            updated: '2017-02-03 22:33:28.000000000',
+          obj => {
+            assert.equal(obj.baseComments.length, 1);
+            assert.deepEqual(obj.baseComments[0], {
+              side: 'PARENT',
+              message: 'how did this work in the first place?',
+              path: 'sieve.go',
+              updated: '2017-02-03 22:33:28.000000000',
+            });
+            assert.equal(obj.comments.length, 1);
+            assert.deepEqual(obj.comments[0], {
+              message: 'this isn’t quite right',
+              path: 'sieve.go',
+              updated: '2017-02-03 22:32:28.000000000',
+            });
+            done();
           });
-          assert.equal(obj.comments.length, 1);
-          assert.deepEqual(obj.comments[0], {
-            message: 'this isn’t quite right',
-            path: 'sieve.go',
-            updated: '2017-02-03 22:32:28.000000000',
-          });
-          done();
-        });
     });
 
-    test('_setRange', function() {
-      var comments = [
+    test('_setRange', () => {
+      const comments = [
         {
           id: 1,
           side: 'PARENT',
@@ -182,7 +182,7 @@
           updated: '2017-02-03 22:33:28.000000000',
         },
       ];
-      var expectedResult = {
+      const expectedResult = {
         id: 2,
         in_reply_to: 1,
         message: 'this isn’t quite right',
@@ -194,12 +194,12 @@
           end_character: 1,
         },
       };
-      var comment = comments[1];
+      const comment = comments[1];
       assert.deepEqual(element._setRange(comments, comment), expectedResult);
     });
 
-    test('_setRanges', function() {
-      var comments = [
+    test('_setRanges', () => {
+      const comments = [
         {
           id: 3,
           in_reply_to: 2,
@@ -225,7 +225,7 @@
           },
         },
       ];
-      var expectedResult = [
+      const expectedResult = [
         {
           id: 1,
           side: 'PARENT',
@@ -266,8 +266,8 @@
       assert.deepEqual(element._setRanges(comments), expectedResult);
     });
 
-    test('differing patch diff comments are properly grouped', function(done) {
-      sandbox.stub(element, 'fetchJSON', function(url) {
+    test('differing patch diff comments are properly grouped', done => {
+      sandbox.stub(element, 'fetchJSON', url => {
         if (url == '/changes/42/revisions/1') {
           return Promise.resolve({
             '/COMMIT_MSG': [],
@@ -305,29 +305,29 @@
         }
       });
       element._getDiffComments('42', '', 1, 2, 'sieve.go').then(
-        function(obj) {
-          assert.equal(obj.baseComments.length, 1);
-          assert.deepEqual(obj.baseComments[0], {
-            message: 'this isn’t quite right',
-            path: 'sieve.go',
-            updated: '2017-02-03 22:32:28.000000000',
+          obj => {
+            assert.equal(obj.baseComments.length, 1);
+            assert.deepEqual(obj.baseComments[0], {
+              message: 'this isn’t quite right',
+              path: 'sieve.go',
+              updated: '2017-02-03 22:32:28.000000000',
+            });
+            assert.equal(obj.comments.length, 2);
+            assert.deepEqual(obj.comments[0], {
+              message: 'What on earth are you thinking, here?',
+              path: 'sieve.go',
+              updated: '2017-02-03 22:32:28.000000000',
+            });
+            assert.deepEqual(obj.comments[1], {
+              message: '¯\\_(ツ)_/¯',
+              path: 'sieve.go',
+              updated: '2017-02-04 22:33:28.000000000',
+            });
+            done();
           });
-          assert.equal(obj.comments.length, 2);
-          assert.deepEqual(obj.comments[0], {
-            message: 'What on earth are you thinking, here?',
-            path: 'sieve.go',
-            updated: '2017-02-03 22:32:28.000000000',
-          });
-          assert.deepEqual(obj.comments[1], {
-            message: '¯\\_(ツ)_/¯',
-            path: 'sieve.go',
-            updated: '2017-02-04 22:33:28.000000000',
-          });
-          done();
-        });
     });
 
-    test('special file path sorting', function() {
+    test('special file path sorting', () => {
       assert.deepEqual(
           ['.b', '/COMMIT_MSG', '.a', 'file'].sort(
               element.specialFilePathCompare),
@@ -354,13 +354,13 @@
           ['foo/bar.h', 'foo/bar.hpp', 'foo/bar.hxx']);
 
       // Regression test for Issue 4448.
-      assert.deepEqual([
-          'minidump/minidump_memory_writer.cc',
-          'minidump/minidump_memory_writer.h',
-          'minidump/minidump_thread_writer.cc',
-          'minidump/minidump_thread_writer.h',
-          ]
-        .sort(element.specialFilePathCompare),
+      assert.deepEqual(
+          [
+            'minidump/minidump_memory_writer.cc',
+            'minidump/minidump_memory_writer.h',
+            'minidump/minidump_thread_writer.cc',
+            'minidump/minidump_thread_writer.h',
+          ].sort(element.specialFilePathCompare),
           [
             'minidump/minidump_memory_writer.h',
             'minidump/minidump_memory_writer.cc',
@@ -369,102 +369,102 @@
           ]);
 
       // Regression test for Issue 4545.
-      assert.deepEqual([
-          'task_test.go',
-          'task.go',
-          ]
-        .sort(element.specialFilePathCompare),
+      assert.deepEqual(
+          [
+            'task_test.go',
+            'task.go',
+          ].sort(element.specialFilePathCompare),
           [
             'task.go',
             'task_test.go',
           ]);
     });
 
-    suite('rebase action', function() {
-      var resolveFetchJSON;
-      setup(function() {
+    suite('rebase action', () => {
+      let resolveFetchJSON;
+      setup(() => {
         sandbox.stub(element, 'fetchJSON').returns(
-            new Promise(function(resolve) {
+            new Promise(resolve => {
               resolveFetchJSON = resolve;
             }));
       });
 
-      test('no rebase on current', function(done) {
+      test('no rebase on current', done => {
         element.getChangeRevisionActions('42', '1337').then(
-          function(response) {
-            assert.isTrue(response.rebase.enabled);
-            assert.isFalse(response.rebase.rebaseOnCurrent);
-            done();
-          });
+            response => {
+              assert.isTrue(response.rebase.enabled);
+              assert.isFalse(response.rebase.rebaseOnCurrent);
+              done();
+            });
         resolveFetchJSON({rebase: {}});
       });
 
-      test('rebase on current', function(done) {
+      test('rebase on current', done => {
         element.getChangeRevisionActions('42', '1337').then(
-          function(response) {
-            assert.isTrue(response.rebase.enabled);
-            assert.isTrue(response.rebase.rebaseOnCurrent);
-            done();
-          });
+            response => {
+              assert.isTrue(response.rebase.enabled);
+              assert.isTrue(response.rebase.rebaseOnCurrent);
+              done();
+            });
         resolveFetchJSON({rebase: {enabled: true}});
       });
     });
 
 
-    test('server error', function(done) {
-      var getResponseObjectStub = sandbox.stub(element, 'getResponseObject');
+    test('server error', done => {
+      const getResponseObjectStub = sandbox.stub(element, 'getResponseObject');
       window.fetch.returns(Promise.resolve({ok: false}));
-      var serverErrorEventPromise = new Promise(function(resolve) {
-        element.addEventListener('server-error', function() { resolve(); });
+      const serverErrorEventPromise = new Promise(resolve => {
+        element.addEventListener('server-error', () => { resolve(); });
       });
 
       element.fetchJSON().then(
-          function(response) {
+          response => {
             assert.isUndefined(response);
             assert.isTrue(getResponseObjectStub.notCalled);
-            serverErrorEventPromise.then(function() {
+            serverErrorEventPromise.then(() => {
               done();
             });
           });
     });
 
-    test('checkCredentials', function(done) {
-      var responses = [
+    test('checkCredentials', done => {
+      const responses = [
         {
           ok: false,
           status: 403,
-          text: function() { return Promise.resolve(); },
+          text() { return Promise.resolve(); },
         },
         {
           ok: true,
           status: 200,
-          text: function() { return Promise.resolve(')]}\'{}'); },
+          text() { return Promise.resolve(')]}\'{}'); },
         },
       ];
       window.fetch.restore();
-      sandbox.stub(window, 'fetch', function(url) {
+      sandbox.stub(window, 'fetch', url => {
         if (url === '/accounts/self/detail') {
           return Promise.resolve(responses.shift());
         }
       });
 
-      element.getLoggedIn().then(function(account) {
+      element.getLoggedIn().then(account => {
         assert.isNotOk(account);
-        element.checkCredentials().then(function(account) {
+        element.checkCredentials().then(account => {
           assert.isOk(account);
           done();
         });
       });
     });
 
-    test('legacy n,z key in change url is replaced', function() {
-      var stub = sandbox.stub(element, 'fetchJSON');
+    test('legacy n,z key in change url is replaced', () => {
+      const stub = sandbox.stub(element, 'fetchJSON');
       element.getChanges(1, null, 'n,z');
       assert.equal(stub.args[0][3].S, 0);
     });
 
-    test('saveDiffPreferences invalidates cache line', function() {
-      var cacheKey = '/accounts/self/preferences.diff';
+    test('saveDiffPreferences invalidates cache line', () => {
+      const cacheKey = '/accounts/self/preferences.diff';
       sandbox.stub(element, 'send');
       element._cache[cacheKey] = {tab_size: 4};
       element.saveDiffPreferences({tab_size: 8});
@@ -472,108 +472,106 @@
       assert.notOk(element._cache[cacheKey]);
     });
 
-    var preferenceSetup = function(testJSON, loggedIn, smallScreen) {
-      sandbox.stub(element, 'getLoggedIn', function() {
+    const preferenceSetup = function(testJSON, loggedIn, smallScreen) {
+      sandbox.stub(element, 'getLoggedIn', () => {
         return Promise.resolve(loggedIn);
       });
-      sandbox.stub(element, '_isNarrowScreen', function() {
+      sandbox.stub(element, '_isNarrowScreen', () => {
         return smallScreen;
       });
-      sandbox.stub(element, '_fetchSharedCacheURL', function() {
+      sandbox.stub(element, '_fetchSharedCacheURL', () => {
         return Promise.resolve(testJSON);
       });
     };
 
     test('getPreferences returns correctly on small screens logged in',
-        function(done) {
+        done => {
+          const testJSON = {diff_view: 'SIDE_BY_SIDE'};
+          const loggedIn = true;
+          const smallScreen = true;
 
-      var testJSON = {diff_view: 'SIDE_BY_SIDE'};
-      var loggedIn = true;
-      var smallScreen = true;
+          preferenceSetup(testJSON, loggedIn, smallScreen);
 
-      preferenceSetup(testJSON, loggedIn, smallScreen);
-
-      element.getPreferences().then(function(obj) {
-        assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
-        assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
-        done();
-      });
-    });
+          element.getPreferences().then(obj => {
+            assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
+            assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
+            done();
+          });
+        });
 
     test('getPreferences returns correctly on small screens not logged in',
-          function(done) {
+        done => {
+          const testJSON = {diff_view: 'SIDE_BY_SIDE'};
+          const loggedIn = false;
+          const smallScreen = true;
 
-      var testJSON = {diff_view: 'SIDE_BY_SIDE'};
-      var loggedIn = false;
-      var smallScreen = true;
-
-      preferenceSetup(testJSON, loggedIn, smallScreen);
-      element.getPreferences().then(function(obj) {
-        assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
-        assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
-        done();
-      });
-    });
+          preferenceSetup(testJSON, loggedIn, smallScreen);
+          element.getPreferences().then(obj => {
+            assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
+            assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
+            done();
+          });
+        });
 
     test('getPreferences returns correctly on larger screens logged in',
-        function(done) {
-      var testJSON = {diff_view: 'UNIFIED_DIFF'};
-      var loggedIn = true;
-      var smallScreen = false;
+        done => {
+          const testJSON = {diff_view: 'UNIFIED_DIFF'};
+          const loggedIn = true;
+          const smallScreen = false;
 
-      preferenceSetup(testJSON, loggedIn, smallScreen);
+          preferenceSetup(testJSON, loggedIn, smallScreen);
 
-      element.getPreferences().then(function(obj) {
-        assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
-        assert.equal(obj.diff_view, 'UNIFIED_DIFF');
-        done();
-      });
-    });
+          element.getPreferences().then(obj => {
+            assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
+            assert.equal(obj.diff_view, 'UNIFIED_DIFF');
+            done();
+          });
+        });
 
     test('getPreferences returns correctly on larger screens not logged in',
-        function(done) {
-      var testJSON = {diff_view: 'UNIFIED_DIFF'};
-      var loggedIn = false;
-      var smallScreen = false;
+        done => {
+          const testJSON = {diff_view: 'UNIFIED_DIFF'};
+          const loggedIn = false;
+          const smallScreen = false;
 
-      preferenceSetup(testJSON, loggedIn, smallScreen);
+          preferenceSetup(testJSON, loggedIn, smallScreen);
 
-      element.getPreferences().then(function(obj) {
-        assert.equal(obj.default_diff_view, 'SIDE_BY_SIDE');
-        assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
-        done();
-      });
-    });
+          element.getPreferences().then(obj => {
+            assert.equal(obj.default_diff_view, 'SIDE_BY_SIDE');
+            assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
+            done();
+          });
+        });
 
-    test('savPreferences normalizes download scheme', function() {
+    test('savPreferences normalizes download scheme', () => {
       sandbox.stub(element, 'send');
       element.savePreferences({download_scheme: 'HTTP'});
       assert.isTrue(element.send.called);
       assert.equal(element.send.lastCall.args[2].download_scheme, 'http');
     });
 
-    test('confirmEmail', function() {
+    test('confirmEmail', () => {
       sandbox.spy(element, 'send');
       element.confirmEmail('foo');
       assert.isTrue(element.send.calledWith(
           'PUT', '/config/server/email.confirm', {token: 'foo'}));
     });
 
-    test('GrReviewerUpdatesParser.parse is used', function() {
+    test('GrReviewerUpdatesParser.parse is used', () => {
       sandbox.stub(GrReviewerUpdatesParser, 'parse').returns(
           Promise.resolve('foo'));
-      return element.getChangeDetail(42).then(function(result) {
+      return element.getChangeDetail(42).then(result => {
         assert.isTrue(GrReviewerUpdatesParser.parse.calledOnce);
         assert.equal(result, 'foo');
       });
     });
 
-    test('setAccountStatus', function(done) {
+    test('setAccountStatus', done => {
       sandbox.stub(element, 'send').returns(Promise.resolve('OOO'));
       sandbox.stub(element, 'getResponseObject')
           .returns(Promise.resolve('OOO'));
       element._cache['/accounts/self/detail'] = {};
-      element.setAccountStatus('OOO').then(function() {
+      element.setAccountStatus('OOO').then(() => {
         assert.isTrue(element.send.calledWith('PUT', '/accounts/self/status',
             {status: 'OOO'}));
         assert.deepEqual(element._cache['/accounts/self/detail'],
@@ -582,24 +580,24 @@
       });
     });
 
-    test('_sendDiffDraft pending requests tracked', function(done) {
-      sandbox.stub(element, 'send', function() {
+    test('_sendDiffDraft pending requests tracked', done => {
+      sandbox.stub(element, 'send', () => {
         assert.equal(element._pendingRequests.sendDiffDraft, 1);
         return Promise.resolve([]);
       });
-      element.saveDiffDraft('', 1, 1).then(function() {
+      element.saveDiffDraft('', 1, 1).then(() => {
         assert.equal(element._pendingRequests.sendDiffDraft, 0);
-        element.deleteDiffDraft('', 1, 1).then(function() {
+        element.deleteDiffDraft('', 1, 1).then(() => {
           assert.equal(element._pendingRequests.sendDiffDraft, 0);
           done();
         });
       });
     });
 
-    test('saveChangeEdit', function(done) {
-      var change_num = '1';
-      var file_name = 'index.php';
-      var file_contents = '<?php';
+    test('saveChangeEdit', done => {
+      const change_num = '1';
+      const file_name = 'index.php';
+      const file_contents = '<?php';
       sandbox.stub(element, 'send').returns(
           Promise.resolve([change_num, file_name, file_contents])
       );
@@ -607,7 +605,7 @@
           .returns(Promise.resolve([change_num, file_name, file_contents]));
       element._cache['/changes/' + change_num + '/edit/' + file_name] = {};
       element.saveChangeEdit(change_num, file_name, file_contents).then(
-          function() {
+          () => {
             assert.isTrue(element.send.calledWith('PUT',
                 '/changes/' + change_num + '/edit/' + file_name,
                 file_contents));
@@ -615,5 +613,35 @@
           }
       );
     });
+
+    test('startWorkInProgress', () => {
+      sandbox.stub(element, 'send').returns(Promise.resolve('ok'));
+      element.startWorkInProgress('42');
+      assert.isTrue(element.send.calledWith(
+          'POST', '/changes/42/wip', {}));
+      element.startWorkInProgress('42', 'revising...');
+      assert.isTrue(element.send.calledWith(
+          'POST', '/changes/42/wip', {message: 'revising...'}));
+    });
+
+    test('startReview', () => {
+      sandbox.stub(element, 'send').returns(Promise.resolve({}));
+      element.startReview('42', {message: 'Please review.'});
+      assert.isTrue(element.send.calledWith(
+          'POST', '/changes/42/ready', {message: 'Please review.'}));
+    });
+
+    test('deleteComment', done => {
+      sandbox.stub(element, 'send').returns(Promise.resolve());
+      sandbox.stub(element, 'getResponseObject').returns('some response');
+      element.deleteComment('foo', 'bar', '01234', 'removal reason')
+          .then(response => {
+            assert.equal(response, 'some response');
+            done();
+          });
+      assert.isTrue(element.send.calledWith(
+          'POST', '/changes/foo/revisions/bar/comments/01234/delete',
+          {reason: 'removal reason'}));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js
index 21a6bc6..232532f 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js
@@ -21,7 +21,7 @@
     // TODO (viktard): Polyfill Object.assign for IE.
     this.result = Object.assign({}, change);
     this._lastState = {};
-  };
+  }
 
   GrReviewerUpdatesParser.parse = function(change) {
     if (!change ||
@@ -30,13 +30,15 @@
         !change.reviewer_updates.length) {
       return change;
     }
-    var parser = new GrReviewerUpdatesParser(change);
+    const parser = new GrReviewerUpdatesParser(change);
     parser._filterRemovedMessages();
     parser._groupUpdates();
     parser._formatUpdates();
+    parser._advanceUpdates();
     return parser.result;
   };
 
+  GrReviewerUpdatesParser.MESSAGE_REVIEWERS_THRESHOLD_MILLIS = 500;
   GrReviewerUpdatesParser.REVIEWER_UPDATE_THRESHOLD_MILLIS = 6000;
 
   GrReviewerUpdatesParser.prototype.result = null;
@@ -49,7 +51,7 @@
    * are used.
    */
   GrReviewerUpdatesParser.prototype._filterRemovedMessages = function() {
-    this.result.messages = this.result.messages.filter(function(message) {
+    this.result.messages = this.result.messages.filter(message => {
       return message.tag !== 'autogenerated:gerrit:deleteReviewer';
     });
   };
@@ -74,10 +76,10 @@
    * @param {Object} update instance of ReviewerUpdateInfo
    */
   GrReviewerUpdatesParser.prototype._completeBatch = function(update) {
-    var items = [];
-    for (var accountId in this._updateItems) {
+    const items = [];
+    for (const accountId in this._updateItems) {
       if (!this._updateItems.hasOwnProperty(accountId)) continue;
-      var updateItem = this._updateItems[accountId];
+      const updateItem = this._updateItems[accountId];
       if (this._lastState[accountId] !== updateItem.state) {
         this._lastState[accountId] = updateItem.state;
         items.push(updateItem);
@@ -96,14 +98,14 @@
    * - Groups with no-change updates are discarded (eg CC -> CC)
    */
   GrReviewerUpdatesParser.prototype._groupUpdates = function() {
-    var updates = this.result.reviewer_updates;
-    var newUpdates = updates.reduce(function(newUpdates, update) {
+    const updates = this.result.reviewer_updates;
+    const newUpdates = updates.reduce((newUpdates, update) => {
       if (!this._batch) {
         this._batch = this._startBatch(update);
       }
-      var updateDate = util.parseDate(update.updated).getTime();
-      var batchUpdateDate = util.parseDate(this._batch.date).getTime();
-      var reviewerId = update.reviewer._account_id.toString();
+      const updateDate = util.parseDate(update.updated).getTime();
+      const batchUpdateDate = util.parseDate(this._batch.date).getTime();
+      const reviewerId = update.reviewer._account_id.toString();
       if (updateDate - batchUpdateDate >
           GrReviewerUpdatesParser.REVIEWER_UPDATE_THRESHOLD_MILLIS ||
           update.updated_by._account_id !== this._batch.author._account_id) {
@@ -122,7 +124,7 @@
         this._updateItems[reviewerId].prev_state = this._lastState[reviewerId];
       }
       return newUpdates;
-    }.bind(this), []);
+    }, []);
     this._completeBatch();
     if (this._batch.updates && this._batch.updates.length) {
       newUpdates.push(this._batch);
@@ -157,14 +159,14 @@
    * @return {!Object} Hash of arrays of AccountInfo, message as key.
    */
   GrReviewerUpdatesParser.prototype._groupUpdatesByMessage = function(updates) {
-    return updates.reduce(function(result, item) {
-      var message = this._getUpdateMessage(item.prev_state, item.state);
+    return updates.reduce((result, item) => {
+      const message = this._getUpdateMessage(item.prev_state, item.state);
       if (!result[message]) {
         result[message] = [];
       }
       result[message].push(item.reviewer);
       return result;
-    }.bind(this), {});
+    }, {});
   };
 
   /**
@@ -173,21 +175,48 @@
    * @see https://gerrit-review.googlesource.com/c/94490/
    */
   GrReviewerUpdatesParser.prototype._formatUpdates = function() {
-    this.result.reviewer_updates.forEach(function(update) {
-      var grouppedReviewers = this._groupUpdatesByMessage(update.updates);
-      var newUpdates = [];
-      for (var message in grouppedReviewers) {
+    for (const update of this.result.reviewer_updates) {
+      const grouppedReviewers = this._groupUpdatesByMessage(update.updates);
+      const newUpdates = [];
+      for (const message in grouppedReviewers) {
         if (grouppedReviewers.hasOwnProperty(message)) {
           newUpdates.push({
-            message: message,
+            message,
             reviewers: grouppedReviewers[message],
           });
         }
       }
       update.updates = newUpdates;
-    }.bind(this));
+    }
+  };
+
+  /**
+   * Moves reviewer updates that are within short time frame of change messages
+   * back in time so they would come before change messages.
+   * TODO(viktard): Remove when server-side serves reviewer updates like so.
+   */
+  GrReviewerUpdatesParser.prototype._advanceUpdates = function() {
+    const updates = this.result.reviewer_updates;
+    const messages = this.result.messages;
+    messages.forEach((message, index) => {
+      const messageDate = util.parseDate(message.date).getTime();
+      const nextMessageDate = index === messages.length - 1 ? null :
+          util.parseDate(messages[index + 1].date).getTime();
+      for(const update of updates) {
+        const date = util.parseDate(update.date).getTime();
+        if (date >= messageDate
+            && (!nextMessageDate || date < nextMessageDate)) {
+          const timestamp = util.parseDate(update.date).getTime() -
+              GrReviewerUpdatesParser.MESSAGE_REVIEWERS_THRESHOLD_MILLIS;
+          update.date = new Date(timestamp)
+            .toISOString().replace('T', ' ').replace('Z', '000000');
+        }
+        if (nextMessageDate && date > nextMessageDate) {
+          break;
+        }
+      }
+    });
   };
 
   window.GrReviewerUpdatesParser = GrReviewerUpdatesParser;
-
 })(window);
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html
index 1ae04a0..f27e068 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html
@@ -27,20 +27,20 @@
 <script src="gr-reviewer-updates-parser.js"></script>
 
 <script>
-  suite('gr-reviewer-updates-parser tests', function() {
-    var sandbox;
-    var instance;
+  suite('gr-reviewer-updates-parser tests', () => {
+    let sandbox;
+    let instance;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('ignores changes without messages', function() {
-      var change = {};
+    test('ignores changes without messages', () => {
+      const change = {};
       sandbox.stub(
           GrReviewerUpdatesParser.prototype, '_filterRemovedMessages');
       sandbox.stub(
@@ -56,8 +56,8 @@
           GrReviewerUpdatesParser.prototype._formatUpdates.called);
     });
 
-    test('ignores changes without reviewer updates', function() {
-      var change = {
+    test('ignores changes without reviewer updates', () => {
+      const change = {
         messages: [],
       };
       sandbox.stub(
@@ -75,8 +75,8 @@
           GrReviewerUpdatesParser.prototype._formatUpdates.called);
     });
 
-    test('ignores changes with empty reviewer updates', function() {
-      var change = {
+    test('ignores changes with empty reviewer updates', () => {
+      const change = {
         messages: [],
         reviewer_updates: [],
       };
@@ -95,18 +95,18 @@
           GrReviewerUpdatesParser.prototype._formatUpdates.called);
     });
 
-    test('filter removed messages', function() {
-      var change = {
-          messages: [
-            {
-              message: 'msg1',
-              tag: 'autogenerated:gerrit:deleteReviewer',
-            },
-            {
-              message: 'msg2',
-              tag: 'foo',
-            }
-          ],
+    test('filter removed messages', () => {
+      const change = {
+        messages: [
+          {
+            message: 'msg1',
+            tag: 'autogenerated:gerrit:deleteReviewer',
+          },
+          {
+            message: 'msg2',
+            tag: 'foo',
+          },
+        ],
       };
       instance = new GrReviewerUpdatesParser(change);
       instance._filterRemovedMessages();
@@ -118,22 +118,22 @@
       });
     });
 
-    test('group reviewer updates', function() {
-      var reviewer1 = {_account_id: 1};
-      var reviewer2 = {_account_id: 2};
-      var date1 = '2017-01-26 12:11:50.000000000';
-      var date2 = '2017-01-26 12:11:55.000000000'; // Within threshold.
-      var date3 = '2017-01-26 12:33:50.000000000';
-      var date4 = '2017-01-26 12:44:50.000000000';
-      var makeItem = function(state, reviewer, opt_date, opt_author) {
+    test('group reviewer updates', () => {
+      const reviewer1 = {_account_id: 1};
+      const reviewer2 = {_account_id: 2};
+      const date1 = '2017-01-26 12:11:50.000000000';
+      const date2 = '2017-01-26 12:11:55.000000000'; // Within threshold.
+      const date3 = '2017-01-26 12:33:50.000000000';
+      const date4 = '2017-01-26 12:44:50.000000000';
+      const makeItem = function(state, reviewer, opt_date, opt_author) {
         return {
-          reviewer: reviewer,
+          reviewer,
           updated: opt_date || date1,
           updated_by: opt_author || reviewer1,
-          state: state,
+          state,
         };
       };
-      var change = {
+      let change = {
         reviewer_updates: [
           makeItem('REVIEWER', reviewer1), // New group.
           makeItem('CC', reviewer2), // Appended.
@@ -198,36 +198,36 @@
       ]);
     });
 
-    test('format reviewer updates', function() {
-      var reviewer1 = {_account_id: 1};
-      var reviewer2 = {_account_id: 2};
-      var makeItem = function(prev, state, opt_reviewer) {
+    test('format reviewer updates', () => {
+      const reviewer1 = {_account_id: 1};
+      const reviewer2 = {_account_id: 2};
+      const makeItem = function(prev, state, opt_reviewer) {
         return {
           reviewer: opt_reviewer || reviewer1,
           prev_state: prev,
-          state: state,
+          state,
         };
       };
-      var makeUpdate = function(items) {
+      const makeUpdate = function(items) {
         return {
           author: reviewer1,
           updated: '',
           updates: items,
         };
       };
-      var change = {
-          reviewer_updates: [
-            makeUpdate([
-              makeItem(undefined, 'CC'),
-              makeItem(undefined, 'CC', reviewer2)
-            ]),
-            makeUpdate([
-              makeItem('CC', 'REVIEWER'),
-              makeItem('REVIEWER', 'REMOVED'),
-              makeItem('REMOVED', 'REVIEWER'),
-              makeItem(undefined, 'REVIEWER', reviewer2),
-            ]),
-          ],
+      const change = {
+        reviewer_updates: [
+          makeUpdate([
+            makeItem(undefined, 'CC'),
+            makeItem(undefined, 'CC', reviewer2),
+          ]),
+          makeUpdate([
+            makeItem('CC', 'REVIEWER'),
+            makeItem('REVIEWER', 'REMOVED'),
+            makeItem('REMOVED', 'REVIEWER'),
+            makeItem(undefined, 'REVIEWER', reviewer2),
+          ]),
+        ],
       };
 
       instance = new GrReviewerUpdatesParser(change);
@@ -237,7 +237,7 @@
       assert.equal(change.reviewer_updates[0].updates.length, 1);
       assert.equal(change.reviewer_updates[1].updates.length, 3);
 
-      var items = change.reviewer_updates[0].updates;
+      let items = change.reviewer_updates[0].updates;
       assert.equal(items[0].message, 'added to CC: ');
       assert.deepEqual(items[0].reviewers, [reviewer1, reviewer2]);
 
@@ -249,5 +249,76 @@
       assert.equal(items[2].message, 'added to REVIEWER: ');
       assert.deepEqual(items[2].reviewers, [reviewer1, reviewer2]);
     });
+
+    test('_advanceUpdates', () => {
+      const T0 = util.parseDate('2017-02-17 19:04:18.000000000').getTime();
+      const tplus = delta => {
+        return new Date(T0 + delta)
+            .toISOString().replace('T', ' ').replace('Z', '000000');
+      };
+      const change = {
+        reviewer_updates: [{
+          date: tplus(0),
+          type: 'REVIEWER_UPDATE',
+          updates: [{
+            message: 'same time update',
+          }],
+        }, {
+          date: tplus(200),
+          type: 'REVIEWER_UPDATE',
+          updates: [{
+            message: 'update within threshold',
+          }],
+        }, {
+          date: tplus(600),
+          type: 'REVIEWER_UPDATE',
+          updates: [{
+            message: 'update between messages',
+          }],
+        }, {
+          date: tplus(1000),
+          type: 'REVIEWER_UPDATE',
+          updates: [{
+            message: 'late update',
+          }],
+        }],
+        messages: [{
+          id: '6734489eb9d642de28dbf2bcf9bda875923800d8',
+          date: tplus(0),
+          message: 'Uploaded patch set 1.',
+        }, {
+          id: '6734489eb9d642de28dbf2bcf9bda875923800d8',
+          date: tplus(800),
+          message: 'Uploaded patch set 2.',
+        }],
+      };
+      instance = new GrReviewerUpdatesParser(change);
+      instance._advanceUpdates();
+      const updates = instance.result.reviewer_updates;
+      assert.isBelow(util.parseDate(updates[0].date).getTime(), T0);
+      assert.isBelow(util.parseDate(updates[1].date).getTime(), T0);
+      assert.equal(updates[2].date, tplus(100));
+      assert.equal(updates[3].date, tplus(500));
+    });
+
+    test('_advanceUpdates endurance', () => {
+      const makeRubbishUpdate = () => ({
+        date: '2016-02-17 19:04:18.000000000',
+        type: 'REVIEWER_UPDATE',
+        updates: [{
+          message: 'update within threshold',
+        }],
+      });
+      const makeRubbishMessage = () => ({
+        id: '6734489eb9d642de28dbf2bcf9bda875923800d8',
+        date: '2016-02-17 19:04:18.000000000',
+        message: 'Uploaded patch set 2.',
+      });
+      instance = new GrReviewerUpdatesParser({
+        messages: _.times(500, makeRubbishMessage),
+        reviewer_updates: _.times(500, makeRubbishUpdate),
+      });
+      instance._advanceUpdates();
+    }).timeout(1000);
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/mock-diff-response_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/mock-diff-response_test.html
index 8141c8a..0385f64 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/mock-diff-response_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/mock-diff-response_test.html
@@ -22,44 +22,44 @@
     (function() {
       'use strict';
 
-      var RESPONSE = {
-        'meta_a': {
-          'name': 'lorem-ipsum.txt',
-          'content_type': 'text/plain',
-          'lines': 45,
+      const RESPONSE = {
+        meta_a: {
+          name: 'lorem-ipsum.txt',
+          content_type: 'text/plain',
+          lines: 45,
         },
-        'meta_b': {
-          'name': 'lorem-ipsum.txt',
-          'content_type': 'text/plain',
-          'lines': 48,
+        meta_b: {
+          name: 'lorem-ipsum.txt',
+          content_type: 'text/plain',
+          lines: 48,
         },
-        'intraline_status': 'OK',
-        'change_type': 'MODIFIED',
-        'diff_header': [
+        intraline_status: 'OK',
+        change_type: 'MODIFIED',
+        diff_header: [
           'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
           'index b2adcf4..554ae49 100644',
           '--- a/lorem-ipsum.txt',
           '+++ b/lorem-ipsum.txt',
         ],
-        'content': [
+        content: [
           {
-            'ab': [
+            ab: [
               'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ' +
                 'nulla phasellus.',
               'Mattis lectus.',
               'Sodales duis.',
               'Orci a faucibus.',
-            ]
+            ],
           },
           {
-            'b': [
+            b: [
               'Nullam neque, ligula ac, id blandit.',
               'Sagittis tincidunt torquent, tempor nunc amet.',
               'At rhoncus id.',
             ],
           },
           {
-            'ab': [
+            ab: [
               'Sem nascetur, erat ut, non in.',
               'A donec, venenatis pellentesque dis.',
               'Mauris mauris.',
@@ -68,7 +68,7 @@
             ],
           },
           {
-            'a': [
+            a: [
               'Est amet, vestibulum pellentesque.',
               'Erat ligula.',
               'Justo eros.',
@@ -76,25 +76,25 @@
             ],
           },
           {
-            'ab': [
+            ab: [
               'Arcu eget, rhoncus amet cursus, ipsum elementum.',
               'Eros suspendisse.',
             ],
           },
           {
-            'a': [
+            a: [
               'Rhoncus tempor, ultricies aliquam ipsum.',
             ],
-            'b': [
+            b: [
               'Rhoncus tempor, ultricies praesent ipsum.',
             ],
-            'edit_a': [
+            edit_a: [
               [
                 26,
                 7,
               ],
             ],
-            'edit_b': [
+            edit_b: [
               [
                 26,
                 8,
@@ -102,7 +102,7 @@
             ],
           },
           {
-            'ab': [
+            ab: [
               'Sollicitudin duis.',
               'Blandit blandit, ante nisl fusce.',
               'Felis ac at, tellus consectetuer.',
@@ -131,7 +131,7 @@
             ],
           },
           {
-            'b': [
+            b: [
               'Eu congue risus.',
               'Enim ac, quis elementum.',
               'Non et elit.',
@@ -139,7 +139,7 @@
             ],
           },
           {
-            'ab': [
+            ab: [
               'Nec at.',
               'Arcu mauris, venenatis lacus fermentum, praesent duis.',
               'Pellentesque amet et, tellus duis.',
@@ -155,7 +155,7 @@
         properties: {
           diffResponse: {
             type: Object,
-            value: function() {
+            value() {
               return RESPONSE;
             },
           },
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
index bef260e9..09787cb 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
@@ -26,21 +26,21 @@
     },
 
     listeners: {
-      change: '_valueChanged',
+      'change': '_valueChanged',
       'dom-change': '_updateValue',
     },
 
-    _updateValue: function() {
+    _updateValue() {
       if (this.bindValue) {
         this.value = this.bindValue;
       }
     },
 
-    _valueChanged: function() {
+    _valueChanged() {
       this.bindValue = this.value;
     },
 
-    ready: function() {
+    ready() {
       // If not set via the property, set bind-value to the element value.
       if (!this.bindValue) { this.bindValue = this.value; }
     },
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
index bd22505..83ae5af 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
@@ -36,15 +36,15 @@
 </test-fixture>
 
 <script>
-  suite('gr-select tests', function() {
-    var element;
+  suite('gr-select tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
     });
 
-    test('bidirectional binding property-to-attribute', function() {
-      var changeStub = sinon.stub();
+    test('bidirectional binding property-to-attribute', () => {
+      const changeStub = sinon.stub();
       element.addEventListener('bind-value-changed', changeStub);
 
       // The selected element should be the first one by default.
@@ -61,8 +61,8 @@
       assert.isTrue(changeStub.called);
     });
 
-    test('bidirectional binding attribute-to-property', function() {
-      var changeStub = sinon.stub();
+    test('bidirectional binding attribute-to-property', () => {
+      const changeStub = sinon.stub();
       element.addEventListener('bind-value-changed', changeStub);
 
       // The selected element should be the first one by default.
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
index 77d1c05..2e7f17c 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
@@ -15,10 +15,10 @@
   'use strict';
 
   // Date cutoff is one day:
-  var DRAFT_MAX_AGE = 24 * 60 * 60 * 1000;
+  const DRAFT_MAX_AGE = 24 * 60 * 60 * 1000;
 
   // Clean up old entries no more frequently than one day.
-  var CLEANUP_THROTTLE_INTERVAL = 24 * 60 * 60 * 1000;
+  const CLEANUP_THROTTLE_INTERVAL = 24 * 60 * 60 * 1000;
 
   Polymer({
     is: 'gr-storage',
@@ -27,7 +27,7 @@
       _lastCleanup: Number,
       _storage: {
         type: Object,
-        value: function() {
+        value() {
           return window.localStorage;
         },
       },
@@ -37,42 +37,43 @@
       },
     },
 
-    getDraftComment: function(location) {
+    getDraftComment(location) {
       this._cleanupDrafts();
       return this._getObject(this._getDraftKey(location));
     },
 
-    setDraftComment: function(location, message) {
-      var key = this._getDraftKey(location);
-      this._setObject(key, {message: message, updated: Date.now()});
+    setDraftComment(location, message) {
+      const key = this._getDraftKey(location);
+      this._setObject(key, {message, updated: Date.now()});
     },
 
-    eraseDraftComment: function(location) {
-      var key = this._getDraftKey(location);
+    eraseDraftComment(location) {
+      const key = this._getDraftKey(location);
       this._storage.removeItem(key);
     },
 
-    getPreferences: function() {
+    getPreferences() {
       return this._getObject('localPrefs');
     },
 
-    savePreferences: function(localPrefs) {
+    savePreferences(localPrefs) {
       this._setObject('localPrefs', localPrefs || null);
     },
 
-    _getDraftKey: function(location) {
-      var range = location.range ? location.range.start_line + '-' +
-          location.range.start_character + '-' + location.range.end_character +
-          '-' + location.range.end_line : null;
-      var key = ['draft', location.changeNum, location.patchNum, location.path,
-          location.line || ''].join(':');
+    _getDraftKey(location) {
+      const range = location.range ?
+          `${location.range.start_line}-${location.range.start_character}` +
+              `-${location.range.end_character}-${location.range.end_line}` :
+          null;
+      let key = ['draft', location.changeNum, location.patchNum, location.path,
+        location.line || ''].join(':');
       if (range) {
         key = key + ':' + range;
       }
       return key;
     },
 
-    _cleanupDrafts: function() {
+    _cleanupDrafts() {
       // Throttle cleanup to the throttle interval.
       if (this._lastCleanup &&
           Date.now() - this._lastCleanup < CLEANUP_THROTTLE_INTERVAL) {
@@ -80,9 +81,9 @@
       }
       this._lastCleanup = Date.now();
 
-      var draft;
-      for (var key in this._storage) {
-        if (key.indexOf('draft:') === 0) {
+      let draft;
+      for (const key in this._storage) {
+        if (key.startsWith('draft:')) {
           draft = this._getObject(key);
           if (Date.now() - draft.updated > DRAFT_MAX_AGE) {
             this._storage.removeItem(key);
@@ -91,13 +92,13 @@
       }
     },
 
-    _getObject: function(key) {
-      var serial = this._storage.getItem(key);
+    _getObject(key) {
+      const serial = this._storage.getItem(key);
       if (!serial) { return null; }
       return JSON.parse(serial);
     },
 
-    _setObject: function(key, obj) {
+    _setObject(key, obj) {
       if (this._exceededQuota) { return; }
       try {
         this._storage.setItem(key, JSON.stringify(obj));
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
index 6d77c55..4171939 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
@@ -31,43 +31,44 @@
 </test-fixture>
 
 <script>
-  suite('gr-storage tests', function() {
-    var element;
+  suite('gr-storage tests', () => {
+    let element;
 
     function mockStorage(opt_quotaExceeded) {
       return {
-        getItem: function(key) { return this[key]; },
-        removeItem: function(key) { delete this[key]; },
-        setItem: function(key, value) {
+        getItem(key) { return this[key]; },
+        removeItem(key) { delete this[key]; },
+        setItem(key, value) {
+          // eslint-disable-next-line no-throw-literal
           if (opt_quotaExceeded) { throw {code: 22}; /* Quota exceeded */ }
           this[key] = value;
         },
       };
     }
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       element._storage = mockStorage();
     });
 
-    test('storing, retrieving and erasing drafts', function() {
-      var changeNum = 1234;
-      var patchNum = 5;
-      var path = 'my_source_file.js';
-      var line = 123;
-      var location = {
-        changeNum: changeNum,
-        patchNum: patchNum,
-        path: path,
-        line: line,
+    test('storing, retrieving and erasing drafts', () => {
+      const changeNum = 1234;
+      const patchNum = 5;
+      const path = 'my_source_file.js';
+      const line = 123;
+      const location = {
+        changeNum,
+        patchNum,
+        path,
+        line,
       };
 
       // The key is in the expected format.
-      var key = element._getDraftKey(location);
+      const key = element._getDraftKey(location);
       assert.equal(key, ['draft', changeNum, patchNum, path, line].join(':'));
 
       // There should be no draft initially.
-      var draft = element.getDraftComment(location);
+      const draft = element.getDraftComment(location);
       assert.isNotOk(draft);
 
       // Setting the draft stores it under the expected key.
@@ -82,24 +83,24 @@
       assert.isNotOk(element._storage.getItem(key));
     });
 
-    test('automatically removes old drafts', function() {
-      var changeNum = 1234;
-      var patchNum = 5;
-      var path = 'my_source_file.js';
-      var line = 123;
-      var location = {
-        changeNum: changeNum,
-        patchNum: patchNum,
-        path: path,
-        line: line,
+    test('automatically removes old drafts', () => {
+      const changeNum = 1234;
+      const patchNum = 5;
+      const path = 'my_source_file.js';
+      const line = 123;
+      const location = {
+        changeNum,
+        patchNum,
+        path,
+        line,
       };
 
-      var key = element._getDraftKey(location);
+      const key = element._getDraftKey(location);
 
       // Make sure that the call to cleanup doesn't get throttled.
       element._lastCleanup = 0;
 
-      var cleanupSpy = sinon.spy(element, '_cleanupDrafts');
+      const cleanupSpy = sinon.spy(element, '_cleanupDrafts');
 
       // Create a message with a timestamp that is a second behind the max age.
       element._storage.setItem(key, JSON.stringify({
@@ -108,7 +109,7 @@
       }));
 
       // Getting the draft should cause it to be removed.
-      var draft = element.getDraftComment(location);
+      const draft = element.getDraftComment(location);
 
       assert.isTrue(cleanupSpy.called);
       assert.isNotOk(draft);
@@ -117,18 +118,18 @@
       cleanupSpy.restore();
     });
 
-    test('_getDraftKey', function() {
-      var changeNum = 1234;
-      var patchNum = 5;
-      var path = 'my_source_file.js';
-      var line = 123;
-      var location = {
-        changeNum: changeNum,
-        patchNum: patchNum,
-        path: path,
-        line: line,
+    test('_getDraftKey', () => {
+      const changeNum = 1234;
+      const patchNum = 5;
+      const path = 'my_source_file.js';
+      const line = 123;
+      const location = {
+        changeNum,
+        patchNum,
+        path,
+        line,
       };
-      var expectedResult = 'draft:1234:5:my_source_file.js:123';
+      let expectedResult = 'draft:1234:5:my_source_file.js:123';
       assert.equal(element._getDraftKey(location), expectedResult);
       location.range = {
         start_character: 1,
@@ -140,21 +141,21 @@
       assert.equal(element._getDraftKey(location), expectedResult);
     });
 
-    test('exceeded quota disables storage', function() {
+    test('exceeded quota disables storage', () => {
       element._storage = mockStorage(true);
       assert.isFalse(element._exceededQuota);
 
-      var changeNum = 1234;
-      var patchNum = 5;
-      var path = 'my_source_file.js';
-      var line = 123;
-      var location = {
-        changeNum: changeNum,
-        patchNum: patchNum,
-        path: path,
-        line: line,
+      const changeNum = 1234;
+      const patchNum = 5;
+      const path = 'my_source_file.js';
+      const line = 123;
+      const location = {
+        changeNum,
+        patchNum,
+        path,
+        line,
       };
-      var key = element._getDraftKey(location);
+      const key = element._getDraftKey(location);
       element.setDraftComment(location, 'my comment');
       assert.isTrue(element._exceededQuota);
       assert.isNotOk(element._storage.getItem(key));
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html
new file mode 100644
index 0000000..8d0b1e3
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html
@@ -0,0 +1,77 @@
+<!--
+Copyright (C) 2017 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.
+-->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html">
+<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../../bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">
+<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+
+<dom-module id="gr-textarea">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      :host(.monospace) {
+        font-family: var(--monospace-font-family);
+      }
+      gr-autocomplete {
+        display: inline-block
+      }
+      #textarea {
+        background-color: var(--background-color, none);
+        width: 100%;
+      }
+      /*This is needed to not add a scroll bar on the side of gr-textarea
+      since there is 2px of padding in iron-autogrow-textarea for the
+      native textarea*/
+      iron-autogrow-textarea {
+        padding: 2px;
+      }
+      #textarea.noBorder {
+        border: none;
+      }
+      #hiddenText {
+        display: block;
+        float: left;
+        position: absolute;
+        visibility: hidden;
+        white-space: pre-wrap
+      }
+    </style>
+    <gr-autocomplete-dropdown id="emojiSuggestions"
+        suggestions="[[_suggestions]]"
+        index="[[_index]]"
+        move-to-root
+        fixed-position="[[fixedPositionDropdown]]"
+        hidden>
+    </gr-autocomplete-dropdown>
+    <div id="hiddenText"></div>
+    <iron-autogrow-textarea
+        id="textarea"
+        autocomplete="[[autocomplete]]"
+        placeholder=[[placeholder]]
+        disabled="[[disabled]]"
+        rows="[[rows]]"
+        max-rows="[[maxRows]]"
+        bind-value="{{text}}"
+        on-bind-value-changed="_onValueChanged"></iron-autogrow-textarea>
+  </template>
+  <script src="gr-textarea.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
new file mode 100644
index 0000000..9158a57
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
@@ -0,0 +1,320 @@
+// Copyright (C) 2017 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.
+(function() {
+  'use strict';
+
+  const MAX_ITEMS_DROPDOWN = 10;
+  const VERTICAL_OFFSET = 7;
+
+  const ALL_SUGGESTIONS = [
+    {value: '💯', match: '100'},
+    {value: '💔', match: 'broken heart'},
+    {value: '🍺', match: 'beer'},
+    {value: '✔', match: 'check'},
+    {value: '😎', match: 'cool'},
+    {value: '😕', match: 'confused'},
+    {value: '😭', match: 'crying'},
+    {value: '🔥', match: 'fire'},
+    {value: '👊', match: 'fistbump'},
+    {value: '🐨', match: 'koala'},
+    {value: '😄', match: 'laugh'},
+    {value: '🤓', match: 'glasses'},
+    {value: '😆', match: 'grin'},
+    {value: '😐', match: 'neutral'},
+    {value: '👌', match: 'ok'},
+    {value: '🎉', match: 'party'},
+    {value: '💩', match: 'poop'},
+    {value: '🙏', match: 'pray'},
+    {value: '😞', match: 'sad'},
+    {value: '😮', match: 'shock'},
+    {value: '😊', match: 'smile'},
+    {value: '😢', match: 'tear'},
+    {value: '😂', match: 'tears'},
+    {value: '😋', match: 'tongue'},
+    {value: '👍', match: 'thumbs up'},
+    {value: '👎', match: 'thumbs down'},
+    {value: '😒', match: 'unamused'},
+    {value: '😉', match: 'wink'},
+    {value: '🍷', match: 'wine'},
+    {value: '😜', match: 'winking tongue'},
+  ];
+
+  Polymer({
+    is: 'gr-textarea',
+
+    properties: {
+      autocomplete: Boolean,
+      disabled: Boolean,
+      rows: Number,
+      maxRows: Number,
+      placeholder: String,
+      fixedPositionDropdown: Boolean,
+      moveToRoot: Boolean,
+      text: {
+        type: String,
+        notify: true,
+      },
+      backgroundColor: {
+        type: String,
+        value: '#fff',
+      },
+      hideBorder: {
+        type: Boolean,
+        value: false,
+      },
+      monospace: {
+        type: Boolean,
+        value: false,
+      },
+      _colonIndex: Number,
+      _currentSearchString: {
+        type: String,
+        value: '',
+        observer: '_determineSuggestions',
+      },
+      _hideAutocomplete: {
+        type: Boolean,
+        value: true,
+      },
+      _index: Number,
+      _suggestions: Array,
+    },
+
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+    ],
+
+    keyBindings: {
+      esc: '_handleEscKey',
+      tab: '_handleEnterByKey',
+      enter: '_handleEnterByKey',
+      up: '_handleUpKey',
+      down: '_handleDownKey',
+    },
+
+    ready() {
+      this._resetEmojiDropdown();
+      if (this.monospace) {
+        this.classList.add('monospace');
+      }
+      if (this.hideBorder) {
+        this.$.textarea.classList.add('noBorder');
+      }
+      if (this.backgroundColor) {
+        this.customStyle['--background-color'] = this.backgroundColor;
+        this.updateStyles();
+      }
+      this.listen(this.$.emojiSuggestions, 'dropdown-closed', '_resetAndFocus');
+      this.listen(this.$.emojiSuggestions, 'item-selected',
+          '_handleEmojiSelect');
+    },
+
+    detached() {
+      this.closeDropdown();
+      this.listen(this.$.emojiSuggestions, 'dropdown-closed', '_resetAndFocus');
+      this.listen(this.$.emojiSuggestions, 'item-selected',
+          '_handleEmojiSelect');
+    },
+
+    closeDropdown() {
+      if (!this.$.emojiSuggestions.hidden) {
+        this._closeEmojiDropdown();
+      }
+    },
+
+    getNativeTextarea() {
+      return this.$.textarea.textarea;
+    },
+
+    putCursorAtEnd() {
+      const textarea = this.getNativeTextarea();
+      // Put the cursor at the end always.
+      textarea.selectionStart = textarea.value.length;
+      textarea.selectionEnd = textarea.selectionStart;
+      this.async(() => {
+        textarea.focus();
+      });
+    },
+
+    _handleEscKey(e) {
+      if (this._hideAutocomplete) { return; }
+      e.preventDefault();
+      e.stopPropagation();
+      this._resetAndFocus();
+    },
+
+    _resetAndFocus() {
+      this._resetEmojiDropdown();
+      this.$.textarea.textarea.focus();
+    },
+
+    _handleUpKey(e) {
+      if (this._hideAutocomplete) { return; }
+      e.preventDefault();
+      e.stopPropagation();
+      this.$.emojiSuggestions.cursorUp();
+    },
+
+    _handleDownKey(e) {
+      if (this._hideAutocomplete) { return; }
+      e.preventDefault();
+      e.stopPropagation();
+      this.$.emojiSuggestions.cursorDown();
+    },
+
+    _handleEnterByKey(e) {
+      if (this._hideAutocomplete) { return; }
+      e.preventDefault();
+      e.stopPropagation();
+      this.text = this._getText(this.$.emojiSuggestions.getCurrentText());
+      this._resetEmojiDropdown();
+    },
+
+    _handleEmojiSelect(e) {
+      this.text = this._getText(e.detail.selected.dataset.value);
+      this._resetEmojiDropdown();
+    },
+
+    _getText(value) {
+      return this.text.substr(0, this._colonIndex) +
+          value + this.text.substr(this.$.textarea.selectionStart) + ' ';
+    },
+
+    _getPositionOfCursor() {
+      this.$.hiddenText.textContent = this.$.textarea.value.substr(0,
+          this.$.textarea.selectionStart);
+
+      const caratSpan = document.createElement('span');
+      this.$.hiddenText.appendChild(caratSpan);
+      return caratSpan.getBoundingClientRect();
+    },
+
+    _getFontSize() {
+      const fontSizePx = getComputedStyle(this).fontSize || '12px';
+      return parseInt(fontSizePx.substr(0, fontSizePx.length - 2),
+          10);
+    },
+
+    _getScrollTop() {
+      return document.body.scrollTop;
+    },
+
+    /**
+     * This positions the dropdown to be just below the cursor position. It is
+     * calculated by having a hidden element with the same width and styling of
+     * the tetarea and the text up until the point of interest. Then a span
+     * element is added to the end so that there is a specific element to get
+     * the position of.  Line height is determined (or falls back to 12px) as
+     * extra height to add.
+     */
+    _updateSelectorPosition() {
+      // These are broken out into separate functions for testability.
+      const caratPosition = this._getPositionOfCursor();
+      const fontSize = this._getFontSize();
+
+      let top = caratPosition.top + fontSize + VERTICAL_OFFSET;
+
+      if (!this.fixedPositionDropdown) {
+        top += this._getScrollTop();
+      }
+      top += 'px';
+      const left = caratPosition.left + 'px';
+      this.$.emojiSuggestions.setPosition(top, left);
+    },
+
+    /**
+     * _handleKeydown used for key handling in the this.$.textarea AND all child
+     * autocomplete options.
+     */
+    _onValueChanged(e) {
+      // If cursor is not in textarea (just opened with colon as last char),
+      // Don't do anything.
+      if (!e.currentTarget.focused) { return; }
+      const newChar = e.detail.value[this.$.textarea.selectionStart - 1];
+
+      // When a colon is detected, set a colon index, but don't do anything else
+      // yet.
+      if (newChar === ':') {
+        this._colonIndex = this.$.textarea.selectionStart - 1;
+      // If the colon index exists, continue to determine what needs to be done
+      // with the dropdown. It may be open or closed at this point.
+      } else if (this._colonIndex !== null) {
+        // The search string is a substring of the textarea's value from (1
+        // position after) the colon index to the cursor position.
+        this._currentSearchString = e.detail.value.substr(this._colonIndex + 1,
+            this.$.textarea.selectionStart);
+        // Under the following conditions, close and reset the dropdown:
+        // - The cursor is no longer at the end of the current search string
+        // - The search string is an space or new line
+        // - The colon has been removed
+        // - There are no suggestions that match the search string
+        if (this.$.textarea.selectionStart !==
+            this._currentSearchString.length + this._colonIndex + 1 ||
+            this._currentSearchString === ' ' ||
+            this._currentSearchString === '\n' ||
+            !e.detail.value[this._colonIndex] === ':' ||
+            !this._suggestions.length) {
+          this._resetEmojiDropdown();
+        // Otherwise open the dropdown and set the position to be just below the
+        // cursor.
+        } else if (this.$.emojiSuggestions.hidden) {
+          this._hideAutocomplete = false;
+          this._openEmojiDropdown();
+          this._updateSelectorPosition();
+        }
+        this.$.textarea.textarea.focus();
+      }
+    },
+
+    _closeEmojiDropdown() {
+      this.$.emojiSuggestions.close();
+      this.$.emojiSuggestions.hidden = true;
+    },
+
+    _openEmojiDropdown() {
+      this.$.emojiSuggestions.open();
+      this.$.emojiSuggestions.hidden = false;
+    },
+
+    _formatSuggestions(matchedSuggestions) {
+      const suggestions = [];
+      for (const suggestion of matchedSuggestions) {
+        suggestion.dataValue = suggestion.value;
+        suggestion.text = suggestion.value + ' ' + suggestion.match;
+        suggestions.push(suggestion);
+      }
+      this._suggestions = suggestions;
+    },
+
+    _determineSuggestions(emojiText) {
+      if (!emojiText.length) {
+        this._formatSuggestions(ALL_SUGGESTIONS);
+      }
+      const matches = ALL_SUGGESTIONS.filter(suggestion => {
+        return suggestion.match.includes(emojiText);
+      }).splice(0, MAX_ITEMS_DROPDOWN);
+      this._formatSuggestions(matches);
+    },
+
+    _resetEmojiDropdown() {
+      // hide and reset the autocomplete dropdown.
+      Polymer.dom.flush();
+      this._currentSearchString = '';
+      this._hideAutocomplete = true;
+      this.closeDropdown();
+      this._colonIndex = null;
+      this.$.textarea.textarea.focus();
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html
new file mode 100644
index 0000000..1c5dea3
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html
@@ -0,0 +1,254 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-textarea</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="gr-textarea.html">
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-textarea></gr-textarea>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-textarea tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('monospace is set properly', () => {
+      assert.isFalse(element.classList.contains('monospace'));
+      element.monospace = true;
+      element.ready();
+      assert.isTrue(element.classList.contains('monospace'));
+    });
+
+    test('hideBorder is set properly', () => {
+      assert.isFalse(element.$.textarea.classList.contains('noBorder'));
+      element.hideBorder = true;
+      element.ready();
+      assert.isTrue(element.$.textarea.classList.contains('noBorder'));
+    });
+
+    test('background color is set properly', () => {
+      assert.equal(getComputedStyle(element.$.textarea).backgroundColor,
+          'rgb(255, 255, 255)');
+      element.backgroundColor = 'pink';
+      element.ready();
+      assert.equal(getComputedStyle(element.$.textarea).backgroundColor,
+          'rgb(255, 192, 203)');
+    });
+
+    test('emoji selector is not open with the textarea lacks focus', () => {
+      element.$.textarea.selectionStart = 1;
+      element.$.textarea.selectionEnd = 1;
+      element.text = ':';
+      assert.isFalse(!element.$.emojiSuggestions.hidden);
+    });
+
+    test('emoji selector is not open when a general text is entered', () => {
+      MockInteractions.focus(element.$.textarea);
+      element.$.textarea.selectionStart = 9;
+      element.$.textarea.selectionEnd = 9;
+      element.text = 'some text';
+      assert.isFalse(!element.$.emojiSuggestions.hidden);
+    });
+
+    test('emoji selector opens when a colon is typed & the textarea has focus',
+        () => {
+          MockInteractions.focus(element.$.textarea);
+          // Needed for Safari tests. selectionStart is not updated when text is
+          // updated.
+          element.$.textarea.selectionStart = 1;
+          element.$.textarea.selectionEnd = 1;
+          element.text = ':';
+          element.$.textarea.selectionStart = 2;
+          element.$.textarea.selectionEnd = 2;
+          element.text = ':t';
+          flushAsynchronousOperations();
+          assert.isFalse(element.$.emojiSuggestions.hidden);
+          assert.equal(element._colonIndex, 0);
+          assert.isFalse(element._hideAutocomplete);
+          assert.equal(element._currentSearchString, 't');
+        });
+
+    test('emoji selector closes when text changes before the colon', () => {
+      const resetStub = sandbox.stub(element, '_resetEmojiDropdown');
+      MockInteractions.focus(element.$.textarea);
+      flushAsynchronousOperations();
+      element.$.textarea.selectionStart = 10;
+      element.$.textarea.selectionEnd = 10;
+      element.text = 'test test ';
+      element.$.textarea.selectionStart = 12;
+      element.$.textarea.selectionEnd = 12;
+      element.text = 'test test :';
+      element.$.textarea.selectionStart = 15;
+      element.$.textarea.selectionEnd = 15;
+      element.text = 'test test :smi';
+
+      assert.equal(element._currentSearchString, 'smi');
+      assert.isFalse(resetStub.called);
+      element.text = 'test test test :smi';
+      assert.isTrue(resetStub.called);
+    });
+
+    test('_resetEmojiDropdown', () => {
+      const closeSpy = sandbox.spy(element, 'closeDropdown');
+      element._resetEmojiDropdown();
+      assert.equal(element._currentSearchString, '');
+      assert.isTrue(element._hideAutocomplete);
+      assert.equal(element._colonIndex, null);
+
+      element.$.emojiSuggestions.open();
+      flushAsynchronousOperations();
+      element._resetEmojiDropdown();
+      assert.isTrue(closeSpy.called);
+    });
+
+    test('_determineSuggestions', () => {
+      const emojiText = 'tear';
+      const formatSpy = sandbox.spy(element, '_formatSuggestions');
+      element._determineSuggestions(emojiText);
+      assert.isTrue(formatSpy.called);
+      assert.isTrue(formatSpy.lastCall.calledWithExactly(
+          [{dataValue: '😢', value: '😢', match: 'tear', text: '😢 tear'},
+          {dataValue: '😂', value: '😂', match: 'tears', text: '😂 tears'}]));
+    });
+
+    test('_formatSuggestions', () => {
+      const matchedSuggestions = [{value: '😢', match: 'tear'},
+          {value: '😂', match: 'tears'}];
+      element._formatSuggestions(matchedSuggestions);
+      assert.deepEqual(
+          [{value: '😢', dataValue: '😢', match: 'tear', text: '😢 tear'},
+          {value: '😂', dataValue: '😂', match: 'tears', text: '😂 tears'}],
+          element._suggestions);
+    });
+
+    test('_handleEmojiSelect', () => {
+      element.$.textarea.selectionStart = 16;
+      element.$.textarea.selectionEnd = 16;
+      element.text = 'test test :tears';
+      element._colonIndex = 10;
+      const selectedItem = {dataset: {value: '😂'}};
+      const event = {detail: {selected: selectedItem}};
+      element._handleEmojiSelect(event);
+      assert.equal(element.text, 'test test 😂 ');
+    });
+
+    test('_getPositionOfCursor', () => {
+      element.$.textarea.selectionStart = 4;
+      element.$.textarea.selectionEnd = 4;
+      element.text = 'test';
+      element._getPositionOfCursor();
+      assert.deepEqual(element.$.hiddenText.innerHTML, element.text +
+          '<span></span>');
+    });
+
+    test('_updateSelectorPosition', () => {
+      const setPositionSpy =
+          sandbox.spy(element.$.emojiSuggestions, 'setPosition');
+      sandbox.stub(element, '_getPositionOfCursor', () => {
+        return {top: 100, left: 30};
+      });
+      sandbox.stub(element, '_getFontSize', () => 12);
+      sandbox.stub(element, '_getScrollTop', () => 100);
+      element._updateSelectorPosition();
+      assert.isTrue(setPositionSpy.lastCall.calledWithExactly('219px', '30px'));
+
+      element.fixedPositionDropdown = true;
+      element._updateSelectorPosition();
+      assert.isTrue(setPositionSpy.lastCall.calledWithExactly('119px', '30px'));
+    });
+
+    test('emoji dropdown is closed when dropdown-closed is fired', () => {
+      const resetSpy = sandbox.spy(element, '_resetAndFocus');
+      element.$.emojiSuggestions.fire('dropdown-closed');
+      assert.isTrue(resetSpy.called);
+    });
+
+    suite('keyboard shortcuts', () => {
+      function setupDropdown() {
+        MockInteractions.focus(element.$.textarea);
+        flushAsynchronousOperations();
+        element.$.textarea.selectionStart = 1;
+        element.$.textarea.selectionEnd = 1;
+        element.text = ':';
+        element.$.textarea.selectionStart = 1;
+        element.$.textarea.selectionEnd = 1;
+        element.text = ':1';
+      }
+
+      test('escape key', () => {
+        const resestSpy = sandbox.spy(element, '_resetAndFocus');
+        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
+        assert.isFalse(resestSpy.called);
+        setupDropdown();
+        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
+        assert.isTrue(resestSpy.called);
+        assert.isFalse(!element.$.emojiSuggestions.hidden);
+      });
+
+      test('up key', () => {
+        const upSpy = sandbox.spy(element.$.emojiSuggestions, 'cursorUp');
+        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
+        assert.isFalse(upSpy.called);
+        setupDropdown();
+        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
+        assert.isTrue(upSpy.called);
+      });
+
+      test('down key', () => {
+        const downSpy = sandbox.spy(element.$.emojiSuggestions, 'cursorDown');
+        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
+        assert.isFalse(downSpy.called);
+        setupDropdown();
+        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
+        assert.isTrue(downSpy.called);
+      });
+
+      test('enter key', () => {
+        const enterSpy = sandbox.spy(element.$.emojiSuggestions,
+            'getCursorTarget');
+        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+        assert.isFalse(enterSpy.called);
+        setupDropdown();
+        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+        assert.isTrue(enterSpy.called);
+        flushAsynchronousOperations();
+        // A space is automatically added at the end.
+        assert.equal(element.text, '💯 ');
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html
index 81e65e3..58f8e39 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html
@@ -19,8 +19,8 @@
 
 <dom-module id="gr-tooltip-content">
   <template>
-    <content></content>
-    <span class="arrow" hidden$="[[!showIcon]]">&#9432;</span>
+    <content></content><!--
+ --><span class="arrow" hidden$="[[!showIcon]]">&#9432;</span>
   </template>
   <script src="gr-tooltip-content.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html
index aac2ea8..35c9a61 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html
@@ -32,18 +32,18 @@
 </test-fixture>
 
 <script>
-  suite('gr-tooltip-content tests', function() {
-    var element;
-    setup(function() {
+  suite('gr-tooltip-content tests', () => {
+    let element;
+    setup(() => {
       element = fixture('basic');
     });
 
-    test('icon is not visible by default', function() {
+    test('icon is not visible by default', () => {
       assert.equal(Polymer.dom(element.root)
           .querySelector('.arrow').hidden, true);
     });
 
-    test('icon is visible with showIcon property', function() {
+    test('icon is visible with showIcon property', () => {
       element.showIcon = true;
       assert.equal(Polymer.dom(element.root)
           .querySelector('.arrow').hidden, false);
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
index 4a5f631..30038e4 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
@@ -25,7 +25,7 @@
       },
     },
 
-    _updateWidth: function(maxWidth) {
+    _updateWidth(maxWidth) {
       this.customStyle['--tooltip-max-width'] = maxWidth;
       this.updateStyles();
     },
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html
index 69a5b75..efc2a00 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html
@@ -32,13 +32,13 @@
 </test-fixture>
 
 <script>
-  suite('gr-tooltip tests', function() {
-    var element;
-    setup(function() {
+  suite('gr-tooltip tests', () => {
+    let element;
+    setup(() => {
       element = fixture('basic');
     });
 
-    test('max-width is respected if set', function() {
+    test('max-width is respected if set', () => {
       element.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit' +
           ', sed do eiusmod tempor incididunt ut labore et dolore magna aliqua';
       element.maxWidth = '50px';
diff --git a/polygerrit-ui/app/elements/test/plugin.html b/polygerrit-ui/app/elements/test/plugin.html
new file mode 100644
index 0000000..bd29b90
--- /dev/null
+++ b/polygerrit-ui/app/elements/test/plugin.html
@@ -0,0 +1,18 @@
+<dom-module id="my-plugin">
+  <script>
+    Gerrit.install(plugin =>
+      plugin.registerStyleModule('app-theme', 'myplugin-app-theme')
+    );
+  </script>
+</dom-module>
+
+<dom-module id="myplugin-app-theme">
+  <style>
+    html {
+      --primary-text-color: #F00BAA;
+      --header-background-color: #F01BAA;
+      --header-title-content: "MyGerrit";
+      --footer-background-color: #F02BAA;
+    }
+  </style>
+</dom-module>
diff --git a/polygerrit-ui/app/lint_test.sh b/polygerrit-ui/app/lint_test.sh
new file mode 100755
index 0000000..35939ba
--- /dev/null
+++ b/polygerrit-ui/app/lint_test.sh
@@ -0,0 +1,22 @@
+#!/bin/sh
+
+set -ex
+
+eslint_bin=$(which npm)
+if [ -z "$eslint_bin" ]; then
+    echo "NPM must be on the path."
+    exit 1
+fi
+
+eslint_bin=$(which eslint)
+eslint_config=$(npm list -g | grep -c eslint-config-google)
+eslint_plugin=$(npm list -g | grep -c eslint-plugin-html)
+if [ -z "$eslint_bin" ] || [ "$eslint_config" -eq "0" ] || [ "$eslint_plugin" -eq "0" ]; then
+    echo "You must install ESLint and its dependencies from NPM."
+    echo "> npm install -g eslint eslint-config-google eslint-plugin-html"
+    echo "For more information, view the README:"
+    echo "https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/#Style-guide"
+    exit 1
+fi
+
+${eslint_bin} --ignore-pattern 'bower_components/' --ignore-pattern 'gr-linked-text' --ignore-pattern 'scripts/vendor' --ext .html,.js .
diff --git a/polygerrit-ui/app/polylint_test.sh b/polygerrit-ui/app/polylint_test.sh
new file mode 100755
index 0000000..4bd1600
--- /dev/null
+++ b/polygerrit-ui/app/polylint_test.sh
@@ -0,0 +1,20 @@
+#!/bin/sh
+
+set -ex
+
+npm_bin=$(which npm)
+if [[ -z "$npm_bin" ]]; then
+    echo "NPM must be on the path."
+    exit 1
+fi
+
+polylint_bin=$(which polylint)
+if [[ -z "$polylint_bin" ]]; then
+    echo "You must install polylint and its dependencies from NPM."
+    echo "> npm install -g polylint"
+    exit 1
+fi
+
+unzip polygerrit-ui/polygerrit_components.bower_components.zip -d polygerrit-ui/app
+
+${polylint_bin} --root polygerrit-ui/app --input elements/gr-app.html
diff --git a/polygerrit-ui/app/scripts/hiddenscroll.js b/polygerrit-ui/app/scripts/hiddenscroll.js
new file mode 100644
index 0000000..b80742a
--- /dev/null
+++ b/polygerrit-ui/app/scripts/hiddenscroll.js
@@ -0,0 +1,29 @@
+// Copyright (C) 2017 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.
+
+(function(window) {
+  window.Gerrit = window.Gerrit || {};
+  if (window.Gerrit.hasOwnProperty('hiddenscroll')) { return; }
+
+  window.Gerrit.hiddenscroll = undefined;
+
+  window.addEventListener('WebComponentsReady', () => {
+    const elem = document.createElement('div');
+    elem.setAttribute(
+        'style', 'width:100px;height:100px;overflow:scroll');
+    document.body.appendChild(elem);
+    window.Gerrit.hiddenscroll = elem.offsetWidth === elem.clientWidth;
+    elem.remove();
+  });
+})(window);
diff --git a/polygerrit-ui/app/scripts/rootElement.js b/polygerrit-ui/app/scripts/rootElement.js
new file mode 100644
index 0000000..1a07edf
--- /dev/null
+++ b/polygerrit-ui/app/scripts/rootElement.js
@@ -0,0 +1,20 @@
+// Copyright (C) 2017 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.
+
+(function(window) {
+  window.Gerrit = window.Gerrit || {};
+  if (window.Gerrit.hasOwnProperty('getRootElement')) { return; }
+
+  window.Gerrit.getRootElement = () => document.body;
+})(window);
\ No newline at end of file
diff --git a/polygerrit-ui/app/scripts/util.js b/polygerrit-ui/app/scripts/util.js
index 6c83905..07b543f 100644
--- a/polygerrit-ui/app/scripts/util.js
+++ b/polygerrit-ui/app/scripts/util.js
@@ -14,7 +14,7 @@
 (function(window) {
   'use strict';
 
-  var util = window.util || {};
+  const util = window.util || {};
 
   util.parseDate = function(dateStr) {
     // Timestamps are given in UTC and have the format
@@ -25,14 +25,14 @@
   };
 
   util.getCookie = function(name) {
-    var key = name + '=';
-    var cookies = document.cookie.split(';');
-    for (var i = 0; i < cookies.length; i++) {
-      var c = cookies[i];
-      while (c.charAt(0) == ' ') {
+    const key = name + '=';
+    const cookies = document.cookie.split(';');
+    for (let i = 0; i < cookies.length; i++) {
+      let c = cookies[i];
+      while (c.charAt(0) === ' ') {
         c = c.substring(1);
       }
-      if (c.indexOf(key) == 0) {
+      if (c.startsWith(key)) {
         return c.substring(key.length, c.length);
       }
     }
@@ -50,7 +50,7 @@
    * @return {String} Returns the truncated value of a URL.
    */
   util.truncatePath = function(path) {
-    var pathPieces = path.split('/');
+    const pathPieces = path.split('/');
 
     if (pathPieces.length < 2) {
       return path;
diff --git a/polygerrit-ui/app/styles/app-theme.html b/polygerrit-ui/app/styles/app-theme.html
index 773b341..c998f03 100644
--- a/polygerrit-ui/app/styles/app-theme.html
+++ b/polygerrit-ui/app/styles/app-theme.html
@@ -15,7 +15,15 @@
 -->
 <style is="custom-style">
 :root {
+  /* Following vars have LTS for plugin API. */
   --primary-text-color: #000;
+  --header-background-color: #eee;
+  --header-title-content: 'PolyGerrit';
+  --header-icon: none;
+  --header-icon-size: 0em;
+  --footer-background-color: var(--header-background-color);
+
+  /* Following are not part of plugin API. */
   --search-border-color: #ddd;
   --selection-background-color: #ebf5fb;
   --default-text-color: #000;
@@ -23,7 +31,6 @@
   --default-horizontal-margin: 1rem;
   --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
   --monospace-font-family: 'Source Code Pro', Menlo, 'Lucida Console', Monaco, monospace;
-
   --iron-overlay-backdrop: {
     transition: none;
   };
diff --git a/polygerrit-ui/app/styles/gr-settings-styles.html b/polygerrit-ui/app/styles/gr-form-styles.html
similarity index 60%
rename from polygerrit-ui/app/styles/gr-settings-styles.html
rename to polygerrit-ui/app/styles/gr-form-styles.html
index fcda1b4..bf5eb43 100644
--- a/polygerrit-ui/app/styles/gr-settings-styles.html
+++ b/polygerrit-ui/app/styles/gr-form-styles.html
@@ -13,43 +13,58 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<dom-module id="gr-settings-styles">
+<dom-module id="gr-form-styles">
   <template>
     <style>
-      .gr-settings-styles fieldset {
+      .gr-form-styles h1 {
+        margin-bottom: .1em;
+      }
+      .gr-form-styles fieldset {
         border: none;
         margin: 0 0 2em 2em;
       }
-      .gr-settings-styles section {
+      .gr-form-styles.full-width fieldset {
+        margin: 0 0 2em 0;
+      }
+      .gr-form-styles section {
         margin-bottom: .5em;
       }
-      .gr-settings-styles .title,
-      .gr-settings-styles .value {
+      .gr-form-styles .title,
+      .gr-form-styles .value {
         display: inline-block;
         vertical-align: top;
       }
-      .gr-settings-styles .title {
+      .gr-form-styles .title {
         color: #666;
         font-weight: bold;
         padding-right: .5em;
         width: 11em;
       }
-      .gr-settings-styles input {
+      .gr-form-styles.full-width .title {
+        color: #666;
+        font-weight: bold;
+        padding-right: .5em;
+        width: 22em;
+      }
+      .gr-form-styles input {
         font-size: 1em;
       }
-      .gr-settings-styles th {
+      .gr-form-styles iron-autogrow-textarea {
+        font-size: 1em;
+      }
+      .gr-form-styles th {
         color: #666;
         text-align: left;
       }
-      .gr-settings-styles tbody tr:nth-child(even) {
+      .gr-form-styles tbody tr:nth-child(even) {
         background-color: #f4f4f4;
       }
       @media only screen and (max-width: 40em) {
-        .gr-settings-styles section {
+        .gr-form-styles section {
           margin-bottom: 1em;
         }
-        .gr-settings-styles .title,
-        .gr-settings-styles .value {
+        .gr-form-styles .title,
+        .gr-form-styles .value {
           display: block;
         }
       }
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index 4dcc9a8..9a2e404 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -21,12 +21,16 @@
 <script src="../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../bower_components/web-component-tester/browser.js"></script>
 <script>
-  var testFiles = [];
-  var elementsPath = '../elements/';
-  var behaviorsPath = '../behaviors/';
+  const testFiles = [];
+  const elementsPath = '../elements/';
+  const behaviorsPath = '../behaviors/';
 
   // Elements tests.
-  [
+  const elements = [
+    // This seemed to be flakey when it was farther down the list. Keep at the
+    // beginning.
+    'gr-app_test.html',
+    'admin/gr-admin-project-list/gr-admin-project-list_test.html',
     'change-list/gr-change-list-item/gr-change-list-item_test.html',
     'change-list/gr-change-list-view/gr-change-list-view_test.html',
     'change-list/gr-change-list/gr-change-list_test.html',
@@ -34,18 +38,21 @@
     'change/gr-account-list/gr-account-list_test.html',
     'change/gr-change-actions/gr-change-actions_test.html',
     'change/gr-change-metadata/gr-change-metadata_test.html',
+    'change/gr-change-metadata/gr-change-metadata-it_test.html',
     'change/gr-change-view/gr-change-view_test.html',
     'change/gr-comment-list/gr-comment-list_test.html',
     'change/gr-commit-info/gr-commit-info_test.html',
+    'change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html',
     'change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html',
     'change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html',
-    'change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html',
     'change/gr-download-dialog/gr-download-dialog_test.html',
+    'change/gr-label-scores/gr-label-scores_test.html',
     'change/gr-file-list/gr-file-list_test.html',
     'change/gr-message/gr-message_test.html',
     'change/gr-messages-list/gr-messages-list_test.html',
     'change/gr-related-changes-list/gr-related-changes-list_test.html',
     'change/gr-reply-dialog/gr-reply-dialog_test.html',
+    'change/gr-reply-dialog/gr-reply-dialog-it_test.html',
     'change/gr-reviewer-list/gr-reviewer-list_test.html',
     'core/gr-account-dropdown/gr-account-dropdown_test.html',
     'core/gr-error-manager/gr-error-manager_test.html',
@@ -53,8 +60,8 @@
     'core/gr-reporting/gr-reporting_test.html',
     'core/gr-search-bar/gr-search-bar_test.html',
     'diff/gr-diff-builder/gr-diff-builder_test.html',
-    'diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html',
     'diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html',
+    'diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html',
     'diff/gr-diff-comment/gr-diff-comment_test.html',
     'diff/gr-diff-cursor/gr-diff-cursor_test.html',
     'diff/gr-diff-highlight/gr-annotation_test.html',
@@ -70,7 +77,8 @@
     'diff/gr-selection-action-box/gr-selection-action-box_test.html',
     'diff/gr-syntax-layer/gr-syntax-layer_test.html',
     'diff/gr-syntax-lib-loader/gr-syntax-lib-loader_test.html',
-    'gr-app_test.html',
+    'plugins/gr-external-style/gr-external-style_test.html',
+    'plugins/gr-plugin-host/gr-plugin-host_test.html',
     'settings/gr-account-info/gr-account-info_test.html',
     'settings/gr-change-table-editor/gr-change-table-editor_test.html',
     'settings/gr-email-editor/gr-email-editor_test.html',
@@ -85,6 +93,8 @@
     'shared/gr-account-link/gr-account-link_test.html',
     'shared/gr-alert/gr-alert_test.html',
     'shared/gr-autocomplete/gr-autocomplete_test.html',
+    'shared/gr-textarea/gr-textarea_test.html',
+    'shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html',
     'shared/gr-avatar/gr-avatar_test.html',
     'shared/gr-button/gr-button_test.html',
     'shared/gr-change-star/gr-change-star_test.html',
@@ -105,25 +115,28 @@
     'shared/gr-storage/gr-storage_test.html',
     'shared/gr-tooltip/gr-tooltip_test.html',
     'shared/gr-tooltip-content/gr-tooltip-content_test.html',
-  ].forEach(function(file) {
+  ];
+  for (let file of elements) {
     file = elementsPath + file;
     testFiles.push(file);
     testFiles.push(file + '?dom=shadow');
-  });
+  }
 
   // Behaviors tests.
-  [
+  const behaviors = [
     'base-url-behavior/base-url-behavior_test.html',
+    'keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html',
     'rest-client-behavior/rest-client-behavior_test.html',
     'gr-change-table-behavior/gr-change-table-behavior_test.html',
     'gr-patch-set-behavior/gr-patch-set-behavior_test.html',
     'gr-path-list-behavior/gr-path-list-behavior_test.html',
     'gr-tooltip-behavior/gr-tooltip-behavior_test.html',
-  ].forEach(function(file) {
+  ];
+  for (let file of behaviors) {
     // Behaviors do not utilize the DOM, so no shadow DOM test is necessary.
     file = behaviorsPath + file;
     testFiles.push(file);
-  });
+  }
 
   WCT.loadSuites(testFiles);
 </script>
diff --git a/polygerrit-ui/app/wct_test.sh b/polygerrit-ui/app/wct_test.sh
index e6f3e0e..e81adfb 100755
--- a/polygerrit-ui/app/wct_test.sh
+++ b/polygerrit-ui/app/wct_test.sh
@@ -36,10 +36,10 @@
         'sauce': {
           'disabled': true,
           'browsers': [
-            'OS X 10.11/chrome',
+            'OS X 10.12/chrome',
             'Windows 10/chrome',
             'Linux/firefox',
-            'OS X 10.11/safari',
+            'OS X 10.12/safari',
             'Windows 10/microsoftedge'
           ]
         }
diff --git a/tools/coverage.sh b/tools/coverage.sh
new file mode 100755
index 0000000..8fa979f
--- /dev/null
+++ b/tools/coverage.sh
@@ -0,0 +1,45 @@
+#!/bin/sh
+#
+# Usage
+#
+#   COVERAGE_CPUS=32 tools/coverage.sh [/path/to/report-directory/]
+#
+# COVERAGE_CPUS defaults to 2, and the default destination is a temp
+# dir.
+
+genhtml=$(which genhtml)
+if [[ -z "${genhtml}" ]]; then
+    echo "Install 'genhtml' (contained in the 'lcov' package)"
+    exit 1
+fi
+
+destdir="$1"
+if [[ -z "${destdir}" ]]; then
+    destdir=$(mktemp -d /tmp/gerritcov.XXXXXX)
+fi
+
+echo "Running 'bazel coverage'; this may take a while"
+
+# coverage is expensive to run; use --jobs=2 to avoid overloading the
+# machine.
+bazel coverage -k --jobs=${COVERAGE_CPUS:-2} -- ... -//gerrit-common:auto_value_tests
+
+# The coverage data contains filenames relative to the Java root, and
+# genhtml has no logic to search these elsewhere. Workaround this
+# limitation by running genhtml in a directory with the files in the
+# right place. Also -inexplicably- genhtml wants to have the source
+# files relative to the output directory.
+mkdir -p ${destdir}/
+cp -a */src/{main,test}/java/* ${destdir}/
+
+base=$(bazel info bazel-testlogs)
+for f in $(find ${base}  -name 'coverage.dat') ; do
+  cp $f ${destdir}/$(echo $f| sed "s|${base}/||" | sed "s|/|_|g")
+done
+
+cd ${destdir}
+find -name '*coverage.dat' -size 0 -delete
+
+genhtml -o . --ignore-errors source *coverage.dat
+
+echo "coverage report at file://${destdir}/index.html"
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index 6838101..8bc70de 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -154,7 +154,7 @@
       src.add(m.group(1))
       # Exceptions: both source and lib
       if p.endswith('libquery_parser.jar') or \
-         p.endswith('prolog/libcommon.jar'):
+         p.endswith('libprolog-common.jar'):
         lib.add(p)
       # JGit dependency from external repository
       if 'gerrit-' not in p and 'jgit' in p:
diff --git a/tools/version.py b/tools/version.py
index 2603829..fed6d5d 100755
--- a/tools/version.py
+++ b/tools/version.py
@@ -53,7 +53,3 @@
 
 src_pattern = re.compile(r'^(GERRIT_VERSION = ")([-.\w]+)(")$', re.MULTILINE)
 replace_in_file('version.bzl', src_pattern)
-
-src_pattern = re.compile(r'^(\s*-DarchetypeVersion=)([-.\w]+)(\s*\\)$',
-                         re.MULTILINE)
-replace_in_file(os.path.join('Documentation', 'dev-plugins.txt'), src_pattern)
diff --git a/version.bzl b/version.bzl
index 7dfd46a..488a83f 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = "2.14.1-SNAPSHOT"
+GERRIT_VERSION = "2.15-SNAPSHOT"