Merge "Document that external-ids paths variably nested"
diff --git a/.bazelversion b/.bazelversion
index 1545d96..7c69a55d 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-3.5.0
+3.7.0
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 4add381..38720fe 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -93,8 +93,8 @@
 == Predefined Groups
 
 Predefined groups differs from system groups by the fact that they
-exist in the ACCOUNT_GROUPS table (like normal groups) but predefined groups
-are created on Gerrit site initialization and unique UUIDs are assigned
+exist in NoteDb under refs/meta/group-names (like normal groups) but predefined
+groups are created on Gerrit site initialization and unique UUIDs are assigned
 to those groups. These UUIDs are different on different Gerrit sites.
 
 Gerrit comes with two predefined groups:
@@ -869,6 +869,11 @@
 private changes (even without having the `View Private Changes` access
 right assigned).
 
+**NOTE**: If link:config-gerrit.html#auth.skipFullRefEvaluationIfAllRefsAreVisible[
+auth.skipFullRefEvaluationIfAllRefsAreVisible] is `true` (which is the case by
+default) privates changes and all change edit refs are also visible to users
+that have read access on `refs/*`.
+
 [[category_toggle_work_in_progress_state]]
 === Toggle Work In Progress state
 
diff --git a/Documentation/concept-patch-sets.txt b/Documentation/concept-patch-sets.txt
index 8609afd..274fbb0 100644
--- a/Documentation/concept-patch-sets.txt
+++ b/Documentation/concept-patch-sets.txt
@@ -89,7 +89,7 @@
 set description does not become a part of the project's history.
 
 To add a patch set description, click *Add a patch set description*, located in
-the file list.
+the file list, or provide it link:user-upload.html#patch_set_description[on upload].
 
 GERRIT
 ------
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 879ec99..ac5e3b7 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -684,8 +684,11 @@
 
 [[auth.skipFullRefEvaluationIfAllRefsAreVisible]]auth.skipFullRefEvaluationIfAllRefsAreVisible::
 +
-Whether to skip the full ref visibility checks as a performance shortcut when all refs are
-visible to a user. Full ref filtering would filter out things like pending edits.
+Whether to skip the full ref visibility checks as a performance shortcut when a
+user has READ permission for all refs.
++
+The full ref filtering would filter out refs for pending edits, private changes
+and auto merge commits.
 +
 By default, true.
 
@@ -742,6 +745,24 @@
 +
 Default is false.
 
+[[cache.openFiles]]cache.openFiles::
++
+The number of file descriptors to add to the limit set by the Gerrit daemon.
++
+Persistent caches are stored on the file system and as such participate in the
+file descriptors utilization. The number of file descriptors can vary depending
+on the cache configuration and the specific backend used.
++
+The additional file descriptors required by the cache should be accounted for
+via this setting, so that the Gerrit daemon can adjust the ulimit accordingly.
++
+If you increase this to a larger setting you may need to also adjust
+the ulimit on file descriptors for the host JVM, as Gerrit needs
+additional file descriptors available for network sockets and other
+repository data manipulation.
++
+Default is 0.
+
 [[cache.name.maxAge]]cache.<name>.maxAge::
 +
 Maximum age to keep an entry in the cache. Entries are removed from
@@ -973,6 +994,11 @@
 be expensive to compute (60 or more seconds for a large history
 like the Linux kernel repository).
 
+cache `"comment_context"`::
++
+Caches the context lines of comments, which are the lines of the source file
+highlighted by the user when the comment was written.
+
 cache `"groups"`::
 +
 Caches the basic group information of internal groups by group ID,
@@ -1284,14 +1310,14 @@
 If set to true, then all UI features for using and interacting with the
 attention set are enabled.
 +
-The default is false for now, but will be changed to true in Q2 2020.
+The default is true.
 
 [[change.enableAssignee]]change.enableAssignee::
 +
 If set to true, then all UI features for using and interacting with the
 assignee are enabled.
 +
-The default is true for now, but will be changed to false in Q2 2020.
+The default is false.
 
 [[change.largeChange]]change.largeChange::
 +
@@ -1700,6 +1726,12 @@
 ----
   javaOptions = -Dlog4j.configuration=file:///home/gerrit/site/etc/log4j.properties
 ----
++
+Gerrit built-in loggers are then ignored: error logger (`error_log` file),
+link:#httpd.requestLog[httpd.requestLog] and
+link:#sshd.requestLog[sshd.requestLog]. The
+link:#log.jsonLogging[log.jsonLogging] and
+link:#log.textLogging[log.textLogging] options are also ignored.
 
 [[container.daemonOpt]]container.daemonOpt::
 +
@@ -3392,6 +3424,11 @@
 If `auth.type` is `LDAP` this setting should use `ldaps://` to
 ensure the end user's plaintext password is transmitted only over
 an encrypted connection.
++
+If you want to configure multiple ldap servers you can try to put
+multiple ldap urls separated by a space:
+`server = ldaps://ldap1 ldaps://ldap2`
+See https://bugs.chromium.org/p/gerrit/issues/detail?id=10841[issue 10841].
 
 [[ldap.startTls]]ldap.startTls::
 +
@@ -3758,8 +3795,13 @@
 
 [[log.jsonLogging]]log.jsonLogging::
 +
-If set to true, enables error, ssh and http logging in JSON format (file name:
-"logs/{error|sshd|httpd}_log.json").
+If set to true, enables error, ssh and http logging in JSON format (file names:
+`logs/error_log.json`, `logs/sshd_log.json` and `logs/httpd_log.json`).
++
+The option only applies to Gerrit built-in loggers. It is ignored when a log4j
+configuration is specified via
+link:#container.javaOptions[container.javaOptions], for example
+`-Dlog4j.configuration=file://etc/log4j.properties`.
 +
 Defaults to false.
 
@@ -3768,6 +3810,11 @@
 If set to true, enables error logging in regular plain text format. Can only be disabled
 if `jsonLogging` is enabled.
 +
+The option only applies to Gerrit built-in loggers. It is ignored when a log4j
+configuration is specified via
+link:#container.javaOptions[container.javaOptions], for example
+`-Dlog4j.configuration=file://etc/log4j.properties`.
++
 Defaults to true.
 
 [[log.compress]]log.compress::
@@ -5513,6 +5560,49 @@
   trustFolderStat = false
 ----
 
+[[jgit-gc]]
+=== Section gc
+
+Options in section gc are used when command link:cmd-gc.html[gerrit gc] is used
+or scheduled via options link:cmd-gc.html#gc.startTime[gc.startTime] and
+link:cmd-gc.html#gc.interval[gc.interval].
+
+[[gc.auto]]gc.auto::
++
+When there are approximately more than this many loose objects in the repository,
+auto gc will pack them. Some commands use this command to perform a light-weight
+garbage collection from time to time. The default value is 6700.
++
+Setting this to 0 disables not only automatic packing based on the number of
+loose objects, but any other heuristic auto gc will otherwise use to determine
+if there’s work to do, such as link:#gc.autoPackLimit[gc.autoPackLimit].
+
+[[gc.autodetach]]gc.autodetach::
++
+Makes auto gc run in a background thread. Default is `true`.
+
+[[gc.autopacklimit]]gc.autopacklimit::
++
+When there are more than this many packs that are not marked with `*.keep` file
+in the repository, auto gc consolidates them into one larger pack. The
+default value is 50. Setting this to 0 disables it. Setting `gc.auto` to 0 will
+also disable this.
+
+[[gc.packRefs]]gc.packRefs::
++
+This variable determines whether gc runs git pack-refs. The default is `true`.
+
+[[gc.reflogExpire]]gc.reflogExpire::
++
+Removes reflog entries older than this time; defaults to 90 days. The value "now"
+expires all entries immediately, and "never" suppresses expiration altogether.
+
+[[gc.reflogExpireUnreachable]]gc.reflogExpireUnreachable::
++
+Removes reflog entries older than this time and not reachable from the
+current tip; defaults to 30 days. The value "now" expires all entries immediately,
+and "never" suppresses expiration altogether.
+
 [[jgit-protocol]]
 === Section protocol
 
@@ -5530,6 +5620,16 @@
 2:: wire protocol version 2. Speeds up fetches from repositories with many refs by allowing the client
     to specify which refs to list before the server lists them.
 
+[[jgit-receive]]
+=== Section receive
+
+[[receive.autogc]]receive.autogc::
++
+By default, `git-receive-pack` will run auto gc after receiving data from git-push and updating refs.
+You can stop it by setting this variable to `false`. This is recommended in gerrit to avoid the
+additional load this creates. Instead schedule gc using link:cmd-gc.html#gc.startTime[gc.startTime]
+and link:cmd-gc.html#gc.interval[gc.interval] or e.g. in a cron job that runs gc in a separate process.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 086e836..0ae038a 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -61,8 +61,7 @@
 in future gerrit releases. To build Gerrit with Java 8 language level, run:
 
 ```
-  $ bazel build --java_toolchain //tools:error_prone_warnings_toolchain_java8
-        :release
+  $ bazel build --java_toolchain //tools:error_prone_warnings_toolchain :release
 ```
 
 [[java-11]]
@@ -288,6 +287,18 @@
   bazel-bin/withdocs.war
 ----
 
+Alternatively, one can generate the documentation as flat files:
+
+----
+  bazel build Documentation:Documentation
+----
+
+The html, css, js files are placed in:
+
+----
+ `bazel-bin/Documentation/`
+----
+
 [[tests]]
 == Running Unit Tests
 
diff --git a/Documentation/dev-core-plugins.txt b/Documentation/dev-core-plugins.txt
index aa519806..6b777d3 100644
--- a/Documentation/dev-core-plugins.txt
+++ b/Documentation/dev-core-plugins.txt
@@ -170,7 +170,7 @@
 The plugin functionality has gone outside the Gerrit-related scope,
 has a clear scope or conflict with other core plugins or existing and
 planned Gerrit core features.
-
++
 NOTE: The plugin would need to remain core until the planned replacement gets
 implemented. Otherwise the feature is likely missing between the removal and
 planned implementation times.
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index 742cf42..bbe227a 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -4,7 +4,8 @@
 This document is about configuring Gerrit Code Review into an
 Eclipse workspace for development.
 
-Java 8 or later SDK is required.
+Java 11 or later SDK is required.
+Otherwise, java 8 can still be used for now as described below.
 
 [[setup]]
 == Project Setup
@@ -30,6 +31,10 @@
 ----
 
 First, generate the Eclipse project by running the `tools/eclipse/project.py` script.
+If running Eclipse on Java 8, add the extra parameter
+`-e='--java_toolchain=//tools:error_prone_warnings_toolchain'`
+for generating a compatible project.
+
 Then, in Eclipse, choose 'Import existing project' and select the `gerrit` project
 from the current working directory.
 
@@ -79,15 +84,16 @@
 link:dev-build-plugins.html#_bundle_custom_plugin_in_release_war[bundling in release.war]
 and run `tools/eclipse/project.py`.
 
-[[Newer Java versions]]
+== Java Versions
 
-Java 9 and later are supported, but some adjustments must be done, because
-Java 8 is still the default:
+Java 11 is supported as a default, but some adjustments must be done for other JDKs:
 
 * Add JRE, e.g.: directory: /usr/lib64/jvm/java-9-openjdk, name: java-9-openjdk-9
 * Change execution environment for gerrit project to: JavaSE-9 (java-9-openjdk-9)
 * Check that compiler compliance level in gerrit project is set to: 9
 
+Moreover, the actual java 11 language features are not supported yet.
+
 [[Formatting]]
 == Code Formatter Settings
 
diff --git a/Documentation/dev-intellij.txt b/Documentation/dev-intellij.txt
index b67d546..149b14a 100644
--- a/Documentation/dev-intellij.txt
+++ b/Documentation/dev-intellij.txt
@@ -9,7 +9,7 @@
 <<dev-bazel#installation,Building with Bazel - Installation>>.
 
 It's strongly recommended to verify you can build your Gerrit tree with Bazel
-for Java 8 from the command line first. Ensure that at least
+for Java 11 from the command line first. Ensure that at least
 `bazel build gerrit` runs successfully before you proceed.
 
 === IntelliJ version and Bazel plugin
@@ -21,12 +21,12 @@
 Also note that the version of the Bazel plugin used in turn may or may not be
 compatible with the Bazel version used.
 
-In addition, Java 8 must be specified on your path or via `JAVA_HOME` so that
+In addition, Java 11 must be specified on your path or via `JAVA_HOME` so that
 building with Bazel via the Bazel plugin is possible.
 
 TIP: If the synchronization of the project with the BUILD files using the Bazel
 plugin fails and IntelliJ reports the error **Could not get Bazel roots**, this
-indicates that the Bazel plugin couldn't find Java 8.
+indicates that the Bazel plugin couldn't find Java 11.
 
 === Installation of IntelliJ IDEA
 
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index b4ae469..1db27d5 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -803,6 +803,35 @@
   }
 ----
 
+To provide additional Guice bindings for options to a command in another classloader, bind a
+ModulesClassNamesProvider which provides the name of your Modules needed for your DynamicBean
+in the other classLoader.
+
+Do this by binding to the name of the command you are going to bind to and providing an
+Iterable of Module names to instantiate and add to the Injector used to instantiate the
+DynamicBean in the other classLoader. This interface supports running LifecycleListeners
+which are defined by the Modules being provided. The duration of the lifecycle starts when
+a ssh or http request starts and ends when the request completes.
+
+[source, java]
+----
+  bind(DynamicOptions.DynamicBean.class)
+      .annotatedWith(Exports.named(
+          "com.google.gerrit.plugins.otherplugin.command"))
+      .to(MyOptionsModulesClassNamesProvider.class);
+
+  static class MyOptionsModulesClassNamesProvider implements DynamicOptions.ModulesClassNamesProvider {
+    {@literal @}Override
+    public String getClassName() {
+      return "com.googlesource.gerrit.plugins.myplugin.CommandOptions";
+    }
+    {@literal @}Override
+    public Iterable<String> getModulesClassNames()() {
+      return "com.googlesource.gerrit.plugins.myplugin.MyOptionsModule";
+    }
+  }
+----
+
 === Calling Command Options ===
 
 Within an OptionHandler, during the processing of an option, plugins can
@@ -864,13 +893,15 @@
 [[query_attributes]]
 === Change Attributes ===
 
+==== ChangePluginDefinedInfoFactory
+
 Plugins can provide additional attributes to be returned from the Get Change and
-Query Change APIs by implementing implementing the `ChangeAttributeFactory`
-interface and adding it to the `DynamicSet` in the plugin module's `configure()`
-method. The new attribute(s) will be output under a `plugin` attribute in the
-change output. This can be further controlled by registering a class containing
-@Option declarations as a `DynamicBean`, annotated with the with HTTP/SSH
-commands on which the options should be available.
+Query Change APIs by implementing the `ChangePluginDefinedInfoFactory` interface
+and adding it to the `DynamicSet` in the plugin module's `configure()` method.
+The new attribute(s) will be output under a `plugin` attribute in the change
+output. This can be further controlled by registering a class containing @Option
+declarations as a `DynamicBean`, annotated with the HTTP/SSH commands on
+which the options should be available.
 
 The example below shows a plugin that adds two attributes (`exampleName` and
 `changeValue`), to the change query output, when the query command is provided
@@ -882,7 +913,7 @@
   @Override
   protected void configure() {
     // Register attribute factory.
-    DynamicSet.bind(binder(), ChangeAttributeFactory.class)
+    DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class)
         .to(AttributeFactory.class);
 
     // Register options for GET /changes/X/change and /changes/X/detail.
@@ -907,7 +938,7 @@
   public boolean all = false;
 }
 
-public class AttributeFactory implements ChangeAttributeFactory {
+public class AttributeFactory implements ChangePluginDefinedInfoFactory {
   protected MyChangeOptions options;
 
   public class PluginAttribute extends PluginDefinedInfo {
@@ -921,14 +952,17 @@
   }
 
   @Override
-  public PluginDefinedInfo create(ChangeData c, BeanProvider bp, String plugin) {
+  public Map<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+      Collection<ChangeData> cds, BeanProvider bp, String plugin) {
     if (options == null) {
       options = (MyChangeOptions) bp.getDynamicBean(plugin);
     }
+    Map<Change.Id, PluginDefinedInfo> out = new HashMap<>();
     if (options.all) {
-      return new PluginAttribute(c);
+      cds.forEach(cd -> out.put(cd.getId(), new PluginAttribute(cd)));
+      return out;
     }
-    return null;
+    return ImmutableMap.of();
   }
 }
 ----
@@ -970,10 +1004,20 @@
 }
 ----
 
-Implementors of the `ChangeAttributeFactory` interface should check whether
-they need to contribute to the link:#change-etag-computation[change ETag
-computation] to prevent callers using ETags from potentially seeing outdated
-plugin attributes.
+Runtime exceptions generated by the implementors of ChangePluginDefinedInfoFactory
+are encapsulated in PluginDefinedInfo objects which are part of SSH/REST query output.
+
+==== ChangeAttributeFactory
+
+Alternatively, there is also `ChangeAttributeFactory` which takes in one single
+`ChangeData` at a time. `ChangePluginDefinedInfoFactory` should be preferred
+over this as it handles many changes at once which also decreases the round-trip
+time for queries resulting in performance increase for bulk queries.
+
+Implementors of the `ChangePluginDefinedInfoFactory` and `ChangeAttributeFactory`
+interfaces should check whether they need to contribute to the
+link:#change-etag-computation[change ETag computation] to prevent callers using
+ETags from potentially seeing outdated plugin attributes.
 
 [[simple-configuration]]
 == Simple Configuration in `gerrit.config`
@@ -2685,8 +2729,8 @@
 [source, java]
 ----
 import java.util.Optional;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitRecord.Status;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRecord.Status;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.SubmitRule;
 
diff --git a/Documentation/dev-processes.txt b/Documentation/dev-processes.txt
index 5828cef..0eb3972 100644
--- a/Documentation/dev-processes.txt
+++ b/Documentation/dev-processes.txt
@@ -89,6 +89,15 @@
 already has dedicated seats in the steering committee (see section
 link:#steering-committee[steering committee]).
 
+If a non-Google seat on the steering committee becomes vacant before
+the current term ends, an exceptional election is conducted in order
+to replace the member(s) leaving the committee. The election will
+follow the same procedure as regular steering committee elections.
+The number of votes each maintainer gets in such exceptional election
+matches the number of seats to be filled. The term of the new member
+of the steering committee ends at the end of the current term of
+the steering committee when the next regular election concludes.
+
 [[contribution-process]]
 == Contribution Process
 
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt
index e60efa0..a7240e2 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -27,14 +27,19 @@
 
 * Propose the release with any plans/objectives to the mailing list.
 
+* Release plans usually become a
+  link:https://www.gerritcodereview.com/news.html[news article]
+  to be followed up with.
+
 * Create a Gerrit `rc0`.
 
-* If needed create a Gerrit `rc1`.
+* If needed create Gerrit `rc1`, `rc2` and `rc3` (one per week, on Mondays
+  or so; see link:https://www.gerritcodereview.com/news.html[past release plans]).
 
 [NOTE]
-You may let in a few features to this release.
+You may let in a few features to these releases.
 
-* If needed create a Gerrit `rc2`.
+* If needed create a Gerrit `rc4`.
 
 [NOTE]
 There should be no new features in this release, only bug fixes.
diff --git a/Documentation/images/user-attention-set-dashboard.png b/Documentation/images/user-attention-set-dashboard.png
index 2bf7ccd..4533380 100644
--- a/Documentation/images/user-attention-set-dashboard.png
+++ b/Documentation/images/user-attention-set-dashboard.png
Binary files differ
diff --git a/Documentation/images/user-attention-set-icon.png b/Documentation/images/user-attention-set-icon.png
index a1d5ac5..a6789b9 100644
--- a/Documentation/images/user-attention-set-icon.png
+++ b/Documentation/images/user-attention-set-icon.png
Binary files differ
diff --git a/Documentation/images/user-attention-set-reply-modify.png b/Documentation/images/user-attention-set-reply-modify.png
index 7705d14..a8895f9 100644
--- a/Documentation/images/user-attention-set-reply-modify.png
+++ b/Documentation/images/user-attention-set-reply-modify.png
Binary files differ
diff --git a/Documentation/images/user-attention-set-reply-select.png b/Documentation/images/user-attention-set-reply-select.png
index 14fadfe3..e93ff58 100644
--- a/Documentation/images/user-attention-set-reply-select.png
+++ b/Documentation/images/user-attention-set-reply-select.png
Binary files differ
diff --git a/Documentation/images/user-attention-set-user-prefs.png b/Documentation/images/user-attention-set-user-prefs.png
new file mode 100644
index 0000000..47cdbf5
--- /dev/null
+++ b/Documentation/images/user-attention-set-user-prefs.png
Binary files differ
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index ad3443b..f6120a7 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -247,132 +247,6 @@
 ----
 
 
-[[isarray]]
-isarray
-
-* isarray
-
-[[isarray_license]]
-----
-(MIT)
-
-Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>;
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-of the Software, and to permit persons to whom the Software is furnished to do
-so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
-----
-
-
-[[Polymer-2018]]
-Polymer-2018
-
-* @webcomponents/webcomponentsjs
-* polymer-bridges
-* polymer-resin
-
-[[Polymer-2018_license]]
-----
-Copyright (c) 2018 The Polymer Project Authors. All rights reserved.
-
-This code may only be used under the BSD style license found at
-http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
-http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
-found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
-part of the polymer project is also subject to an additional IP rights grant
-found at http://polymer.github.io/PATENTS.txt
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
-
-   * Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
-   * Redistributions in binary form must reproduce the above
-copyright notice, this list of conditions and the following disclaimer
-in the documentation and/or other materials provided with the
-distribution.
-   * Neither the name of Google Inc. nor the names of its
-contributors may be used to endorse or promote products derived from
-this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-----
-
-
-[[Polymer-2017]]
-Polymer-2017
-
-* @polymer/decorators
-* @polymer/polymer
-* @webcomponents/shadycss
-
-[[Polymer-2017_license]]
-----
-Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
-
-This code may only be used under the BSD style license found at
-http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
-http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
-found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
-part of the polymer project is also subject to an additional IP rights grant
-found at http://polymer.github.io/PATENTS.txt
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
-
-   * Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
-   * Redistributions in binary form must reproduce the above
-copyright notice, this list of conditions and the following disclaimer
-in the documentation and/or other materials provided with the
-distribution.
-   * Neither the name of Google Inc. nor the names of its
-contributors may be used to endorse or promote products derived from
-this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-----
-
-
 [[Polymer-2014]]
 Polymer-2014
 
@@ -506,6 +380,100 @@
 ----
 
 
+[[Polymer-2017]]
+Polymer-2017
+
+* @polymer/decorators
+* @polymer/polymer
+* @webcomponents/shadycss
+
+[[Polymer-2017_license]]
+----
+Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
+
+This code may only be used under the BSD style license found at
+http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
+http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
+found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
+part of the polymer project is also subject to an additional IP rights grant
+found at http://polymer.github.io/PATENTS.txt
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
+[[Polymer-2018]]
+Polymer-2018
+
+* @webcomponents/webcomponentsjs
+* polymer-bridges
+* polymer-resin
+
+[[Polymer-2018_license]]
+----
+Copyright (c) 2018 The Polymer Project Authors. All rights reserved.
+
+This code may only be used under the BSD style license found at
+http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
+http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
+found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
+part of the polymer project is also subject to an additional IP rights grant
+found at http://polymer.github.io/PATENTS.txt
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
 [[ba-linkify]]
 ba-linkify
 
@@ -992,34 +960,112 @@
 ----
 
 
-[[path-to-regexp]]
-path-to-regexp
+[[isarray]]
+isarray
 
-* path-to-regexp
+* isarray
 
-[[path-to-regexp_license]]
+[[isarray_license]]
 ----
-The MIT License (MIT)
+(MIT)
 
-Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com)
+Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>;
 
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
 
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
 
 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+----
+
+
+[[lit-element]]
+lit-element
+
+* lit-element
+
+[[lit-element_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2017, The Polymer Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+  this list of conditions and the following disclaimer in the documentation
+  and/or other materials provided with the distribution.
+
+* Neither the name of the copyright holder nor the names of its
+  contributors may be used to endorse or promote products derived from
+  this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
+[[lit-html]]
+lit-html
+
+* lit-html
+
+[[lit-html_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2017, The Polymer Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+  this list of conditions and the following disclaimer in the documentation
+  and/or other materials provided with the distribution.
+
+* Neither the name of the copyright holder nor the names of its
+  contributors may be used to endorse or promote products derived from
+  this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
 ----
 
@@ -1056,3 +1102,271 @@
 
 ----
 
+
+[[path-to-regexp]]
+path-to-regexp
+
+* path-to-regexp
+
+[[path-to-regexp_license]]
+----
+The MIT License (MIT)
+
+Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+----
+
+
+[[rxjs]]
+rxjs
+
+* rxjs
+
+[[rxjs_license]]
+----
+                               Apache License
+                         Version 2.0, January 2004
+                      http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+    "License" shall mean the terms and conditions for use, reproduction,
+    and distribution as defined by Sections 1 through 9 of this document.
+
+    "Licensor" shall mean the copyright owner or entity authorized by
+    the copyright owner that is granting the License.
+
+    "Legal Entity" shall mean the union of the acting entity and all
+    other entities that control, are controlled by, or are under common
+    control with that entity. For the purposes of this definition,
+    "control" means (i) the power, direct or indirect, to cause the
+    direction or management of such entity, whether by contract or
+    otherwise, or (ii) ownership of fifty percent (50%) or more of the
+    outstanding shares, or (iii) beneficial ownership of such entity.
+
+    "You" (or "Your") shall mean an individual or Legal Entity
+    exercising permissions granted by this License.
+
+    "Source" form shall mean the preferred form for making modifications,
+    including but not limited to software source code, documentation
+    source, and configuration files.
+
+    "Object" form shall mean any form resulting from mechanical
+    transformation or translation of a Source form, including but
+    not limited to compiled object code, generated documentation,
+    and conversions to other media types.
+
+    "Work" shall mean the work of authorship, whether in Source or
+    Object form, made available under the License, as indicated by a
+    copyright notice that is included in or attached to the work
+    (an example is provided in the Appendix below).
+
+    "Derivative Works" shall mean any work, whether in Source or Object
+    form, that is based on (or derived from) the Work and for which the
+    editorial revisions, annotations, elaborations, or other modifications
+    represent, as a whole, an original work of authorship. For the purposes
+    of this License, Derivative Works shall not include works that remain
+    separable from, or merely link (or bind by name) to the interfaces of,
+    the Work and Derivative Works thereof.
+
+    "Contribution" shall mean any work of authorship, including
+    the original version of the Work and any modifications or additions
+    to that Work or Derivative Works thereof, that is intentionally
+    submitted to Licensor for inclusion in the Work by the copyright owner
+    or by an individual or Legal Entity authorized to submit on behalf of
+    the copyright owner. For the purposes of this definition, "submitted"
+    means any form of electronic, verbal, or written communication sent
+    to the Licensor or its representatives, including but not limited to
+    communication on electronic mailing lists, source code control systems,
+    and issue tracking systems that are managed by, or on behalf of, the
+    Licensor for the purpose of discussing and improving the Work, but
+    excluding communication that is conspicuously marked or otherwise
+    designated in writing by the copyright owner as "Not a Contribution."
+
+    "Contributor" shall mean Licensor and any individual or Legal Entity
+    on behalf of whom a Contribution has been received by Licensor and
+    subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+    this License, each Contributor hereby grants to You a perpetual,
+    worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+    copyright license to reproduce, prepare Derivative Works of,
+    publicly display, publicly perform, sublicense, and distribute the
+    Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+    this License, each Contributor hereby grants to You a perpetual,
+    worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+    (except as stated in this section) patent license to make, have made,
+    use, offer to sell, sell, import, and otherwise transfer the Work,
+    where such license applies only to those patent claims licensable
+    by such Contributor that are necessarily infringed by their
+    Contribution(s) alone or by combination of their Contribution(s)
+    with the Work to which such Contribution(s) was submitted. If You
+    institute patent litigation against any entity (including a
+    cross-claim or counterclaim in a lawsuit) alleging that the Work
+    or a Contribution incorporated within the Work constitutes direct
+    or contributory patent infringement, then any patent licenses
+    granted to You under this License for that Work shall terminate
+    as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+    Work or Derivative Works thereof in any medium, with or without
+    modifications, and in Source or Object form, provided that You
+    meet the following conditions:
+
+    (a) You must give any other recipients of the Work or
+        Derivative Works a copy of this License; and
+
+    (b) You must cause any modified files to carry prominent notices
+        stating that You changed the files; and
+
+    (c) You must retain, in the Source form of any Derivative Works
+        that You distribute, all copyright, patent, trademark, and
+        attribution notices from the Source form of the Work,
+        excluding those notices that do not pertain to any part of
+        the Derivative Works; and
+
+    (d) If the Work includes a "NOTICE" text file as part of its
+        distribution, then any Derivative Works that You distribute must
+        include a readable copy of the attribution notices contained
+        within such NOTICE file, excluding those notices that do not
+        pertain to any part of the Derivative Works, in at least one
+        of the following places: within a NOTICE text file distributed
+        as part of the Derivative Works; within the Source form or
+        documentation, if provided along with the Derivative Works; or,
+        within a display generated by the Derivative Works, if and
+        wherever such third-party notices normally appear. The contents
+        of the NOTICE file are for informational purposes only and
+        do not modify the License. You may add Your own attribution
+        notices within Derivative Works that You distribute, alongside
+        or as an addendum to the NOTICE text from the Work, provided
+        that such additional attribution notices cannot be construed
+        as modifying the License.
+
+    You may add Your own copyright statement to Your modifications and
+    may provide additional or different license terms and conditions
+    for use, reproduction, or distribution of Your modifications, or
+    for any such Derivative Works as a whole, provided Your use,
+    reproduction, and distribution of the Work otherwise complies with
+    the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+    any Contribution intentionally submitted for inclusion in the Work
+    by You to the Licensor shall be under the terms and conditions of
+    this License, without any additional terms or conditions.
+    Notwithstanding the above, nothing herein shall supersede or modify
+    the terms of any separate license agreement you may have executed
+    with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+    names, trademarks, service marks, or product names of the Licensor,
+    except as required for reasonable and customary use in describing the
+    origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+    agreed to in writing, Licensor provides the Work (and each
+    Contributor provides its Contributions) on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+    implied, including, without limitation, any warranties or conditions
+    of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+    PARTICULAR PURPOSE. You are solely responsible for determining the
+    appropriateness of using or redistributing the Work and assume any
+    risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+    whether in tort (including negligence), contract, or otherwise,
+    unless required by applicable law (such as deliberate and grossly
+    negligent acts) or agreed to in writing, shall any Contributor be
+    liable to You for damages, including any direct, indirect, special,
+    incidental, or consequential damages of any character arising as a
+    result of this License or out of the use or inability to use the
+    Work (including but not limited to damages for loss of goodwill,
+    work stoppage, computer failure or malfunction, or any and all
+    other commercial damages or losses), even if such Contributor
+    has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+    the Work or Derivative Works thereof, You may choose to offer,
+    and charge a fee for, acceptance of support, warranty, indemnity,
+    or other liability obligations and/or rights consistent with this
+    License. However, in accepting such obligations, You may act only
+    on Your own behalf and on Your sole responsibility, not on behalf
+    of any other Contributor, and only if You agree to indemnify,
+    defend, and hold each Contributor harmless for any liability
+    incurred by, or claims asserted against, such Contributor by reason
+    of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+    To apply the Apache License to your work, attach the following
+    boilerplate notice, with the fields enclosed by brackets "[]"
+    replaced with your own identifying information. (Don't include
+    the brackets!)  The text should be enclosed in the appropriate
+    comment syntax for the file format. We also recommend that a
+    file or class name and description of purpose be included on the
+    same "printed page" as the copyright notice for easier
+    identification within third-party archives.
+
+ Copyright (c) 2015-2018 Google, Inc., Netflix, Inc., Microsoft Corp. and contributors
+
+ 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.
+ 
+
+----
+
+
+[[tslib]]
+tslib
+
+* tslib
+
+[[tslib_license]]
+----
+Copyright (c) Microsoft Corporation.

+

+Permission to use, copy, modify, and/or distribute this software for any

+purpose with or without fee is hereby granted.

+

+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH

+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY

+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,

+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM

+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR

+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR

+PERFORMANCE OF THIS SOFTWARE.
+
+----
+
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 98e99d4..d43203f 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -44,10 +44,12 @@
 
 * auto:auto-value
 * auto:auto-value-annotations
+* auto:auto-value-gson
 * commons:codec
 * commons:compress
 * commons:dbcp
 * commons:lang
+* commons:lang3
 * commons:net
 * commons:pool
 * commons:validator
@@ -3189,132 +3191,6 @@
 ----
 
 
-[[isarray]]
-isarray
-
-* isarray
-
-[[isarray_license]]
-----
-(MIT)
-
-Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>;
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-of the Software, and to permit persons to whom the Software is furnished to do
-so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
-----
-
-
-[[Polymer-2018]]
-Polymer-2018
-
-* @webcomponents/webcomponentsjs
-* polymer-bridges
-* polymer-resin
-
-[[Polymer-2018_license]]
-----
-Copyright (c) 2018 The Polymer Project Authors. All rights reserved.
-
-This code may only be used under the BSD style license found at
-http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
-http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
-found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
-part of the polymer project is also subject to an additional IP rights grant
-found at http://polymer.github.io/PATENTS.txt
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
-
-   * Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
-   * Redistributions in binary form must reproduce the above
-copyright notice, this list of conditions and the following disclaimer
-in the documentation and/or other materials provided with the
-distribution.
-   * Neither the name of Google Inc. nor the names of its
-contributors may be used to endorse or promote products derived from
-this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-----
-
-
-[[Polymer-2017]]
-Polymer-2017
-
-* @polymer/decorators
-* @polymer/polymer
-* @webcomponents/shadycss
-
-[[Polymer-2017_license]]
-----
-Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
-
-This code may only be used under the BSD style license found at
-http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
-http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
-found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
-part of the polymer project is also subject to an additional IP rights grant
-found at http://polymer.github.io/PATENTS.txt
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
-
-   * Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
-   * Redistributions in binary form must reproduce the above
-copyright notice, this list of conditions and the following disclaimer
-in the documentation and/or other materials provided with the
-distribution.
-   * Neither the name of Google Inc. nor the names of its
-contributors may be used to endorse or promote products derived from
-this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-----
-
-
 [[Polymer-2014]]
 Polymer-2014
 
@@ -3448,6 +3324,100 @@
 ----
 
 
+[[Polymer-2017]]
+Polymer-2017
+
+* @polymer/decorators
+* @polymer/polymer
+* @webcomponents/shadycss
+
+[[Polymer-2017_license]]
+----
+Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
+
+This code may only be used under the BSD style license found at
+http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
+http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
+found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
+part of the polymer project is also subject to an additional IP rights grant
+found at http://polymer.github.io/PATENTS.txt
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
+[[Polymer-2018]]
+Polymer-2018
+
+* @webcomponents/webcomponentsjs
+* polymer-bridges
+* polymer-resin
+
+[[Polymer-2018_license]]
+----
+Copyright (c) 2018 The Polymer Project Authors. All rights reserved.
+
+This code may only be used under the BSD style license found at
+http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
+http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
+found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
+part of the polymer project is also subject to an additional IP rights grant
+found at http://polymer.github.io/PATENTS.txt
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
 [[ba-linkify]]
 ba-linkify
 
@@ -3934,34 +3904,112 @@
 ----
 
 
-[[path-to-regexp]]
-path-to-regexp
+[[isarray]]
+isarray
 
-* path-to-regexp
+* isarray
 
-[[path-to-regexp_license]]
+[[isarray_license]]
 ----
-The MIT License (MIT)
+(MIT)
 
-Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com)
+Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>;
 
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
 
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
 
 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+----
+
+
+[[lit-element]]
+lit-element
+
+* lit-element
+
+[[lit-element_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2017, The Polymer Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+  this list of conditions and the following disclaimer in the documentation
+  and/or other materials provided with the distribution.
+
+* Neither the name of the copyright holder nor the names of its
+  contributors may be used to endorse or promote products derived from
+  this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
+[[lit-html]]
+lit-html
+
+* lit-html
+
+[[lit-html_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2017, The Polymer Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+  this list of conditions and the following disclaimer in the documentation
+  and/or other materials provided with the distribution.
+
+* Neither the name of the copyright holder nor the names of its
+  contributors may be used to endorse or promote products derived from
+  this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
 ----
 
@@ -3999,6 +4047,274 @@
 ----
 
 
+[[path-to-regexp]]
+path-to-regexp
+
+* path-to-regexp
+
+[[path-to-regexp_license]]
+----
+The MIT License (MIT)
+
+Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+----
+
+
+[[rxjs]]
+rxjs
+
+* rxjs
+
+[[rxjs_license]]
+----
+                               Apache License
+                         Version 2.0, January 2004
+                      http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+    "License" shall mean the terms and conditions for use, reproduction,
+    and distribution as defined by Sections 1 through 9 of this document.
+
+    "Licensor" shall mean the copyright owner or entity authorized by
+    the copyright owner that is granting the License.
+
+    "Legal Entity" shall mean the union of the acting entity and all
+    other entities that control, are controlled by, or are under common
+    control with that entity. For the purposes of this definition,
+    "control" means (i) the power, direct or indirect, to cause the
+    direction or management of such entity, whether by contract or
+    otherwise, or (ii) ownership of fifty percent (50%) or more of the
+    outstanding shares, or (iii) beneficial ownership of such entity.
+
+    "You" (or "Your") shall mean an individual or Legal Entity
+    exercising permissions granted by this License.
+
+    "Source" form shall mean the preferred form for making modifications,
+    including but not limited to software source code, documentation
+    source, and configuration files.
+
+    "Object" form shall mean any form resulting from mechanical
+    transformation or translation of a Source form, including but
+    not limited to compiled object code, generated documentation,
+    and conversions to other media types.
+
+    "Work" shall mean the work of authorship, whether in Source or
+    Object form, made available under the License, as indicated by a
+    copyright notice that is included in or attached to the work
+    (an example is provided in the Appendix below).
+
+    "Derivative Works" shall mean any work, whether in Source or Object
+    form, that is based on (or derived from) the Work and for which the
+    editorial revisions, annotations, elaborations, or other modifications
+    represent, as a whole, an original work of authorship. For the purposes
+    of this License, Derivative Works shall not include works that remain
+    separable from, or merely link (or bind by name) to the interfaces of,
+    the Work and Derivative Works thereof.
+
+    "Contribution" shall mean any work of authorship, including
+    the original version of the Work and any modifications or additions
+    to that Work or Derivative Works thereof, that is intentionally
+    submitted to Licensor for inclusion in the Work by the copyright owner
+    or by an individual or Legal Entity authorized to submit on behalf of
+    the copyright owner. For the purposes of this definition, "submitted"
+    means any form of electronic, verbal, or written communication sent
+    to the Licensor or its representatives, including but not limited to
+    communication on electronic mailing lists, source code control systems,
+    and issue tracking systems that are managed by, or on behalf of, the
+    Licensor for the purpose of discussing and improving the Work, but
+    excluding communication that is conspicuously marked or otherwise
+    designated in writing by the copyright owner as "Not a Contribution."
+
+    "Contributor" shall mean Licensor and any individual or Legal Entity
+    on behalf of whom a Contribution has been received by Licensor and
+    subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+    this License, each Contributor hereby grants to You a perpetual,
+    worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+    copyright license to reproduce, prepare Derivative Works of,
+    publicly display, publicly perform, sublicense, and distribute the
+    Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+    this License, each Contributor hereby grants to You a perpetual,
+    worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+    (except as stated in this section) patent license to make, have made,
+    use, offer to sell, sell, import, and otherwise transfer the Work,
+    where such license applies only to those patent claims licensable
+    by such Contributor that are necessarily infringed by their
+    Contribution(s) alone or by combination of their Contribution(s)
+    with the Work to which such Contribution(s) was submitted. If You
+    institute patent litigation against any entity (including a
+    cross-claim or counterclaim in a lawsuit) alleging that the Work
+    or a Contribution incorporated within the Work constitutes direct
+    or contributory patent infringement, then any patent licenses
+    granted to You under this License for that Work shall terminate
+    as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+    Work or Derivative Works thereof in any medium, with or without
+    modifications, and in Source or Object form, provided that You
+    meet the following conditions:
+
+    (a) You must give any other recipients of the Work or
+        Derivative Works a copy of this License; and
+
+    (b) You must cause any modified files to carry prominent notices
+        stating that You changed the files; and
+
+    (c) You must retain, in the Source form of any Derivative Works
+        that You distribute, all copyright, patent, trademark, and
+        attribution notices from the Source form of the Work,
+        excluding those notices that do not pertain to any part of
+        the Derivative Works; and
+
+    (d) If the Work includes a "NOTICE" text file as part of its
+        distribution, then any Derivative Works that You distribute must
+        include a readable copy of the attribution notices contained
+        within such NOTICE file, excluding those notices that do not
+        pertain to any part of the Derivative Works, in at least one
+        of the following places: within a NOTICE text file distributed
+        as part of the Derivative Works; within the Source form or
+        documentation, if provided along with the Derivative Works; or,
+        within a display generated by the Derivative Works, if and
+        wherever such third-party notices normally appear. The contents
+        of the NOTICE file are for informational purposes only and
+        do not modify the License. You may add Your own attribution
+        notices within Derivative Works that You distribute, alongside
+        or as an addendum to the NOTICE text from the Work, provided
+        that such additional attribution notices cannot be construed
+        as modifying the License.
+
+    You may add Your own copyright statement to Your modifications and
+    may provide additional or different license terms and conditions
+    for use, reproduction, or distribution of Your modifications, or
+    for any such Derivative Works as a whole, provided Your use,
+    reproduction, and distribution of the Work otherwise complies with
+    the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+    any Contribution intentionally submitted for inclusion in the Work
+    by You to the Licensor shall be under the terms and conditions of
+    this License, without any additional terms or conditions.
+    Notwithstanding the above, nothing herein shall supersede or modify
+    the terms of any separate license agreement you may have executed
+    with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+    names, trademarks, service marks, or product names of the Licensor,
+    except as required for reasonable and customary use in describing the
+    origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+    agreed to in writing, Licensor provides the Work (and each
+    Contributor provides its Contributions) on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+    implied, including, without limitation, any warranties or conditions
+    of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+    PARTICULAR PURPOSE. You are solely responsible for determining the
+    appropriateness of using or redistributing the Work and assume any
+    risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+    whether in tort (including negligence), contract, or otherwise,
+    unless required by applicable law (such as deliberate and grossly
+    negligent acts) or agreed to in writing, shall any Contributor be
+    liable to You for damages, including any direct, indirect, special,
+    incidental, or consequential damages of any character arising as a
+    result of this License or out of the use or inability to use the
+    Work (including but not limited to damages for loss of goodwill,
+    work stoppage, computer failure or malfunction, or any and all
+    other commercial damages or losses), even if such Contributor
+    has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+    the Work or Derivative Works thereof, You may choose to offer,
+    and charge a fee for, acceptance of support, warranty, indemnity,
+    or other liability obligations and/or rights consistent with this
+    License. However, in accepting such obligations, You may act only
+    on Your own behalf and on Your sole responsibility, not on behalf
+    of any other Contributor, and only if You agree to indemnify,
+    defend, and hold each Contributor harmless for any liability
+    incurred by, or claims asserted against, such Contributor by reason
+    of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+    To apply the Apache License to your work, attach the following
+    boilerplate notice, with the fields enclosed by brackets "[]"
+    replaced with your own identifying information. (Don't include
+    the brackets!)  The text should be enclosed in the appropriate
+    comment syntax for the file format. We also recommend that a
+    file or class name and description of purpose be included on the
+    same "printed page" as the copyright notice for easier
+    identification within third-party archives.
+
+ Copyright (c) 2015-2018 Google, Inc., Netflix, Inc., Microsoft Corp. and contributors
+
+ 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.
+ 
+
+----
+
+
+[[tslib]]
+tslib
+
+* tslib
+
+[[tslib_license]]
+----
+Copyright (c) Microsoft Corporation.

+

+Permission to use, copy, modify, and/or distribute this software for any

+purpose with or without fee is hereby granted.

+

+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH

+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY

+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,

+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM

+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR

+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR

+PERFORMANCE OF THIS SOFTWARE.
+
+----
+
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 3040348..8a95bab 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -66,6 +66,11 @@
 * `caches/disk_hit_ratio`: Disk hit ratio for persistent cache.
 * `caches/refresh_count`: The number of refreshes per cache with an indicator if a reload was necessary.
 
+Cache disk metrics are expensive to compute on larger installations and are not
+computed by default. They can be enabled via the
+link:config.gerrit.html#cache.enableDiskStatMetrics[`cache.enableDiskStatMetrics`]
+setting.
+
 === Change
 
 * `change/submit_rule_evaluation`: Latency for evaluating submit rules on a change.
diff --git a/Documentation/prolog-cookbook.txt b/Documentation/prolog-cookbook.txt
index 32c30b8..189ccfc 100644
--- a/Documentation/prolog-cookbook.txt
+++ b/Documentation/prolog-cookbook.txt
@@ -26,7 +26,7 @@
 link:https://groups.google.com/d/topic/repo-discuss/wJxTGhlHZMM/discussion[This
 discussion thread,role=external,window=_blank] explains why Prolog was chosen for the purpose of writing
 project specific submit rules.
-link:http://gerrit-documentation.googlecode.com/svn/ReleaseNotes/ReleaseNotes-2.2.2.html[Gerrit
+link:https://gerrit-documentation.storage.googleapis.com/ReleaseNotes/ReleaseNotes-2.2.2.html#_prolog[Gerrit
 2.2.2 ReleaseNotes,role=external,window=_blank] introduces Prolog support in Gerrit.
 
 [[SubmitType]]
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 32f8656..2a59d0c 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -2807,9 +2807,11 @@
 |`email_strategy`               ||
 The type of email strategy to use. On `ENABLED`, the user will receive emails
 from Gerrit. On `CC_ON_OWN_COMMENTS` the user will also receive emails for
-their own comments. On `DISABLED` the user will not receive any email
-notifications from Gerrit.
-Allowed values are `ENABLED`, `CC_ON_OWN_COMMENTS`, `DISABLED`.
+their own comments. On `ATTENTION_SET_ONLY`, on emails about changes, the user
+will receive emails only if they are in the attention set of that change.
+On `DISABLED` the user will not receive any email notifications from Gerrit.
+Allowed values are `ENABLED`, `CC_ON_OWN_COMMENTS`, `ATTENTION_SET_ONLY`,
+`DISABLED`.
 |`default_base_for_merges`      ||
 The base which should be pre-selected in the 'Diff Against' drop-down
 list when the change screen is opened for a merge commit.
@@ -2870,9 +2872,11 @@
 |`email_strategy`               |optional|
 The type of email strategy to use. On `ENABLED`, the user will receive emails
 from Gerrit. On `CC_ON_OWN_COMMENTS` the user will also receive emails for
-their own comments. On `DISABLED` the user will not receive any email
-notifications from Gerrit.
-Allowed values are `ENABLED`, `CC_ON_OWN_COMMENTS`, `DISABLED`.
+their own comments. On `ATTENTION_SET_ONLY`, on emails about changes, the user
+will receive emails only if they are in the attention set of that change.
+On `DISABLED` the user will not receive any email notifications from Gerrit.
+Allowed values are `ENABLED`, `CC_ON_OWN_COMMENTS`, `ATTENTION_SET_ONLY`,
+`DISABLED`.
 |`default_base_for_merges`      |optional|
 The base which should be pre-selected in the 'Diff Against' drop-down
 list when the change screen is opened for a merge commit.
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 8bd02a8..43c3b9e 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -216,15 +216,18 @@
 [[labels]]
 --
 * `LABELS`: a summary of each label required for submit, and
-  approvers that have granted (or rejected) with that label.
+  approvers that have granted (or rejected) with that label
+  as well as all reviewers by state, and reviewers that may
+  be removed by the current user.
 --
 
 [[detailed-labels]]
 --
 * `DETAILED_LABELS`: detailed label information, including numeric
   values of all existing approvals, recognized label values, values
-  permitted to be set by the current user, all reviewers by state, and
-  reviewers that may be removed by the current user.
+  permitted to be set by any reviewer and the change owner, all
+  reviewers by state, and reviewers that may be removed by the
+  current user.
 --
 
 [[current-revision]]
@@ -5134,6 +5137,8 @@
 Different than the link:#get-ported-comments[Get Ported Comments] endpoint, the `author` of the
 returned comments is not filled for this endpoint as only comments of the calling user are returned.
 
+This endpoint requires authentication.
+
 .Request
 ----
   GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/ported_drafts/ HTTP/1.0
@@ -5935,6 +5940,8 @@
 If a user is added while already in the attention set, the
 request is silently ignored.
 
+The user must be a reviewer, cc, uploader, or owner on the change.
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/attention HTTP/1.0
@@ -6408,7 +6415,8 @@
 |`removable_reviewers`|optional|
 The reviewers that can be removed by the calling user as a list of
 link:rest-api-accounts.html#account-info[AccountInfo] entities. +
-Only set if link:#detailed-labels[detailed labels] are requested.
+Only set if link:#labels[labels] or
+link:#detailed-labels[detailed labels] are requested.
 |`reviewers`          |optional|
 The reviewers as a map that maps a reviewer state to a list of
 link:rest-api-accounts.html#account-info[AccountInfo] entities.
@@ -6417,13 +6425,15 @@
 `CC`: Users that were added to the change, but have not voted. +
 `REMOVED`: Users that were previously reviewers on the change, but have
 been removed. +
-Only set if link:#detailed-labels[detailed labels] are requested.
+Only set if link:#labels[labels] or
+link:#detailed-labels[detailed labels] are requested.
 |`pending_reviewers`  |optional|
 Updates to `reviewers` that have been made while the change was in the
 WIP state. Only present on WIP changes and only if there are pending
 reviewer updates to report. These are reviewers who have not yet been
 notified about being added to or removed from the change. +
-Only set if link:#detailed-labels[detailed labels] are requested.
+Only set if link:#labels[labels] or
+link:#detailed-labels[detailed labels] are requested.
 |`reviewer_updates`|optional|
 Updates to reviewers set for the change as
 link:#review-update-info[ReviewerUpdateInfo] entities.
@@ -7325,6 +7335,13 @@
 |`merge`              ||
 The detail of the source commit for merge as a link:#merge-input[MergeInput]
 entity.
+|`author`             |optional|
+An link:rest-api-accounts.html#account-input[AccountInput] entity
+that will set the author of the commit to create. The author must be
+specified as name/email combination.
+The caller needs "Forge Author" permission when using this field.
+This field does not affect the owner of the change, which will
+continue to use the identity of the caller.
 |==================================
 
 [[move-input]]
@@ -7655,7 +7672,8 @@
 `ready` and `work_in_progress` to be true.
 |`add_to_attention_set`                |optional|
 list of link:#attention-set-input[AttentionSetInput] entities to add
-to the link:#attention-set[attention set].
+to the link:#attention-set[attention set]. Users that are not reviewers,
+ccs, owner, or uploader are silently ignored.
 |`remove_from_attention_set`           |optional|
 list of link:#attention-set-input[AttentionSetInput] entities to remove
 from the link:#attention-set[attention set].
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 4473a8d..a62ed47 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1701,7 +1701,7 @@
 |======================
 |Field Name|Description
 |`status`  |The status of the consistency problem. +
-Possible values are `ERROR` and `WARNING`.
+Possible values are `FATAL`, `ERROR` and `WARNING`.
 |`message` |Message describing the consistency problem.
 |======================
 
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 92759b6..d34ccb4 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -3393,6 +3393,8 @@
 |`status`                    ||The HTTP status code for the access.
 200 means success and 403 means denied.
 |`message`                   |optional|A clarifying message if `status` is not 200.
+|`debug_logs`                |optional|
+Debug logs that may help to understand why a permission is denied or allowed.
 |=========================================
 
 [[auto_closeable_changes_check_input]]
diff --git a/Documentation/user-attention-set.txt b/Documentation/user-attention-set.txt
index f870405..bca338a 100644
--- a/Documentation/user-attention-set.txt
+++ b/Documentation/user-attention-set.txt
@@ -1,10 +1,5 @@
 = Gerrit Code Review - Attention Set
 
-The Attention Set will be part of the upcoming 3.3 release (due late 2020).
-We are testing at on some hosts on `googlesource.com` right now. If you build
-your Gerrit from master, you can enable it using
-link:config-gerrit.html#change.enableAttentionSet[enableAttentionSet].
-
 Report a bug or send feedback using
 link:https://bugs.chromium.org/p/gerrit/issues/entry?template=Attention+Set[this Monorail template].
 You can also report a bug through the bug icon in the user hovercard and in the
@@ -16,7 +11,8 @@
 Code Review is a turn-based workflow going back and forth between the change
 owner and reviewers. For every change Gerrit maintains an "Attention Set" with
 users that are currently expected to act on the change. Both on the dashboard
-and on the change page, this is expressed by an arrow icon before the user name:
+and on the change page, this is expressed by an arrow icon before a (bolded)
+user name:
 
 image::images/user-attention-set-icon.png["account chip with attention icon", align="center"]
 
@@ -41,6 +37,7 @@
 changing the attention set:
 
 * If reviewers are added to a change, then they are added to the attention set.
+  * Exception: A reviewer adding themselves along with a comment or vote.
 * If an active change is submitted, abandoned or reset to "work in progress",
   then all users are removed from the attention set.
 * Replying (commenting, voting or just writing a change message) removes the
@@ -48,8 +45,8 @@
   conversations that the user is replying to.
 * If a *reviewer* replies, then the change owner (and uploader) are added to the
   attention set.
-* For merged and abandoned changes the owner is added when a new human comment
-  is created.
+* For merged and abandoned changes the owner is added only when a human creates
+  an unresolved comment.
 * Only owner, uploader, reviewers and ccs can be in the attention set.
 
 *!IMPORTANT!* These rules are not meant to be super smart and to always do the
@@ -99,18 +96,39 @@
 === Dashboard
 
 The default *dashboard* contains a new section at the top called "Your Turn". It
-lists all changes where the logged-in user is in the attention set.
+lists all changes where the logged-in user is in the attention set. When you are
+a reviewer, the change is highlighted and is shown at the top of the section.
+The "Waiting" column indicates how long the owner has already been waiting for
+you to act.
 
 image::images/user-attention-set-dashboard.png["dashboard with Your Turn section", align="center"]
 
-As an active developer one of your daily goals will be to iterate over this list
-and clear it.
+As an active developer, one of your daily goals will be to iterate over this
+list and clear it.
 
 image::images/user-attention-set-dashboard-empty.png["dashboard with empty Your Turn section", align="center"]
 
 Note that you can also navigate to other users' dashboards to check their
 "Your Turn" section.
 
+=== Emails
+
+Every email begins with `Attention is currently required from: ...`, so you can
+identify at a glance whether you are expected to act.
+
+You can even change your email notification preferences in the user settings to
+only receive emails when you are in the attention set of a change:
+
+image::images/user-attention-set-user-prefs.png["user preference for email notifications", align="center"]
+
+If you prefer setting up customized filters in your mail client, then you can
+make use of the `Gerrit-Attention:` footer lines that are added for every user
+in the attention set, e.g.
+
+----
+Gerrit-Attention: Marian Harbach <mharbach@google.com>
+----
+
 === Assignee
 
 While the "Assignee" feature can still be used together with the attention set,
@@ -123,6 +141,9 @@
 Otherwise "Assignee" and "Attention Set" are very much overlapping, so we
 recommend to only use one of them.
 
+If you don't expect action from reviewers, then consider adding them to CC
+instead.
+
 The "Assignee" feature can be turned on/off with the
 link:config-gerrit.html#change.enableAttentionSet[enableAssignee] config option.
 
@@ -133,6 +154,31 @@
 change page. This former way of keeping track of what you should look at has
 been replaced by the attention set.
 
+=== For Gerrit Admins
+
+The Attention Set will be part of the upcoming 3.3 release (due late 2020). It
+is enabled by default, but you can disable it by setting
+link:config-gerrit.html#change.enableAttentionSet[enableAttentionSet] to false.
+
+=== Important note for all host owners, project owners, and bot owners
+
+If you are a host/project owner, please make sure all bots that run against your
+host/project are part of the "Service Users" group.
+
+If you are a bot owner, please make sure your bot is part of the "Service Users"
+group on all hosts it runs on.
+
+To add users to the "Service Users" group, first ensure that the group exists on
+your host. If it doesn't, create it. The name must exactly be "Service Users".
+
+To create a group, use the Gerrit UI; BROWSE -> Groups -> CREATE NEW.
+
+Then, add the bots as members in this group. Alternatively, add an existing
+group that has multiple bots as a subgroup of "Service Users".
+
+To add members or subgroups, use the Gerrit UI; BROWSE -> Groups ->
+search for "Service Users" -> Members.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/user-named-destinations.txt b/Documentation/user-named-destinations.txt
index 1b6f143..a1ab258 100644
--- a/Documentation/user-named-destinations.txt
+++ b/Documentation/user-named-destinations.txt
@@ -13,6 +13,7 @@
 row in a destination file represents a single destination in the
 named set.  The left column represents the ref of the destination,
 and the right column represents the project of the destination.
+The named destinations can be publicly accessible by other users.
 
 Example destination file named `destinations/myreviews`:
 
diff --git a/Documentation/user-named-queries.txt b/Documentation/user-named-queries.txt
index e79b3da..c01f790 100644
--- a/Documentation/user-named-queries.txt
+++ b/Documentation/user-named-queries.txt
@@ -7,7 +7,8 @@
 link:intro-user.html#user-refs[user's ref] in the `All-Users` project.  The
 user's queries file is a 2 column tab delimited file.  The left
 column represents the name of the query, and the right column
-represents the query expression represented by the name.
+represents the query expression represented by the name. The named queries
+can be publicly accessible by other users.
 
 Example queries file:
 
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index ffe889f..5f18e9b 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -106,9 +106,11 @@
 that was scraped out of the commit message.
 
 [[destination]]
-destination:'NAME'::
+destination:'[name=]NAME[,user=USER]'::
 +
-Changes which match the current user's destination named 'NAME'.
+Changes which match the specified USER's destination named 'NAME'. If 'USER'
+is unspecified, the current user is used. The named destinations can be
+publicly accessible by other users.
 (see link:user-named-destinations.html[Named Destinations]).
 
 [[owner]]
@@ -123,9 +125,11 @@
 Changes originally submitted by a user in 'GROUP'.
 
 [[query]]
-query:'NAME'::
+query:'[name=]NAME[,user=USER]'::
 +
-Changes which match the current user's query named 'NAME'
+Changes which match the specified USER's query named 'NAME'. If 'USER'
+is unspecified, the current user is used. The named queries can be
+publicly accessible by other users.
 (see link:user-named-queries.html[Named Queries]).
 
 [[reviewer]]
@@ -304,6 +308,11 @@
 `file:"^name[1-3].xml"`.
 +
 Slash ('/') is used path separator.
++
+More examples:
+* `-file:^path/.*` - changes that do not modify files from `path/`,
+* `file:{^~(path/.*)}` - changes that modify files not from `path/` (but may
+contain files from `path/`).
 
 [[file]]
 file:'NAME', f:'NAME'::
@@ -524,7 +533,7 @@
 For example, added:>50 will be true for any change which adds at least 50
 lines.
 +
-Valid relations are >=, >, <=, <, or no relation, which will match if the
+Valid relations are >=, >, \<=, <, or no relation, which will match if the
 number of lines is exactly equal.
 
 [[commentby]]
@@ -576,7 +585,7 @@
 For example, unresolved:>0 will be true for any change which has at least one unresolved
 comment while unresolved:0 will be true for any change which has all comments resolved.
 +
-Valid relations are >=, >, <=, <, or no relation, which will match if the number of unresolved
+Valid relations are >=, >, \<=, <, or no relation, which will match if the number of unresolved
 comments is exactly equal.
 
 == Argument Quoting
@@ -693,7 +702,7 @@
 Matches changes with a +1 code review where the reviewer is in the
 ldap/linux.workflow group.
 
-`label:Code-Review<=-1`::
+`label:Code-Review\<=-1`::
 +
 Matches changes with either a -1, -2, or any lower score.
 
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index 926aa71..cdaf155 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -315,11 +315,11 @@
 preference is set so the default behavior is to create `work-in-progress`
 changes, this can be overridden with the `ready` option.
 
-[[message]]
-==== Message
+[[patch_set_description]]
+==== Patch Set Description
 
-A comment message can be applied to the change by using the `message` (or `m`)
-option:
+A link:concept-patch-sets.html#_description[patch set description] can be
+applied by using the `message` (or `m`) option:
 
 ----
   git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/experimental%m=This_is_a_rebase_on_master%21
diff --git a/WORKSPACE b/WORKSPACE
index 4c2fe35..fff9235 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -34,11 +34,11 @@
 
 http_archive(
     name = "bazel_toolchains",
-    sha256 = "88e818f9f03628eef609c8429c210ecf265ffe46c2af095f36c7ef8b1855fef5",
-    strip_prefix = "bazel-toolchains-92dd8a7a518a2fb7ba992d47c8b38299fe0be825",
+    sha256 = "726b5423e1c7a3866a3a6d68e7123b4a955e9fcbe912a51e0f737e6dab1d0af2",
+    strip_prefix = "bazel-toolchains-3.1.0",
     urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/bazel-toolchains/archive/92dd8a7a518a2fb7ba992d47c8b38299fe0be825.tar.gz",
-        "https://github.com/bazelbuild/bazel-toolchains/archive/92dd8a7a518a2fb7ba992d47c8b38299fe0be825.tar.gz",
+        "https://mirror.bazel.build/github.com/bazelbuild/bazel-toolchains/releases/download/3.1.0/bazel-toolchains-3.1.0.tar.gz",
+        "https://github.com/bazelbuild/bazel-toolchains/releases/download/3.1.0/bazel-toolchains-3.1.0.tar.gz",
     ],
 )
 
@@ -51,10 +51,10 @@
 
 http_archive(
     name = "com_google_protobuf",
-    sha256 = "71030a04aedf9f612d2991c1c552317038c3c5a2b578ac4745267a45e7037c29",
-    strip_prefix = "protobuf-3.12.3",
+    sha256 = "d0f5f605d0d656007ce6c8b5a82df3037e1d8fe8b121ed42e536f569dec16113",
+    strip_prefix = "protobuf-3.14.0",
     urls = [
-        "https://github.com/protocolbuffers/protobuf/archive/v3.12.3.tar.gz",
+        "https://github.com/protocolbuffers/protobuf/archive/v3.14.0.tar.gz",
     ],
 )
 
@@ -64,8 +64,8 @@
 
 http_archive(
     name = "build_bazel_rules_nodejs",
-    sha256 = "5bf77cc2d13ddf9124f4c1453dd96063774d755d4fc75d922471540d1c9a8ea8",
-    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/2.0.0/rules_nodejs-2.0.0.tar.gz"],
+    sha256 = "f2194102720e662dbf193546585d705e645314319554c6ce7e47d8b59f459e9c",
+    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/2.2.2/rules_nodejs-2.2.2.tar.gz"],
 )
 
 # Golang support for PolyGerrit local dev server.
@@ -640,6 +640,38 @@
     sha1 = "eff48ed53995db2dadf0456426cc1f8700136f86",
 )
 
+AUTO_VALUE_GSON_VERSION = "1.3.0"
+
+maven_jar(
+    name = "auto-value-gson-runtime",
+    artifact = "com.ryanharter.auto.value:auto-value-gson-runtime:" + AUTO_VALUE_GSON_VERSION,
+    sha1 = "a69a9db5868bb039bd80f60661a771b643eaba59",
+)
+
+maven_jar(
+    name = "auto-value-gson-extension",
+    artifact = "com.ryanharter.auto.value:auto-value-gson-extension:" + AUTO_VALUE_GSON_VERSION,
+    sha1 = "6a61236d17b58b05e32b4c532bcb348280d2212b",
+)
+
+maven_jar(
+    name = "auto-value-gson-factory",
+    artifact = "com.ryanharter.auto.value:auto-value-gson-factory:" + AUTO_VALUE_GSON_VERSION,
+    sha1 = "b1f01918c0d6cb1f5482500e6b9e62589334dbb0",
+)
+
+maven_jar(
+    name = "javapoet",
+    artifact = "com.squareup:javapoet:1.13.0",
+    sha1 = "d6562d385049f35eb50403fa86bb11cce76b866a",
+)
+
+maven_jar(
+    name = "autotransient",
+    artifact = "io.sweers.autotransient:autotransient:1.0.0",
+    sha1 = "38b1c630b8e76560221622289f37be40105abb3d",
+)
+
 declare_nongoogle_deps()
 
 LUCENE_VERS = "6.6.5"
@@ -843,30 +875,30 @@
     sha1 = "42a25dc3219429f0e5d060061f71acb49bf010a0",
 )
 
-TRUTH_VERS = "1.0.1"
+TRUTH_VERS = "1.1"
 
 maven_jar(
     name = "truth",
     artifact = "com.google.truth:truth:" + TRUTH_VERS,
-    sha1 = "361459309085bd9441cb97b62f160e8b353a93c0",
+    sha1 = "6a096a16646559c24397b03f797d0c9d75ee8720",
 )
 
 maven_jar(
     name = "truth-java8-extension",
     artifact = "com.google.truth.extensions:truth-java8-extension:" + TRUTH_VERS,
-    sha1 = "ef07b2cc2201472381fdd3bcf773310e22bb9080",
+    sha1 = "258db6eb8df61832c5c059ed2bc2e1c88683e92f",
 )
 
 maven_jar(
     name = "truth-liteproto-extension",
     artifact = "com.google.truth.extensions:truth-liteproto-extension:" + TRUTH_VERS,
-    sha1 = "bd1f5ac8a5f66e60cd1738f7b95c97a582ffcef9",
+    sha1 = "bf65afa13aa03330e739bcaa5d795fe0f10fbf20",
 )
 
 maven_jar(
     name = "truth-proto-extension",
     artifact = "com.google.truth.extensions:truth-proto-extension:" + TRUTH_VERS,
-    sha1 = "039aa2d7c9196b30d367eac7cb467ecaa726e23d",
+    sha1 = "64cba89cf87c1d84cb8c81d06f0b9c482f10b4dc",
 )
 
 maven_jar(
@@ -875,48 +907,48 @@
     sha1 = "7e060dd5b19431e6d198e91ff670644372f60fbd",
 )
 
-JETTY_VERS = "9.4.30.v20200611"
+JETTY_VERS = "9.4.32.v20200930"
 
 maven_jar(
     name = "jetty-servlet",
     artifact = "org.eclipse.jetty:jetty-servlet:" + JETTY_VERS,
-    sha1 = "ca3dea2cd34ee88cec017001603af0c9e74781d6",
+    sha1 = "4253dd46c099e0bca4dd763fc1e10774e10de00a",
 )
 
 maven_jar(
     name = "jetty-security",
     artifact = "org.eclipse.jetty:jetty-security:" + JETTY_VERS,
-    sha1 = "1a5261f6ad4081ad9e9bb01416d639931d391273",
+    sha1 = "16a6110fa40e49050146de5f597ab3a3a3fa83b5",
 )
 
 maven_jar(
     name = "jetty-server",
     artifact = "org.eclipse.jetty:jetty-server:" + JETTY_VERS,
-    sha1 = "e5ede3724d062717d0c04e4c77f74fe8115c2a6f",
+    sha1 = "d2d89099be5237cf68254bc943a7d800d3ee1945",
 )
 
 maven_jar(
     name = "jetty-jmx",
     artifact = "org.eclipse.jetty:jetty-jmx:" + JETTY_VERS,
-    sha1 = "653559eaec0f9a335a0d12e90bc764b28f341241",
+    sha1 = "5e8e87a6f89b8eabf5b5b1765e3d758209001570",
 )
 
 maven_jar(
     name = "jetty-http",
     artifact = "org.eclipse.jetty:jetty-http:" + JETTY_VERS,
-    sha1 = "cd6223382e4f82b9ea807d8cdb04a23e5d629f1c",
+    sha1 = "5fdcefd82178d11f895690f4fe6e843be69394b3",
 )
 
 maven_jar(
     name = "jetty-io",
     artifact = "org.eclipse.jetty:jetty-io:" + JETTY_VERS,
-    sha1 = "9c360d08e903b2dbd5d1f8e889a32046948628ce",
+    sha1 = "0d0f32c3b511d6b3a542787f95ed229731588810",
 )
 
 maven_jar(
     name = "jetty-util",
     artifact = "org.eclipse.jetty:jetty-util:" + JETTY_VERS,
-    sha1 = "39ec6aa4745952077f5407cb1394d8ba2db88b13",
+    sha1 = "efefd29006dcc9c9960a679263504287ce4e6896",
 )
 
 maven_jar(
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 78a621c..f59daf5 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -168,6 +168,8 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -388,6 +390,15 @@
     initSsh();
   }
 
+  protected void restart() throws Exception {
+    server = GerritServer.restart(server, createModule(), createSshModule());
+    server.getTestInjector().injectMembers(this);
+    if (resetter != null) {
+      server.getTestInjector().injectMembers(resetter);
+    }
+    initSsh();
+  }
+
   protected void reindexAccount(Account.Id accountId) {
     accountIndexer.index(accountId);
   }
@@ -430,15 +441,18 @@
     baseConfig.setInt("receive", null, "changeUpdateThreads", 4);
     Module module = createModule();
     Module auditModule = createAuditModule();
+    Module sshModule = createSshModule();
     if (classDesc.equals(methodDesc) && !classDesc.sandboxed() && !methodDesc.sandboxed()) {
       if (commonServer == null) {
         commonServer =
-            GerritServer.initAndStart(temporaryFolder, classDesc, baseConfig, module, auditModule);
+            GerritServer.initAndStart(
+                temporaryFolder, classDesc, baseConfig, module, auditModule, sshModule);
       }
       server = commonServer;
     } else {
       server =
-          GerritServer.initAndStart(temporaryFolder, methodDesc, baseConfig, module, auditModule);
+          GerritServer.initAndStart(
+              temporaryFolder, methodDesc, baseConfig, module, auditModule, sshModule);
     }
 
     server.getTestInjector().injectMembers(this);
@@ -536,6 +550,11 @@
     return null;
   }
 
+  /** Override to bind an additional Guice module for SSH injector */
+  public Module createSshModule() {
+    return null;
+  }
+
   protected void initSsh() throws Exception {
     if (testRequiresSsh
         && SshMode.useSsh()
@@ -750,6 +769,58 @@
     return result;
   }
 
+  protected PushOneCommit.Result createNParentsMergeCommitChange(String ref, List<String> fileNames)
+      throws Exception {
+    // This method creates n different commits and creates a merge commit pointing to all n parents.
+    // Each commit will contain all the fileNames. Commit i will have the following file names and
+    // their contents:
+    // {$file_1_name, ${file_1_name}-1}
+    // {$file_2_name, ${file_2_name}-1}, etc...
+    // The merge commit will have:
+    // {$file_1_name, ${file_1_name}-1}
+    // {$file_2_name, ${file_2_name}-2},
+    // {$file_3_name, ${file_3_name}-3}, etc...
+    // i.e. taking the ith file from the ith commit.
+    int n = fileNames.size();
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+
+    List<PushOneCommit.Result> pushResults = new ArrayList<>();
+
+    for (int i = 1; i <= n; i++) {
+      int finalI = i;
+      pushResults.add(
+          pushFactory
+              .create(
+                  admin.newIdent(),
+                  testRepo,
+                  "parent " + i,
+                  fileNames.stream().collect(Collectors.toMap(f -> f, f -> f + "-" + finalI)))
+              .to(ref));
+
+      // reset HEAD in order to create a sibling of the first change
+      if (i < n) {
+        testRepo.reset(initial);
+      }
+    }
+
+    PushOneCommit m =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "merge",
+            IntStream.range(1, n + 1)
+                .boxed()
+                .collect(
+                    Collectors.toMap(
+                        i -> fileNames.get((int) i - 1),
+                        i -> fileNames.get((int) i - 1) + "-" + i)));
+
+    m.setParents(pushResults.stream().map(PushOneCommit.Result::getCommit).collect(toList()));
+    PushOneCommit.Result result = m.to(ref);
+    result.assertOkStatus();
+    return result;
+  }
+
   protected PushOneCommit.Result createCommitAndPush(
       TestRepository<InMemoryRepository> repo,
       String ref,
@@ -1540,7 +1611,8 @@
 
   protected List<CommentInfo> getChangeSortedComments(int changeNum) throws Exception {
     List<CommentInfo> comments = new ArrayList<>();
-    Map<String, List<CommentInfo>> commentsMap = gApi.changes().id(changeNum).comments();
+    Map<String, List<CommentInfo>> commentsMap =
+        gApi.changes().id(changeNum).commentsRequest().get();
     for (Map.Entry<String, List<CommentInfo>> e : commentsMap.entrySet()) {
       for (CommentInfo c : e.getValue()) {
         c.path = e.getKey(); // Set the comment's path field.
diff --git a/java/com/google/gerrit/acceptance/AbstractLifecycleListenersTest.java b/java/com/google/gerrit/acceptance/AbstractLifecycleListenersTest.java
new file mode 100644
index 0000000..6acf486
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/AbstractLifecycleListenersTest.java
@@ -0,0 +1,109 @@
+// Copyright (C) 2020 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;
+
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.server.restapi.change.QueryChanges;
+import com.google.gerrit.sshd.commands.Query;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.google.inject.internal.UniqueAnnotations;
+import java.util.Collections;
+import org.kohsuke.args4j.Option;
+
+public class AbstractLifecycleListenersTest extends AbstractDaemonTest {
+  protected static class SimpleModule extends AbstractModule {
+    @Override
+    public void configure() {
+      bind(com.google.gerrit.server.DynamicOptions.DynamicBean.class)
+          .annotatedWith(Exports.named(Query.class))
+          .to(MyClassNameProvider.class);
+      bind(DynamicOptions.DynamicBean.class)
+          .annotatedWith(Exports.named(QueryChanges.class))
+          .to(MyClassNameProvider.class);
+    }
+  }
+
+  protected static class MyClassNameProvider implements DynamicOptions.ModulesClassNamesProvider {
+    @Override
+    public String getClassName() {
+      return "com.google.gerrit.acceptance.AbstractLifecycleListenersTest$MyOptions";
+    }
+
+    @Override
+    public Iterable<String> getModulesClassNames() {
+      return Collections.singleton(
+          "com.google.gerrit.acceptance.AbstractLifecycleListenersTest$MyOptions$MyOptionsModule");
+    }
+  }
+
+  public static class MyOptions implements DynamicOptions.DynamicBean {
+    @Option(name = "--opt")
+    public boolean opt;
+
+    public static class MyOptionsModule extends AbstractModule {
+      @Override
+      protected void configure() {
+        bind(LifecycleListener.class)
+            .annotatedWith(UniqueAnnotations.create())
+            .to(MyLifecycleListener.class);
+      }
+    }
+  }
+
+  protected static class MyLifecycleListener implements LifecycleListener {
+    protected final InvocationCheck invocationCheck;
+
+    @Inject
+    public MyLifecycleListener(InvocationCheck invocationCheck) {
+      this.invocationCheck = invocationCheck;
+    }
+
+    @Override
+    public void start() {
+      invocationCheck.setStartInvoked(true);
+    }
+
+    @Override
+    public void stop() {
+      invocationCheck.setStopInvoked(true);
+    }
+  }
+
+  @Singleton
+  public static class InvocationCheck {
+    private boolean isStartInvoked = false;
+    private boolean isStopInvoked = false;
+
+    public boolean isStartInvoked() {
+      return isStartInvoked;
+    }
+
+    public void setStartInvoked(boolean startInvoked) {
+      isStartInvoked = startInvoked;
+    }
+
+    public boolean isStopInvoked() {
+      return isStopInvoked;
+    }
+
+    public void setStopInvoked(boolean stopInvoked) {
+      isStopInvoked = stopInvoked;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
index 020602b..a91bc49 100644
--- a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
@@ -21,26 +21,36 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
 import com.google.gerrit.server.change.ChangeAttributeFactory;
+import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.restapi.change.GetChange;
 import com.google.gerrit.server.restapi.change.QueryChanges;
 import com.google.gerrit.sshd.commands.Query;
 import com.google.gson.Gson;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
 import com.google.inject.Module;
+import java.util.Collection;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
 import org.kohsuke.args4j.Option;
 
 public class AbstractPluginFieldsTest extends AbstractDaemonTest {
+  @Inject private ChangeOperations changeOperations;
+
   protected static class MyInfo extends PluginDefinedInfo {
     @Nullable String theAttribute;
 
@@ -91,6 +101,70 @@
     }
   }
 
+  protected static class PluginDefinedSimpleAttributeModule extends AbstractModule {
+    @Override
+    public void configure() {
+      DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class)
+          .toInstance(
+              (cds, bp, p) -> {
+                Map<Change.Id, PluginDefinedInfo> out = new HashMap<>();
+                cds.forEach(cd -> out.put(cd.getId(), new MyInfo("change " + cd.getId())));
+                return out;
+              });
+    }
+  }
+
+  protected static class PluginDefinedBulkExceptionModule extends AbstractModule {
+    @Override
+    protected void configure() {
+      DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class)
+          .toInstance(
+              (cds, bp, p) -> {
+                throw new RuntimeException("Sample Exception");
+              });
+    }
+  }
+
+  protected static class PluginDefinedChangesByCommitBulkAttributeModule extends AbstractModule {
+    @Override
+    public void configure() {
+      DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class)
+          .toInstance(
+              (cds, bp, p) -> {
+                Map<Change.Id, PluginDefinedInfo> out = new HashMap<>();
+                cds.forEach(
+                    cd ->
+                        out.put(
+                            cd.getId(),
+                            !cd.commitMessage().contains("no-info")
+                                ? new MyInfo("change " + cd.getId())
+                                : null));
+                return out;
+              });
+    }
+  }
+
+  protected static class PluginDefinedSingleCallBulkAttributeModule extends AbstractModule {
+    @Override
+    public void configure() {
+      DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class)
+          .to(SingleCallBulkFactoryAttribute.class);
+    }
+  }
+
+  protected static class SingleCallBulkFactoryAttribute implements ChangePluginDefinedInfoFactory {
+    public static int timesCreateCalled = 0;
+
+    @Override
+    public Map<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+        Collection<ChangeData> cds, DynamicOptions.BeanProvider beanProvider, String plugin) {
+      timesCreateCalled++;
+      Map<Change.Id, PluginDefinedInfo> out = new HashMap<>();
+      cds.forEach(cd -> out.put(cd.getId(), new MyInfo("change " + cd.getId())));
+      return out;
+    }
+  }
+
   private static class MyOptions implements DynamicBean {
     @Option(name = "--opt")
     private String opt;
@@ -111,6 +185,32 @@
     }
   }
 
+  public static class BulkAttributeFactoryWithOption implements ChangePluginDefinedInfoFactory {
+    protected MyOptions opts;
+
+    @Override
+    public Map<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+        Collection<ChangeData> cds, DynamicOptions.BeanProvider beanProvider, String plugin) {
+      if (opts == null) {
+        opts = (MyOptions) beanProvider.getDynamicBean(plugin);
+      }
+      Map<Change.Id, PluginDefinedInfo> out = new HashMap<>();
+      cds.forEach(cd -> out.put(cd.getId(), new MyInfo("opt " + opts.opt)));
+      return out;
+    }
+  }
+
+  protected static class PluginDefinedOptionAttributeModule extends AbstractModule {
+    @Override
+    public void configure() {
+      DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class)
+          .to(BulkAttributeFactoryWithOption.class);
+      bind(DynamicBean.class).annotatedWith(Exports.named(Query.class)).to(MyOptions.class);
+      bind(DynamicBean.class).annotatedWith(Exports.named(QueryChanges.class)).to(MyOptions.class);
+      bind(DynamicBean.class).annotatedWith(Exports.named(GetChange.class)).to(MyOptions.class);
+    }
+  }
+
   protected void getChangeWithNullAttribute(PluginInfoGetter getter) throws Exception {
     Change.Id id = createChange().getChange().getId();
     assertThat(getter.call(id)).isNull();
@@ -138,6 +238,113 @@
     assertThat(getter.call(id)).isNull();
   }
 
+  protected void getSingleChangeWithPluginDefinedBulkAttribute(BulkPluginInfoGetterWithId getter)
+      throws Exception {
+    Change.Id id = createChange().getChange().getId();
+
+    Map<Change.Id, List<PluginDefinedInfo>> pluginInfos = getter.call(id);
+    assertThat(pluginInfos.get(id)).isNull();
+
+    try (AutoCloseable ignored =
+        installPlugin("my-plugin", PluginDefinedSimpleAttributeModule.class)) {
+      pluginInfos = getter.call(id);
+      assertThat(pluginInfos.get(id)).containsExactly(new MyInfo("my-plugin", "change " + id));
+    }
+
+    pluginInfos = getter.call(id);
+    assertThat(pluginInfos.get(id)).isNull();
+  }
+
+  protected void getMultipleChangesWithPluginDefinedBulkAttribute(BulkPluginInfoGetter getter)
+      throws Exception {
+    Change.Id id1 = createChange().getChange().getId();
+    Change.Id id2 = createChange().getChange().getId();
+
+    Map<Change.Id, List<PluginDefinedInfo>> pluginInfos = getter.call();
+    assertThat(pluginInfos.get(id1)).isNull();
+    assertThat(pluginInfos.get(id2)).isNull();
+
+    try (AutoCloseable ignored =
+        installPlugin("my-plugin", PluginDefinedSimpleAttributeModule.class)) {
+      pluginInfos = getter.call();
+      assertThat(pluginInfos.get(id1)).containsExactly(new MyInfo("my-plugin", "change " + id1));
+      assertThat(pluginInfos.get(id2)).containsExactly(new MyInfo("my-plugin", "change " + id2));
+    }
+
+    pluginInfos = getter.call();
+    assertThat(pluginInfos.get(id1)).isNull();
+    assertThat(pluginInfos.get(id2)).isNull();
+  }
+
+  protected void getChangesByCommitMessageWithPluginDefinedBulkAttribute(
+      BulkPluginInfoGetter getter) throws Exception {
+    Change.Id changeWithNoInfo = changeOperations.newChange().commitMessage("no-info").create();
+    Change.Id changeWithInfo = changeOperations.newChange().commitMessage("info").create();
+
+    Map<Change.Id, List<PluginDefinedInfo>> pluginInfos = getter.call();
+    assertThat(pluginInfos.get(changeWithNoInfo)).isNull();
+    assertThat(pluginInfos.get(changeWithInfo)).isNull();
+
+    try (AutoCloseable ignored =
+        installPlugin("my-plugin", PluginDefinedChangesByCommitBulkAttributeModule.class)) {
+      pluginInfos = getter.call();
+      assertThat(pluginInfos.get(changeWithNoInfo)).isNull();
+      assertThat(pluginInfos.get(changeWithInfo))
+          .containsExactly(new MyInfo("my-plugin", "change " + changeWithInfo));
+    }
+
+    pluginInfos = getter.call();
+    assertThat(pluginInfos.get(changeWithNoInfo)).isNull();
+    assertThat(pluginInfos.get(changeWithInfo)).isNull();
+  }
+
+  protected void getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
+      BulkPluginInfoGetter getter) throws Exception {
+    Change.Id id1 = createChange().getChange().getId();
+    Change.Id id2 = createChange().getChange().getId();
+
+    Map<Change.Id, List<PluginDefinedInfo>> pluginInfos = getter.call();
+    assertThat(pluginInfos.get(id1)).isNull();
+    assertThat(pluginInfos.get(id2)).isNull();
+
+    try (AutoCloseable ignored =
+            installPlugin("my-plugin-1", PluginDefinedSimpleAttributeModule.class);
+        AutoCloseable ignored1 = installPlugin("my-plugin-2", SimpleAttributeModule.class)) {
+      pluginInfos = getter.call();
+      assertThat(pluginInfos.get(id1)).contains(new MyInfo("my-plugin-1", "change " + id1));
+      assertThat(pluginInfos.get(id1)).contains(new MyInfo("my-plugin-2", "change " + id1));
+      assertThat(pluginInfos.get(id2)).contains(new MyInfo("my-plugin-1", "change " + id2));
+      assertThat(pluginInfos.get(id2)).contains(new MyInfo("my-plugin-2", "change " + id2));
+    }
+
+    pluginInfos = getter.call();
+    assertThat(pluginInfos.get(id1)).isNull();
+    assertThat(pluginInfos.get(id2)).isNull();
+  }
+
+  protected void getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
+      BulkPluginInfoGetter getter) throws Exception {
+    Change.Id id1 = createChange().getChange().getId();
+    Change.Id id2 = createChange().getChange().getId();
+    int timesCalled = SingleCallBulkFactoryAttribute.timesCreateCalled;
+
+    Map<Change.Id, List<PluginDefinedInfo>> pluginInfos = getter.call();
+    assertThat(pluginInfos.get(id1)).isNull();
+    assertThat(pluginInfos.get(id2)).isNull();
+
+    try (AutoCloseable ignored =
+        installPlugin("my-plugin", PluginDefinedSingleCallBulkAttributeModule.class)) {
+      pluginInfos = getter.call();
+      assertThat(pluginInfos.get(id1)).containsExactly(new MyInfo("my-plugin", "change " + id1));
+      assertThat(pluginInfos.get(id2)).containsExactly(new MyInfo("my-plugin", "change " + id2));
+      assertThat(SingleCallBulkFactoryAttribute.timesCreateCalled).isEqualTo(timesCalled + 1);
+    }
+
+    pluginInfos = getter.call();
+    assertThat(pluginInfos.get(id1)).isNull();
+    assertThat(pluginInfos.get(id2)).isNull();
+  }
+
   protected void getChangeWithOption(
       PluginInfoGetter getterWithoutOptions, PluginInfoGetterWithOptions getterWithOptions)
       throws Exception {
@@ -154,17 +361,61 @@
     assertThat(getterWithoutOptions.call(id)).isNull();
   }
 
-  protected static List<MyInfo> pluginInfoFromSingletonList(List<ChangeInfo> changeInfos) {
+  protected void getChangeWithPluginDefinedBulkAttributeOption(
+      BulkPluginInfoGetterWithId getterWithoutOptions,
+      BulkPluginInfoGetterWithIdAndOptions getterWithOptions)
+      throws Exception {
+    Change.Id id = createChange().getChange().getId();
+    assertThat(getterWithoutOptions.call(id).get(id)).isNull();
+
+    try (AutoCloseable ignored =
+        installPlugin("my-plugin", PluginDefinedOptionAttributeModule.class)) {
+      assertThat(getterWithoutOptions.call(id).get(id))
+          .containsExactly(new MyInfo("my-plugin", "opt null"));
+      assertThat(
+              getterWithOptions.call(id, ImmutableListMultimap.of("my-plugin--opt", "foo")).get(id))
+          .containsExactly(new MyInfo("my-plugin", "opt foo"));
+    }
+
+    assertThat(getterWithoutOptions.call(id).get(id)).isNull();
+  }
+
+  protected void getChangeWithPluginDefinedBulkAttributeWithException(
+      BulkPluginInfoGetterWithId getter) throws Exception {
+    Change.Id id = createChange().getChange().getId();
+    assertThat(getter.call(id).get(id)).isNull();
+
+    try (AutoCloseable ignored =
+        installPlugin("my-plugin", PluginDefinedBulkExceptionModule.class)) {
+      PluginDefinedInfo errorInfo = new PluginDefinedInfo();
+      List<PluginDefinedInfo> outputInfos = getter.call(id).get(id);
+      assertThat(outputInfos).hasSize(1);
+      assertThat(outputInfos.get(0).name).isEqualTo("my-plugin");
+      assertThat(outputInfos.get(0).message).isEqualTo("Something went wrong in plugin: my-plugin");
+    }
+
+    assertThat(getter.call(id).get(id)).isNull();
+  }
+
+  protected static List<PluginDefinedInfo> pluginInfoFromSingletonList(
+      List<ChangeInfo> changeInfos) {
     assertThat(changeInfos).hasSize(1);
     return pluginInfoFromChangeInfo(changeInfos.get(0));
   }
 
-  protected static List<MyInfo> pluginInfoFromChangeInfo(ChangeInfo changeInfo) {
+  protected static List<PluginDefinedInfo> pluginInfoFromChangeInfo(ChangeInfo changeInfo) {
     List<PluginDefinedInfo> pluginInfo = changeInfo.plugins;
     if (pluginInfo == null) {
       return null;
     }
-    return pluginInfo.stream().map(MyInfo.class::cast).collect(toImmutableList());
+    return pluginInfo.stream().map(PluginDefinedInfo.class::cast).collect(toImmutableList());
+  }
+
+  protected static Map<Change.Id, List<PluginDefinedInfo>> pluginInfosFromChangeInfos(
+      List<ChangeInfo> changeInfos) {
+    Map<Change.Id, List<PluginDefinedInfo>> out = new HashMap<>();
+    changeInfos.forEach(ci -> out.put(Change.id(ci._number), pluginInfoFromChangeInfo(ci)));
+    return out;
   }
 
   /**
@@ -180,7 +431,8 @@
    * @param plugins list of {@code MyInfo} objects, each as a raw map returned from Gson.
    * @return decoded list of {@code MyInfo}s.
    */
-  protected static List<MyInfo> decodeRawPluginsList(Gson gson, @Nullable Object plugins) {
+  protected static List<PluginDefinedInfo> decodeRawPluginsList(
+      Gson gson, @Nullable Object plugins) {
     if (plugins == null) {
       return null;
     }
@@ -188,14 +440,44 @@
     return gson.fromJson(gson.toJson(plugins), new TypeToken<List<MyInfo>>() {}.getType());
   }
 
+  protected static Map<Change.Id, List<PluginDefinedInfo>> getPluginInfosFromChangeInfos(
+      Gson gson, List<Map<String, Object>> changeInfos) {
+    Map<Change.Id, List<PluginDefinedInfo>> out = new HashMap<>();
+    changeInfos.forEach(
+        change -> {
+          Double changeId =
+              (Double)
+                  (change.get("number") != null ? change.get("number") : change.get("_number"));
+          out.put(
+              Change.id(changeId.intValue()), decodeRawPluginsList(gson, change.get("plugins")));
+        });
+    return out;
+  }
+
   @FunctionalInterface
   protected interface PluginInfoGetter {
-    List<MyInfo> call(Change.Id id) throws Exception;
+    List<PluginDefinedInfo> call(Change.Id id) throws Exception;
+  }
+
+  @FunctionalInterface
+  protected interface BulkPluginInfoGetter {
+    Map<Change.Id, List<PluginDefinedInfo>> call() throws Exception;
+  }
+
+  @FunctionalInterface
+  protected interface BulkPluginInfoGetterWithId {
+    Map<Change.Id, List<PluginDefinedInfo>> call(Change.Id id) throws Exception;
+  }
+
+  @FunctionalInterface
+  protected interface BulkPluginInfoGetterWithIdAndOptions {
+    Map<Change.Id, List<PluginDefinedInfo>> call(
+        Change.Id id, ImmutableListMultimap<String, String> pluginOptions) throws Exception;
   }
 
   @FunctionalInterface
   protected interface PluginInfoGetterWithOptions {
-    List<MyInfo> call(Change.Id id, ImmutableListMultimap<String, String> pluginOptions)
+    List<PluginDefinedInfo> call(Change.Id id, ImmutableListMultimap<String, String> pluginOptions)
         throws Exception;
   }
 }
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index 3ab1cec..6897488 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.GroupsUpdate;
@@ -110,7 +111,7 @@
           throw new NoSuchGroupException(n);
         }
         addGroupMember(group.get().getGroupUUID(), id);
-        if ("Service Users".equals(n)) {
+        if (ServiceUserClassifier.SERVICE_USERS.equals(n)) {
           tags.add("SERVICE_USER");
         }
       }
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index a5d8d19..03644a6 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.events.GroupIndexedListener;
 import com.google.gerrit.extensions.events.ProjectIndexedListener;
 import com.google.gerrit.extensions.events.RevisionCreatedListener;
+import com.google.gerrit.extensions.events.TopicEditedListener;
 import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -58,6 +59,7 @@
   private final DynamicSet<GroupIndexedListener> groupIndexedListeners;
   private final DynamicSet<ProjectIndexedListener> projectIndexedListeners;
   private final DynamicSet<CommitValidationListener> commitValidationListeners;
+  private final DynamicSet<TopicEditedListener> topicEditedListeners;
   private final DynamicSet<ExceptionHook> exceptionHooks;
   private final DynamicSet<PerformanceLogger> performanceLoggers;
   private final DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
@@ -89,6 +91,7 @@
       DynamicSet<GroupIndexedListener> groupIndexedListeners,
       DynamicSet<ProjectIndexedListener> projectIndexedListeners,
       DynamicSet<CommitValidationListener> commitValidationListeners,
+      DynamicSet<TopicEditedListener> topicEditedListeners,
       DynamicSet<ExceptionHook> exceptionHooks,
       DynamicSet<PerformanceLogger> performanceLoggers,
       DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners,
@@ -116,6 +119,7 @@
     this.groupIndexedListeners = groupIndexedListeners;
     this.projectIndexedListeners = projectIndexedListeners;
     this.commitValidationListeners = commitValidationListeners;
+    this.topicEditedListeners = topicEditedListeners;
     this.exceptionHooks = exceptionHooks;
     this.performanceLoggers = performanceLoggers;
     this.projectCreationValidationListeners = projectCreationValidationListeners;
@@ -168,6 +172,10 @@
       return add(commitValidationListeners, commitValidationListener);
     }
 
+    public Registration add(TopicEditedListener topicEditedListener) {
+      return add(topicEditedListeners, topicEditedListener);
+    }
+
     public Registration add(ExceptionHook exceptionHook) {
       return add(exceptionHooks, exceptionHook);
     }
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 5942c0f..0025396 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -323,6 +323,7 @@
    * @param desc server description.
    * @param baseConfig default config values; merged with config from {@code desc}.
    * @param testSysModule additional Guice module to use.
+   * @param testSshModule additional Guice module to use.
    * @return started server.
    * @throws Exception
    */
@@ -331,14 +332,15 @@
       Description desc,
       Config baseConfig,
       @Nullable Module testSysModule,
-      @Nullable Module testAuditModule)
+      @Nullable Module testAuditModule,
+      @Nullable Module testSshModule)
       throws Exception {
     Path site = temporaryFolder.newFolder().toPath();
     try {
       if (!desc.memory()) {
         init(desc, baseConfig, site);
       }
-      return start(desc, baseConfig, site, testSysModule, testAuditModule, null);
+      return start(desc, baseConfig, site, testSysModule, testAuditModule, testSshModule, null);
     } catch (Exception e) {
       throw e;
     }
@@ -354,6 +356,7 @@
    *     initialize this directory. Can be retrieved from the returned instance via {@link
    *     #getSitePath()}.
    * @param testSysModule optional additional module to add to the system injector.
+   * @param testSshModule optional additional module to add to the ssh injector.
    * @param inMemoryRepoManager {@link InMemoryRepositoryManager} that should be used if the site is
    *     started in memory
    * @param additionalArgs additional command-line arguments for the daemon program; only allowed if
@@ -367,6 +370,7 @@
       Path site,
       @Nullable Module testSysModule,
       @Nullable Module testAuditModule,
+      @Nullable Module testSshModule,
       @Nullable InMemoryRepositoryManager inMemoryRepoManager,
       String... additionalArgs)
       throws Exception {
@@ -390,6 +394,9 @@
     if (testSysModule != null) {
       daemon.addAdditionalSysModuleForTesting(testSysModule);
     }
+    if (testSshModule != null) {
+      daemon.addAdditionalSshModuleForTesting(testSshModule);
+    }
     daemon.setEnableSshd(desc.useSsh());
 
     if (desc.memory()) {
@@ -614,7 +621,24 @@
 
     server.close();
     server.daemon.stop();
-    return start(server.desc, cfg, site, null, null, inMemoryRepoManager);
+    return start(server.desc, cfg, site, null, null, null, inMemoryRepoManager);
+  }
+
+  public static GerritServer restart(
+      GerritServer server, @Nullable Module testSysModule, @Nullable Module testSshModule)
+      throws Exception {
+    checkState(server.desc.sandboxed(), "restarting as slave requires @Sandboxed");
+    Config cfg = server.testInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
+    Path site = server.testInjector.getInstance(Key.get(Path.class, SitePath.class));
+
+    InMemoryRepositoryManager inMemoryRepoManager = null;
+    if (hasBinding(server.testInjector, InMemoryRepositoryManager.class)) {
+      inMemoryRepoManager = server.testInjector.getInstance(InMemoryRepositoryManager.class);
+    }
+
+    server.close();
+    server.daemon.stop();
+    return start(server.desc, cfg, site, testSysModule, null, testSshModule, inMemoryRepoManager);
   }
 
   private static boolean hasBinding(Injector injector, Class<?> clazz) {
diff --git a/java/com/google/gerrit/acceptance/SshSession.java b/java/com/google/gerrit/acceptance/SshSession.java
index 6ecf85f..6698657 100644
--- a/java/com/google/gerrit/acceptance/SshSession.java
+++ b/java/com/google/gerrit/acceptance/SshSession.java
@@ -65,6 +65,22 @@
     }
   }
 
+  @SuppressWarnings("resource")
+  public int execAndReturnStatus(String command) throws Exception {
+    ChannelExec channel = (ChannelExec) getSession().openChannel("exec");
+    try {
+      channel.setCommand(command);
+      InputStream err = channel.getErrStream();
+      channel.connect();
+
+      Scanner s = new Scanner(err, UTF_8.name()).useDelimiter("\\A");
+      error = s.hasNext() ? s.next() : null;
+      return channel.getExitStatus();
+    } finally {
+      channel.disconnect();
+    }
+  }
+
   private boolean hasError() {
     return error != null;
   }
diff --git a/java/com/google/gerrit/acceptance/StandaloneSiteTest.java b/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
index 43fe4eb..dcb49a5 100644
--- a/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
+++ b/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
@@ -187,7 +187,14 @@
   private GerritServer startImpl(@Nullable Module testSysModule, String... additionalArgs)
       throws Exception {
     return GerritServer.start(
-        serverDesc, baseConfig, sitePaths.site_path, testSysModule, null, null, additionalArgs);
+        serverDesc,
+        baseConfig,
+        sitePaths.site_path,
+        testSysModule,
+        null,
+        null,
+        null,
+        additionalArgs);
   }
 
   protected static void runGerrit(String... args) throws Exception {
diff --git a/java/com/google/gerrit/acceptance/ssh/GracefulCommand.java b/java/com/google/gerrit/acceptance/ssh/GracefulCommand.java
new file mode 100644
index 0000000..ddaf341
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/ssh/GracefulCommand.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2020 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.ssh;
+
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.gerrit.sshd.CommandMetaData;
+
+@CommandMetaData(
+    name = "graceful",
+    description = "Test command for graceful shutdown",
+    runsAt = MASTER_OR_SLAVE)
+public class GracefulCommand extends TestCommand {
+
+  @Override
+  boolean isGraceful() {
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/ssh/NonGracefulCommand.java b/java/com/google/gerrit/acceptance/ssh/NonGracefulCommand.java
new file mode 100644
index 0000000..ed635c8
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/ssh/NonGracefulCommand.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2020 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.ssh;
+
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.gerrit.sshd.CommandMetaData;
+
+@CommandMetaData(
+    name = "non-graceful",
+    description = "Test command for immediate shutdown",
+    runsAt = MASTER_OR_SLAVE)
+public class NonGracefulCommand extends TestCommand {
+
+  @Override
+  boolean isGraceful() {
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/ssh/TestCommand.java b/java/com/google/gerrit/acceptance/ssh/TestCommand.java
new file mode 100644
index 0000000..7839578
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/ssh/TestCommand.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2020 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.ssh;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.sshd.SshCommand;
+import java.util.concurrent.CyclicBarrier;
+import org.kohsuke.args4j.Option;
+
+public abstract class TestCommand extends SshCommand {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  public static final CyclicBarrier syncPoint = new CyclicBarrier(2);
+
+  @Option(
+      name = "--duration",
+      aliases = {"-d"},
+      required = true,
+      usage = "Duration of the command execution in seconds")
+  private int duration;
+
+  @Override
+  protected void run() throws UnloggedFailure, Failure, Exception {
+    logger.atFine().log("Starting command.");
+    if (isGraceful()) {
+      enableGracefulStop();
+    }
+    try {
+      syncPoint.await();
+      Thread.sleep(duration * 1000);
+      logger.atFine().log("Stopping command.");
+    } catch (Exception e) {
+      throw die("Command ended prematurely.", e);
+    }
+  }
+
+  abstract boolean isGraceful();
+}
diff --git a/java/com/google/gerrit/acceptance/ssh/TestSshCommandModule.java b/java/com/google/gerrit/acceptance/ssh/TestSshCommandModule.java
new file mode 100644
index 0000000..626092b
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/ssh/TestSshCommandModule.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2020 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.ssh;
+
+import com.google.gerrit.sshd.CommandModule;
+
+public class TestSshCommandModule extends CommandModule {
+  @Override
+  protected void configure() {
+    command("graceful").to(GracefulCommand.class);
+    command("non-graceful").to(NonGracefulCommand.class);
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/StartAwarePositionBuilder.java b/java/com/google/gerrit/acceptance/testsuite/change/StartAwarePositionBuilder.java
index c9a0eff..b9639f5 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/StartAwarePositionBuilder.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/StartAwarePositionBuilder.java
@@ -43,7 +43,7 @@
               TestRange.Position.builder().line(endLine).charOffset(endCharOffset).build();
           TestRange range = testRangeBuilder.setEnd(end).build();
           rangeConsumer.accept(range);
-          return new FileBuilder<T>(fileFunction);
+          return new FileBuilder<>(fileFunction);
         });
   }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
index e4d594b..f6e5de3 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
@@ -90,12 +90,13 @@
 
     CreateProjectArgs args = new CreateProjectArgs();
     args.setProjectName(name);
+    args.permissionsOnly = projectCreation.permissionOnly().orElse(false);
     args.branch =
         projectCreation.branches().stream().map(RefNames::fullName).collect(toImmutableList());
     args.createEmptyCommit = projectCreation.createEmptyCommit().orElse(true);
     projectCreation.parent().ifPresent(p -> args.newParent = p);
     // ProjectCreator wants non-null owner IDs.
-    args.ownerIds = new ArrayList<>();
+    args.ownerIds = new ArrayList<>(projectCreation.owners());
     projectCreation.submitType().ifPresent(st -> args.submitType = st);
     projectCreator.createProject(args);
     return Project.nameKey(name);
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
index 2649dea..3337fc3 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.acceptance.testsuite.project;
 
+import static java.util.Objects.requireNonNull;
+
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.SubmitType;
 import java.util.Optional;
@@ -35,8 +38,12 @@
 
   public abstract Optional<Boolean> createEmptyCommit();
 
+  public abstract Optional<Boolean> permissionOnly();
+
   public abstract Optional<SubmitType> submitType();
 
+  public abstract ImmutableSet<AccountGroup.UUID> owners();
+
   abstract ThrowingFunction<TestProjectCreation, Project.NameKey> projectCreator();
 
   public static Builder builder(
@@ -67,11 +74,20 @@
 
     public abstract TestProjectCreation.Builder createEmptyCommit(boolean value);
 
+    public abstract TestProjectCreation.Builder permissionOnly(boolean value);
+
     /** Skips the empty commit on creation. This means that project's branches will not exist. */
     public TestProjectCreation.Builder noEmptyCommit() {
       return createEmptyCommit(false);
     }
 
+    public TestProjectCreation.Builder addOwner(AccountGroup.UUID owner) {
+      ownersBuilder().add(requireNonNull(owner, "owner"));
+      return this;
+    }
+
+    abstract ImmutableSet.Builder<AccountGroup.UUID> ownersBuilder();
+
     abstract TestProjectCreation.Builder projectCreator(
         ThrowingFunction<TestProjectCreation, Project.NameKey> projectCreator);
 
diff --git a/java/com/google/gerrit/entities/BUILD b/java/com/google/gerrit/entities/BUILD
index 66d1869..c0f5de6 100644
--- a/java/com/google/gerrit/entities/BUILD
+++ b/java/com/google/gerrit/entities/BUILD
@@ -10,11 +10,13 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/extensions:api",
+        "//lib:gson",
         "//lib:guava",
         "//lib:jgit",
         "//lib:protobuf",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/auto:auto-value-gson",
         "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//proto:cache_java_proto",
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index 845a9bb..aab72ea72 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -21,6 +21,9 @@
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
+import com.google.gson.annotations.SerializedName;
 import java.sql.Timestamp;
 import java.util.Arrays;
 import java.util.Optional;
@@ -283,6 +286,7 @@
       return Change.key(KeyUtil.decode(str));
     }
 
+    @SerializedName("id")
     abstract String key();
 
     public String get() {
@@ -307,6 +311,10 @@
     public final String toString() {
       return get();
     }
+
+    public static TypeAdapter<Key> typeAdapter(Gson gson) {
+      return new AutoValue_Change_Key.GsonTypeAdapter(gson);
+    }
   }
 
   /** Minimum database status constant for an open change. */
diff --git a/java/com/google/gerrit/entities/CommentContext.java b/java/com/google/gerrit/entities/CommentContext.java
new file mode 100644
index 0000000..183f6d0
--- /dev/null
+++ b/java/com/google/gerrit/entities/CommentContext.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2020 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.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableMap;
+
+/** An entity class representing all context lines of a comment. */
+@AutoValue
+public abstract class CommentContext {
+  public static CommentContext create(ImmutableMap<Integer, String> lines) {
+    return new AutoValue_CommentContext(lines);
+  }
+
+  /** Map of {line number, line text} of the context lines of a comment */
+  public abstract ImmutableMap<Integer, String> lines();
+}
diff --git a/java/com/google/gerrit/entities/EntitiesAdapterFactory.java b/java/com/google/gerrit/entities/EntitiesAdapterFactory.java
new file mode 100644
index 0000000..e6a06fd
--- /dev/null
+++ b/java/com/google/gerrit/entities/EntitiesAdapterFactory.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2020 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.entities;
+
+import com.google.gson.TypeAdapterFactory;
+import com.ryanharter.auto.value.gson.GsonTypeAdapterFactory;
+
+@GsonTypeAdapterFactory
+public abstract class EntitiesAdapterFactory implements TypeAdapterFactory {
+  public static TypeAdapterFactory create() {
+    return new AutoValueGson_EntitiesAdapterFactory();
+  }
+}
diff --git a/java/com/google/gerrit/entities/RefNames.java b/java/com/google/gerrit/entities/RefNames.java
index 400861c..5595bc7 100644
--- a/java/com/google/gerrit/entities/RefNames.java
+++ b/java/com/google/gerrit/entities/RefNames.java
@@ -288,10 +288,16 @@
    * Whether the ref is managed by Gerrit. Covers all Gerrit-internal refs like refs/cache-automerge
    * and refs/meta as well as refs/changes. Does not cover user-created refs like branches or custom
    * ref namespaces like refs/my-company.
+   *
+   * <p>Any ref for which this method evaluates to true will be served to users who have the {@code
+   * ACCESS_DATABASE} capability.
+   *
+   * <p><b>Caution</b>Any ref not in this list will be served if the user was granted a READ
+   * permission on it using Gerrit's permission model.
    */
   public static boolean isGerritRef(String ref) {
     return ref.startsWith(REFS_CHANGES)
-        || ref.startsWith(REFS_META)
+        || ref.startsWith(REFS_EXTERNAL_IDS)
         || ref.startsWith(REFS_CACHE_AUTOMERGE)
         || ref.startsWith(REFS_DRAFT_COMMENTS)
         || ref.startsWith(REFS_DELETED_GROUPS)
@@ -299,7 +305,8 @@
         || ref.startsWith(REFS_GROUPS)
         || ref.startsWith(REFS_GROUPNAMES)
         || ref.startsWith(REFS_USERS)
-        || ref.startsWith(REFS_STARRED_CHANGES);
+        || ref.startsWith(REFS_STARRED_CHANGES)
+        || ref.startsWith(REFS_REJECT_COMMITS);
   }
 
   static Integer parseShardedRefPart(String name) {
diff --git a/java/com/google/gerrit/extensions/api/changes/CommentInput.java b/java/com/google/gerrit/extensions/api/changes/CommentInput.java
index 4e2f033..5970541 100644
--- a/java/com/google/gerrit/extensions/api/changes/CommentInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/CommentInput.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.extensions.api.changes;
 
-/** Input to the {@link ChangeApi#comments(CommentInput)}. */
+/** Input to the {@link ChangeApi#comments}. */
 public class CommentInput {
   public boolean enableContext;
 }
diff --git a/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java b/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
index fab2ec4..423ac49 100644
--- a/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
+++ b/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
@@ -14,10 +14,15 @@
 
 package com.google.gerrit.extensions.api.config;
 
+import java.util.List;
+
 public class AccessCheckInfo {
   public String message;
   // HTTP status code
   public int status;
 
+  /** Debug logs that may help to understand why a permission is denied or allowed. */
+  public List<String> debugLogs;
+
   // for future extension, we may add inputs / results for bulk checks.
 }
diff --git a/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java b/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java
index 2c166d0..e582f1b 100644
--- a/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java
+++ b/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java
@@ -48,6 +48,7 @@
 
   public static class ConsistencyProblemInfo {
     public enum Status {
+      FATAL,
       ERROR,
       WARNING,
     }
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index c6555b9..681d0bd 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -31,7 +31,9 @@
     PULL,
     CHECKOUT,
     CHERRY_PICK,
-    FORMAT_PATCH
+    FORMAT_PATCH,
+    BRANCH,
+    RESET,
   }
 
   public enum DateFormat {
@@ -75,6 +77,7 @@
   public enum EmailStrategy {
     ENABLED,
     CC_ON_OWN_COMMENTS,
+    ATTENTION_SET_ONLY,
     DISABLED
   }
 
diff --git a/java/com/google/gerrit/extensions/common/MergePatchSetInput.java b/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
index 53f5e07..734d7e9 100644
--- a/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
+++ b/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
@@ -14,9 +14,12 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+
 public class MergePatchSetInput {
   public String subject;
   public boolean inheritParent;
   public String baseChange;
   public MergeInput merge;
+  public AccountInput author;
 }
diff --git a/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java b/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
index e6fef0f..69bfa2c 100644
--- a/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
+++ b/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
@@ -16,4 +16,5 @@
 
 public class PluginDefinedInfo {
   public String name;
+  public String message;
 }
diff --git a/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index 5c4830c..a3a67e5 100644
--- a/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PropertyMap;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AuthResult;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -161,15 +162,11 @@
   }
 
   @Override
-  public ExternalId.Key getLastLoginExternalId() {
-    return val != null ? val.getExternalId() : null;
-  }
-
-  @Override
   public CurrentUser getUser() {
     if (user == null) {
       if (isSignedIn()) {
-        user = identified.create(val.getAccountId());
+
+        user = identified.create(val.getAccountId(), getUserProperties(val));
       } else {
         user = anonymousProvider.get();
       }
@@ -177,6 +174,15 @@
     return user;
   }
 
+  private static PropertyMap getUserProperties(@Nullable WebSessionManager.Val val) {
+    if (val == null || val.getExternalId() == null) {
+      return PropertyMap.EMPTY;
+    }
+    return PropertyMap.builder()
+        .put(CurrentUser.LAST_LOGIN_EXTERNAL_ID_PROPERTY_KEY, val.getExternalId())
+        .build();
+  }
+
   @Override
   public void login(AuthResult res, boolean rememberMe) {
     Account.Id id = res.getAccountId();
@@ -194,7 +200,7 @@
     key = manager.createKey(id);
     val = manager.createVal(key, id, rememberMe, identity, null, null);
     saveCookie();
-    user = identified.create(val.getAccountId());
+    user = identified.create(val.getAccountId(), getUserProperties(val));
   }
 
   /** Set the user account for this current request only. */
@@ -202,7 +208,7 @@
   public void setUserAccountId(Account.Id id) {
     key = new Key("id:" + id);
     val = new Val(id, 0, false, null, 0, null, null);
-    user = identified.runAs(id, user);
+    user = identified.runAs(id, user, PropertyMap.EMPTY);
   }
 
   @Override
diff --git a/java/com/google/gerrit/httpd/WebSession.java b/java/com/google/gerrit/httpd/WebSession.java
index e8b54fe..daf30ff 100644
--- a/java/com/google/gerrit/httpd/WebSession.java
+++ b/java/com/google/gerrit/httpd/WebSession.java
@@ -19,7 +19,6 @@
 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.externalids.ExternalId;
 
 public interface WebSession {
   boolean isSignedIn();
@@ -29,8 +28,6 @@
 
   boolean isValidXGerritAuth(String keyIn);
 
-  ExternalId.Key getLastLoginExternalId();
-
   CurrentUser getUser();
 
   void login(AuthResult res, boolean rememberMe);
diff --git a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
index 509a9f1..e20c9b9 100644
--- a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
+++ b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
@@ -35,6 +35,7 @@
 import java.io.IOException;
 import java.io.OutputStream;
 import java.util.Locale;
+import java.util.Optional;
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -124,8 +125,8 @@
   }
 
   private static boolean correctUser(String user, WebSession session) {
-    ExternalId.Key id = session.getLastLoginExternalId();
-    return id != null && id.equals(ExternalId.Key.create(SCHEME_GERRIT, user));
+    Optional<ExternalId.Key> id = session.getUser().getLastLoginExternalIdKey();
+    return id.map(i -> i.equals(ExternalId.Key.create(SCHEME_GERRIT, user))).orElse(false);
   }
 
   String getRemoteUser(HttpServletRequest req) {
diff --git a/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java b/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java
index d92da18..e3e96df 100644
--- a/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java
+++ b/java/com/google/gerrit/httpd/raw/AuthorizationCheckServlet.java
@@ -43,8 +43,8 @@
   @Override
   protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
     CacheHeaders.setNotCacheable(res);
-    res.setContentLength(0);
     if (user.get().isIdentifiedUser()) {
+      res.setContentLength(0);
       res.setStatus(HttpServletResponse.SC_NO_CONTENT);
     } else {
       res.setStatus(HttpServletResponse.SC_FORBIDDEN);
diff --git a/java/com/google/gerrit/httpd/raw/CatServlet.java b/java/com/google/gerrit/httpd/raw/CatServlet.java
index 7a4f4e6..f5d72b2 100644
--- a/java/com/google/gerrit/httpd/raw/CatServlet.java
+++ b/java/com/google/gerrit/httpd/raw/CatServlet.java
@@ -123,7 +123,7 @@
     final Change.Id changeId = patchKey.patchSetId().changeId();
     String revision;
     try {
-      ChangeNotes notes = changeNotesFactory.createChecked(changeId);
+      ChangeNotes notes = changeNotesFactory.createCheckedUsingIndexLookup(changeId);
       permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
       projectCache
           .get(notes.getProjectName())
diff --git a/java/com/google/gerrit/httpd/restapi/ParameterParser.java b/java/com/google/gerrit/httpd/restapi/ParameterParser.java
index 172321d..95d99f0 100644
--- a/java/com/google/gerrit/httpd/restapi/ParameterParser.java
+++ b/java/com/google/gerrit/httpd/restapi/ParameterParser.java
@@ -32,7 +32,6 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.common.Nullable;
-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;
@@ -44,7 +43,6 @@
 import com.google.gson.JsonObject;
 import com.google.gson.JsonPrimitive;
 import com.google.inject.Inject;
-import com.google.inject.Injector;
 import java.io.IOException;
 import java.io.StringWriter;
 import java.util.HashSet;
@@ -149,24 +147,20 @@
   }
 
   private final CmdLineParser.Factory parserFactory;
-  private final Injector injector;
-  private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
   @Inject
-  ParameterParser(
-      CmdLineParser.Factory pf,
-      Injector injector,
-      DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
+  ParameterParser(CmdLineParser.Factory pf) {
     this.parserFactory = pf;
-    this.injector = injector;
-    this.dynamicBeans = dynamicBeans;
   }
 
   <T> boolean parse(
-      T param, ListMultimap<String, String> in, HttpServletRequest req, HttpServletResponse res)
+      T param,
+      DynamicOptions pluginOptions,
+      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();
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 665cc33..4d55b36 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -103,11 +103,13 @@
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.OptionUtil;
 import com.google.gerrit.server.RequestInfo;
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.audit.ExtendedHttpAuditEvent;
+import com.google.gerrit.server.cache.PerThreadCache;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.group.GroupAuditService;
@@ -145,6 +147,7 @@
 import com.google.gson.stream.JsonWriter;
 import com.google.gson.stream.MalformedJsonException;
 import com.google.inject.Inject;
+import com.google.inject.Injector;
 import com.google.inject.Provider;
 import com.google.inject.TypeLiteral;
 import com.google.inject.util.Providers;
@@ -249,6 +252,8 @@
     final ChangeFinder changeFinder;
     final RetryHelper retryHelper;
     final PluginSetContext<ExceptionHook> exceptionHooks;
+    final Injector injector;
+    final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
     @Inject
     Globals(
@@ -264,7 +269,9 @@
         DynamicSet<PerformanceLogger> performanceLoggers,
         ChangeFinder changeFinder,
         RetryHelper retryHelper,
-        PluginSetContext<ExceptionHook> exceptionHooks) {
+        PluginSetContext<ExceptionHook> exceptionHooks,
+        Injector injector,
+        DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
       this.currentUser = currentUser;
       this.webSession = webSession;
       this.paramParser = paramParser;
@@ -279,6 +286,8 @@
       this.retryHelper = retryHelper;
       this.exceptionHooks = exceptionHooks;
       allowOrigin = makeAllowOrigin(config);
+      this.injector = injector;
+      this.dynamicBeans = dynamicBeans;
     }
 
     private static Pattern makeAllowOrigin(Config cfg) {
@@ -327,7 +336,7 @@
     try (TraceContext traceContext = enableTracing(req, res)) {
       List<IdString> path = splitPath(req);
 
-      try {
+      try (PerThreadCache ignored = PerThreadCache.create()) {
         RequestInfo requestInfo = createRequestInfo(traceContext, requestUri(req), path);
         globals.requestListeners.runEach(l -> l.onRequest(requestInfo));
 
@@ -497,105 +506,116 @@
             return;
           }
 
-          if (!globals.paramParser.get().parse(viewData.view, qp.params(), req, res)) {
-            return;
+          try (DynamicOptions pluginOptions =
+              new DynamicOptions(viewData.view, globals.injector, globals.dynamicBeans)) {
+            if (!globals
+                .paramParser
+                .get()
+                .parse(viewData.view, pluginOptions, qp.params(), req, res)) {
+              return;
+            }
+
+            if (viewData.view instanceof RestReadView<?> && isRead(req)) {
+              response =
+                  invokeRestReadViewWithRetry(
+                      req,
+                      traceContext,
+                      viewData,
+                      (RestReadView<RestResource>) viewData.view,
+                      rsrc);
+            } else if (viewData.view instanceof RestModifyView<?, ?>) {
+              @SuppressWarnings("unchecked")
+              RestModifyView<RestResource, Object> m =
+                  (RestModifyView<RestResource, Object>) viewData.view;
+
+              Type type = inputType(m);
+              inputRequestBody = parseRequest(req, type);
+              response =
+                  invokeRestModifyViewWithRetry(
+                      req, traceContext, viewData, m, rsrc, inputRequestBody);
+
+              if (inputRequestBody instanceof RawInput) {
+                try (InputStream is = req.getInputStream()) {
+                  ServletUtils.consumeRequestBody(is);
+                }
+              }
+            } else if (viewData.view instanceof RestCollectionCreateView<?, ?, ?>) {
+              @SuppressWarnings("unchecked")
+              RestCollectionCreateView<RestResource, RestResource, Object> m =
+                  (RestCollectionCreateView<RestResource, RestResource, Object>) viewData.view;
+
+              Type type = inputType(m);
+              inputRequestBody = parseRequest(req, type);
+              response =
+                  invokeRestCollectionCreateViewWithRetry(
+                      req, traceContext, viewData, m, rsrc, path.get(0), inputRequestBody);
+              if (inputRequestBody instanceof RawInput) {
+                try (InputStream is = req.getInputStream()) {
+                  ServletUtils.consumeRequestBody(is);
+                }
+              }
+            } else if (viewData.view instanceof RestCollectionDeleteMissingView<?, ?, ?>) {
+              @SuppressWarnings("unchecked")
+              RestCollectionDeleteMissingView<RestResource, RestResource, Object> m =
+                  (RestCollectionDeleteMissingView<RestResource, RestResource, Object>)
+                      viewData.view;
+
+              Type type = inputType(m);
+              inputRequestBody = parseRequest(req, type);
+              response =
+                  invokeRestCollectionDeleteMissingViewWithRetry(
+                      req, traceContext, viewData, m, rsrc, path.get(0), inputRequestBody);
+              if (inputRequestBody instanceof RawInput) {
+                try (InputStream is = req.getInputStream()) {
+                  ServletUtils.consumeRequestBody(is);
+                }
+              }
+            } else if (viewData.view instanceof RestCollectionModifyView<?, ?, ?>) {
+              @SuppressWarnings("unchecked")
+              RestCollectionModifyView<RestResource, RestResource, Object> m =
+                  (RestCollectionModifyView<RestResource, RestResource, Object>) viewData.view;
+
+              Type type = inputType(m);
+              inputRequestBody = parseRequest(req, type);
+              response =
+                  invokeRestCollectionModifyViewWithRetry(
+                      req, traceContext, viewData, m, rsrc, inputRequestBody);
+              if (inputRequestBody instanceof RawInput) {
+                try (InputStream is = req.getInputStream()) {
+                  ServletUtils.consumeRequestBody(is);
+                }
+              }
+            } else {
+              throw new ResourceNotFoundException();
+            }
+
+            if (response instanceof Response.Redirect) {
+              CacheHeaders.setNotCacheable(res);
+              String location = ((Response.Redirect) response).location();
+              res.sendRedirect(location);
+              logger.atFinest().log("REST call redirected to: %s", location);
+              return;
+            } else if (response instanceof Response.Accepted) {
+              CacheHeaders.setNotCacheable(res);
+              res.setStatus(response.statusCode());
+              res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted) response).location());
+              logger.atFinest().log("REST call succeeded: %d", response.statusCode());
+              return;
+            }
+
+            statusCode = response.statusCode();
+            configureCaching(req, res, traceContext, rsrc, viewData, response.caching());
+            res.setStatus(statusCode);
+            logger.atFinest().log("REST call succeeded: %d", statusCode);
           }
 
-          if (viewData.view instanceof RestReadView<?> && isRead(req)) {
-            response =
-                invokeRestReadViewWithRetry(
-                    req, traceContext, viewData, (RestReadView<RestResource>) viewData.view, rsrc);
-          } else if (viewData.view instanceof RestModifyView<?, ?>) {
-            @SuppressWarnings("unchecked")
-            RestModifyView<RestResource, Object> m =
-                (RestModifyView<RestResource, Object>) viewData.view;
-
-            Type type = inputType(m);
-            inputRequestBody = parseRequest(req, type);
-            response =
-                invokeRestModifyViewWithRetry(
-                    req, traceContext, viewData, m, rsrc, inputRequestBody);
-
-            if (inputRequestBody instanceof RawInput) {
-              try (InputStream is = req.getInputStream()) {
-                ServletUtils.consumeRequestBody(is);
-              }
+          if (response != Response.none()) {
+            Object value = Response.unwrap(response);
+            if (value instanceof BinaryResult) {
+              responseBytes = replyBinaryResult(req, res, (BinaryResult) value);
+            } else {
+              responseBytes = replyJson(req, res, false, qp.config(), value);
             }
-          } else if (viewData.view instanceof RestCollectionCreateView<?, ?, ?>) {
-            @SuppressWarnings("unchecked")
-            RestCollectionCreateView<RestResource, RestResource, Object> m =
-                (RestCollectionCreateView<RestResource, RestResource, Object>) viewData.view;
-
-            Type type = inputType(m);
-            inputRequestBody = parseRequest(req, type);
-            response =
-                invokeRestCollectionCreateViewWithRetry(
-                    req, traceContext, viewData, m, rsrc, path.get(0), inputRequestBody);
-            if (inputRequestBody instanceof RawInput) {
-              try (InputStream is = req.getInputStream()) {
-                ServletUtils.consumeRequestBody(is);
-              }
-            }
-          } else if (viewData.view instanceof RestCollectionDeleteMissingView<?, ?, ?>) {
-            @SuppressWarnings("unchecked")
-            RestCollectionDeleteMissingView<RestResource, RestResource, Object> m =
-                (RestCollectionDeleteMissingView<RestResource, RestResource, Object>) viewData.view;
-
-            Type type = inputType(m);
-            inputRequestBody = parseRequest(req, type);
-            response =
-                invokeRestCollectionDeleteMissingViewWithRetry(
-                    req, traceContext, viewData, m, rsrc, path.get(0), inputRequestBody);
-            if (inputRequestBody instanceof RawInput) {
-              try (InputStream is = req.getInputStream()) {
-                ServletUtils.consumeRequestBody(is);
-              }
-            }
-          } else if (viewData.view instanceof RestCollectionModifyView<?, ?, ?>) {
-            @SuppressWarnings("unchecked")
-            RestCollectionModifyView<RestResource, RestResource, Object> m =
-                (RestCollectionModifyView<RestResource, RestResource, Object>) viewData.view;
-
-            Type type = inputType(m);
-            inputRequestBody = parseRequest(req, type);
-            response =
-                invokeRestCollectionModifyViewWithRetry(
-                    req, traceContext, viewData, m, rsrc, inputRequestBody);
-            if (inputRequestBody instanceof RawInput) {
-              try (InputStream is = req.getInputStream()) {
-                ServletUtils.consumeRequestBody(is);
-              }
-            }
-          } else {
-            throw new ResourceNotFoundException();
-          }
-
-          if (response instanceof Response.Redirect) {
-            CacheHeaders.setNotCacheable(res);
-            String location = ((Response.Redirect) response).location();
-            res.sendRedirect(location);
-            logger.atFinest().log("REST call redirected to: %s", location);
-            return;
-          } else if (response instanceof Response.Accepted) {
-            CacheHeaders.setNotCacheable(res);
-            res.setStatus(response.statusCode());
-            res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted) response).location());
-            logger.atFinest().log("REST call succeeded: %d", response.statusCode());
-            return;
-          }
-
-          statusCode = response.statusCode();
-          configureCaching(req, res, traceContext, rsrc, viewData, response.caching());
-          res.setStatus(statusCode);
-          logger.atFinest().log("REST call succeeded: %d", statusCode);
-        }
-
-        if (response != Response.none()) {
-          Object value = Response.unwrap(response);
-          if (value instanceof BinaryResult) {
-            responseBytes = replyBinaryResult(req, res, (BinaryResult) value);
-          } else {
-            responseBytes = replyJson(req, res, false, qp.config(), value);
           }
         }
       } catch (MalformedJsonException | JsonParseException e) {
@@ -1639,9 +1659,6 @@
           "Invalid authentication method. In order to authenticate, "
               + "prefix the REST endpoint URL with /a/ (e.g. http://example.com/a/projects/).");
     }
-    if (user.isIdentifiedUser()) {
-      user.setLastLoginExternalIdKey(globals.webSession.get().getLastLoginExternalId());
-    }
   }
 
   private List<String> getParameterNames(HttpServletRequest req) {
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index ba037d9..2eb19aa 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -206,6 +206,7 @@
   private AbstractModule luceneModule;
   private Module emailModule;
   private List<Module> testSysModules = new ArrayList<>();
+  private List<Module> testSshModules = new ArrayList<>();
   private Module auditEventModule;
 
   private Runnable serverStarted;
@@ -337,6 +338,11 @@
   }
 
   @VisibleForTesting
+  public void addAdditionalSshModuleForTesting(@Nullable Module... modules) {
+    testSshModules.addAll(Arrays.asList(modules));
+  }
+
+  @VisibleForTesting
   public void start() throws IOException {
     if (dbInjector == null) {
       dbInjector = createDbInjector(true /* enableMetrics */);
@@ -532,6 +538,8 @@
             replica,
             sysInjector.getInstance(DownloadConfig.class),
             sysInjector.getInstance(LfsPluginAuthCommand.Module.class)));
+
+    modules.addAll(testSshModules);
     if (!replica) {
       modules.add(new IndexCommandsModule(sysInjector));
       modules.add(new SequenceCommandsModule());
diff --git a/java/com/google/gerrit/pgm/Reindex.java b/java/com/google/gerrit/pgm/Reindex.java
index 966801f..c8d69f1c 100644
--- a/java/com/google/gerrit/pgm/Reindex.java
+++ b/java/com/google/gerrit/pgm/Reindex.java
@@ -29,6 +29,10 @@
 import com.google.gerrit.lucene.LuceneIndexModule;
 import com.google.gerrit.pgm.util.BatchProgramModule;
 import com.google.gerrit.pgm.util.SiteProgram;
+import com.google.gerrit.server.LibModuleLoader;
+import com.google.gerrit.server.LibModuleType;
+import com.google.gerrit.server.ModuleOverloader;
+import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.IndexModule;
@@ -159,6 +163,7 @@
     }
     modules.add(indexModule);
     modules.add(new BatchProgramModule());
+    modules.add(new H2CacheModule());
     modules.add(
         new FactoryModule() {
           @Override
@@ -167,7 +172,9 @@
           }
         });
 
-    return dbInjector.createChildInjector(modules);
+    return dbInjector.createChildInjector(
+        ModuleOverloader.override(
+            modules, LibModuleLoader.loadModules(dbInjector, LibModuleType.SYS_MODULE)));
   }
 
   private void overrideConfig() {
diff --git a/java/com/google/gerrit/pgm/init/SitePathInitializer.java b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
index 8a71c1c..ddc4f79 100644
--- a/java/com/google/gerrit/pgm/init/SitePathInitializer.java
+++ b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
@@ -125,7 +125,8 @@
     extractMailExample("DeleteVoteHtml.soy");
     extractMailExample("Footer.soy");
     extractMailExample("FooterHtml.soy");
-    extractMailExample("HeaderHtml.soy");
+    extractMailExample("ChangeHeader.soy");
+    extractMailExample("ChangeHeaderHtml.soy");
     extractMailExample("HttpPasswordUpdate.soy");
     extractMailExample("HttpPasswordUpdateHtml.soy");
     extractMailExample("InboundEmailRejection.soy");
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index 35ba3d0..e9c0136 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -38,7 +38,6 @@
 import com.google.gerrit.server.account.ServiceUserClassifierImpl;
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
 import com.google.gerrit.server.cache.CacheRemovalListener;
-import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
 import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangeJson;
@@ -153,7 +152,7 @@
     install(new BatchGitModule());
     install(new DefaultPermissionBackendModule());
     install(new DefaultMemoryCacheModule());
-    install(new H2CacheModule());
+
     install(new ExternalIdModule());
     install(new GroupModule());
     install(new NoteDbModule());
diff --git a/java/com/google/gerrit/server/AnonymousUser.java b/java/com/google/gerrit/server/AnonymousUser.java
index c96d61a..91d2d05 100644
--- a/java/com/google/gerrit/server/AnonymousUser.java
+++ b/java/com/google/gerrit/server/AnonymousUser.java
@@ -27,6 +27,12 @@
   }
 
   @Override
+  public Object getCacheKey() {
+    // Treat all anonymous users as a single user
+    return "anonymous";
+  }
+
+  @Override
   public String toString() {
     return "ANONYMOUS";
   }
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 069006b..b7a00dd 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -113,6 +113,7 @@
         "//lib/commons:compress",
         "//lib/commons:dbcp",
         "//lib/commons:lang",
+        "//lib/commons:lang3",
         "//lib/commons:net",
         "//lib/commons:validator",
         "//lib/errorprone:annotations",
diff --git a/java/com/google/gerrit/server/CommentContextLoader.java b/java/com/google/gerrit/server/CommentContextLoader.java
index 7f84693..68a80c3 100644
--- a/java/com/google/gerrit/server/CommentContextLoader.java
+++ b/java/com/google/gerrit/server/CommentContextLoader.java
@@ -17,18 +17,20 @@
 import static java.util.stream.Collectors.groupingBy;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.CommentContext;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.ContextLineInfo;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.Text;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -48,7 +50,6 @@
 
   private final GitRepositoryManager repoManager;
   private final Project.NameKey project;
-  private Map<ContextData, List<ContextLineInfo>> candidates;
 
   public interface Factory {
     CommentContextLoader create(Project.NameKey project);
@@ -58,80 +59,66 @@
   CommentContextLoader(GitRepositoryManager repoManager, @Assisted Project.NameKey project) {
     this.repoManager = repoManager;
     this.project = project;
-    this.candidates = new HashMap<>();
   }
 
   /**
-   * Returns an empty list of {@link ContextLineInfo}. Clients are expected to call this method one
-   * or more times. Each call returns a reference to an empty {@link List<ContextLineInfo>}.
+   * Load the comment context for multiple comments at once. This method will open the repository
+   * and read the source files for all necessary comments' file paths.
    *
-   * <p>A single call to {@link #fill()} will cause all list references returned from this method to
-   * be populated. If a client calls this method again with a comment that was passed before calling
-   * {@link #fill()}, the new populated list will be returned.
-   *
-   * @param comment the comment entity for which we want to load the context
-   * @return a list of {@link ContextLineInfo}
+   * @param comments a list of comments.
+   * @return a Map where all entries consist of the input comments and the values are their
+   *     corresponding {@link CommentContext}.
    */
-  public List<ContextLineInfo> getContext(CommentInfo comment) {
-    ContextData key =
-        ContextData.create(
-            comment.id,
-            ObjectId.fromString(comment.commitId),
-            comment.path,
-            getStartAndEndLines(comment));
-    List<ContextLineInfo> context = candidates.get(key);
-    if (context == null) {
-      context = new ArrayList<>();
-      candidates.put(key, context);
-    }
-    return context;
-  }
+  public Map<Comment, CommentContext> getContext(Iterable<Comment> comments) {
+    ImmutableMap.Builder<Comment, CommentContext> result =
+        ImmutableMap.builderWithExpectedSize(Iterables.size(comments));
 
-  /**
-   * A call to this method loads the context for all comments stored in {@link
-   * CommentContextLoader#candidates}. This is useful so that the repository is opened once for all
-   * comments.
-   */
-  public void fill() {
     // Group comments by commit ID so that each commit is parsed only once
-    Map<ObjectId, List<ContextData>> commentsByCommitId =
-        candidates.keySet().stream().collect(groupingBy(ContextData::commitId));
+    Map<ObjectId, List<Comment>> commentsByCommitId =
+        Streams.stream(comments).collect(groupingBy(Comment::getCommitId));
 
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       for (ObjectId commitId : commentsByCommitId.keySet()) {
         RevCommit commit = rw.parseCommit(commitId);
-        for (ContextData k : commentsByCommitId.get(commitId)) {
-          if (!k.range().isPresent()) {
+        for (Comment comment : commentsByCommitId.get(commitId)) {
+          Optional<Range> range = getStartAndEndLines(comment);
+          if (!range.isPresent()) {
             continue;
           }
-          try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), k.path(), commit.getTree())) {
+          // TODO(ghareeb): We can further group the comments by file paths to avoid opening
+          // the same file multiple times.
+          try (TreeWalk tw =
+              TreeWalk.forPath(rw.getObjectReader(), comment.key.filename, commit.getTree())) {
             if (tw == null) {
               logger.atWarning().log(
                   "Failed to find path %s in the git tree of ID %s.",
-                  k.path(), commit.getTree().getId());
+                  comment.key.filename, commit.getTree().getId());
               continue;
             }
             ObjectId id = tw.getObjectId(0);
             Text src = new Text(repo.open(id, Constants.OBJ_BLOB));
-            List<ContextLineInfo> contextLines = candidates.get(k);
-            Range r = k.range().get();
-            for (int i = r.start(); i <= r.end(); i++) {
-              contextLines.add(new ContextLineInfo(i, src.getString(i - 1)));
+            Range r = range.get();
+            ImmutableMap.Builder<Integer, String> context =
+                ImmutableMap.builderWithExpectedSize(r.end() - r.start());
+            for (int i = r.start(); i < r.end(); i++) {
+              context.put(i, src.getString(i - 1));
             }
+            result.put(comment, CommentContext.create(context.build()));
           }
         }
       }
+      return result.build();
     } catch (IOException e) {
       throw new StorageException("Failed to load the comment context", e);
     }
   }
 
-  private static Optional<Range> getStartAndEndLines(CommentInfo comment) {
+  private static Optional<Range> getStartAndEndLines(Comment comment) {
     if (comment.range != null) {
-      return Optional.of(Range.create(comment.range.startLine, comment.range.endLine));
-    } else if (comment.line != null) {
-      return Optional.of(Range.create(comment.line, comment.line));
+      return Optional.of(Range.create(comment.range.startLine, comment.range.endLine + 1));
+    } else if (comment.lineNbr > 0) {
+      return Optional.of(Range.create(comment.lineNbr, comment.lineNbr + 1));
     }
     return Optional.empty();
   }
@@ -142,23 +129,10 @@
       return new AutoValue_CommentContextLoader_Range(start, end);
     }
 
+    /** Start line of the comment (inclusive). */
     abstract int start();
 
+    /** End line of the comment (exclusive). */
     abstract int end();
   }
-
-  @AutoValue
-  abstract static class ContextData {
-    static ContextData create(String id, ObjectId commitId, String path, Optional<Range> range) {
-      return new AutoValue_CommentContextLoader_ContextData(id, commitId, path, range);
-    }
-
-    abstract String id();
-
-    abstract ObjectId commitId();
-
-    abstract String path();
-
-    abstract Optional<Range> range();
-  }
 }
diff --git a/java/com/google/gerrit/server/CurrentUser.java b/java/com/google/gerrit/server/CurrentUser.java
index 1955340..825b34f 100644
--- a/java/com/google/gerrit/server/CurrentUser.java
+++ b/java/com/google/gerrit/server/CurrentUser.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server;
 
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -31,17 +30,19 @@
  * @see IdentifiedUser
  */
 public abstract class CurrentUser {
-  /** Unique key for plugin/extension specific data on a CurrentUser. */
-  public static final class PropertyKey<T> {
-    public static <T> PropertyKey<T> create() {
-      return new PropertyKey<>();
-    }
+  public static final PropertyMap.Key<ExternalId.Key> LAST_LOGIN_EXTERNAL_ID_PROPERTY_KEY =
+      PropertyMap.key();
 
-    private PropertyKey() {}
+  private final PropertyMap properties;
+  private AccessPath accessPath = AccessPath.UNKNOWN;
+
+  protected CurrentUser() {
+    this.properties = PropertyMap.EMPTY;
   }
 
-  private AccessPath accessPath = AccessPath.UNKNOWN;
-  private PropertyKey<ExternalId.Key> lastLoginExternalIdPropertyKey = PropertyKey.create();
+  protected CurrentUser(PropertyMap properties) {
+    this.properties = properties;
+  }
 
   /** How this user is accessing the Gerrit Code Review application. */
   public final AccessPath getAccessPath() {
@@ -90,6 +91,12 @@
    */
   public abstract GroupMembership getEffectiveGroups();
 
+  /**
+   * Returns a unique identifier for this user that is intended to be used as a cache key. Returned
+   * object should to implement {@code equals()} and {@code hashCode()} for effective caching.
+   */
+  public abstract Object getCacheKey();
+
   /** Unique name of the user on this server, if one has been assigned. */
   public Optional<String> getUserName() {
     return Optional.empty();
@@ -127,29 +134,18 @@
   }
 
   /**
-   * Lookup a previously stored property.
+   * Lookup a stored property.
    *
-   * @param key unique property key.
-   * @return previously stored value, or {@code Optional#empty()}.
+   * @param key unique property key. This key has to be the same instance that was used to store the
+   *     value when constructing the {@link PropertyMap}
+   * @return stored value, or {@code Optional#empty()}.
    */
-  public <T> Optional<T> get(PropertyKey<T> key) {
-    return Optional.empty();
-  }
-
-  /**
-   * Store a property for later retrieval.
-   *
-   * @param key unique property key.
-   * @param value value to store; or {@code null} to clear the value.
-   */
-  public <T> void put(PropertyKey<T> key, @Nullable T value) {}
-
-  public void setLastLoginExternalIdKey(ExternalId.Key externalIdKey) {
-    put(lastLoginExternalIdPropertyKey, externalIdKey);
+  public <T> Optional<T> get(PropertyMap.Key<T> key) {
+    return properties.get(key);
   }
 
   public Optional<ExternalId.Key> getLastLoginExternalIdKey() {
-    return get(lastLoginExternalIdPropertyKey);
+    return get(LAST_LOGIN_EXTERNAL_ID_PROPERTY_KEY);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/DynamicOptions.java b/java/com/google/gerrit/server/DynamicOptions.java
index 41dc082..1d36ff0 100644
--- a/java/com/google/gerrit/server/DynamicOptions.java
+++ b/java/com/google/gerrit/server/DynamicOptions.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.server.plugins.DelegatingClassLoader;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.inject.Injector;
@@ -29,7 +30,7 @@
 import java.util.WeakHashMap;
 
 /** Helper class to define and parse options from plugins on ssh and RestAPI commands. */
-public class DynamicOptions {
+public class DynamicOptions implements AutoCloseable {
   /**
    * To provide additional options, bind a DynamicBean. For example:
    *
@@ -98,7 +99,9 @@
    *
    * <p>Do this by binding to the name of the command you are going to bind to and providing an
    * Iterable of Module names to instantiate and add to the Injector used to instantiate the
-   * DynamicBean in the other classLoader. For example:
+   * DynamicBean in the other classLoader. This interface supports running LifecycleListeners which
+   * are defined by the Modules being provided. The duration of the lifecycle starts when a ssh or
+   * http request starts and ends when the request completes. For example:
    *
    * <pre>
    *   bind(com.google.gerrit.server.DynamicOptions.DynamicBean.class)
@@ -106,7 +109,7 @@
    *           "com.google.gerrit.plugins.otherplugin.command"))
    *       .to(MyOptionsModulesClassNamesProvider.class);
    *
-   *   static class MyOptionsModulesClassNamesProvider implements DynamicOptions.ClassNameProvider {
+   *   static class MyOptionsModulesClassNamesProvider implements DynamicOptions.ModulesClassNamesProvider {
    *     {@literal @}Override
    *     public String getClassName() {
    *       return "com.googlesource.gerrit.plugins.myplugin.CommandOptions";
@@ -190,6 +193,7 @@
   protected Object bean;
   protected Map<String, DynamicBean> beansByPlugin;
   protected Injector injector;
+  protected LifecycleManager lifecycleManager;
 
   /**
    * Internal: For Gerrit to include options from DynamicBeans, setup a DynamicMap and instantiate
@@ -209,6 +213,7 @@
   public DynamicOptions(Object bean, Injector injector, DynamicMap<DynamicBean> dynamicBeans) {
     this.bean = bean;
     this.injector = injector;
+    lifecycleManager = new LifecycleManager();
     beansByPlugin = new HashMap<>();
     Class<?> beanClass =
         (bean instanceof BeanReceiver)
@@ -221,6 +226,7 @@
         beansByPlugin.put(plugin, getDynamicBean(bean, provider.get()));
       }
     }
+    startLifecycleListeners();
   }
 
   @SuppressWarnings("unchecked")
@@ -255,9 +261,10 @@
             modules.add(modulesInjector.getInstance(mClass));
           }
         }
-        return modulesInjector
-            .createChildInjector(modules)
-            .getInstance((Class<DynamicOptions.DynamicBean>) loader.loadClass(className));
+        Injector childModulesInjector = modulesInjector.createChildInjector(modules);
+        lifecycleManager.add(childModulesInjector);
+        return childModulesInjector.getInstance(
+            (Class<DynamicOptions.DynamicBean>) loader.loadClass(className));
       } catch (ClassNotFoundException e) {
         throw new RuntimeException(e);
       }
@@ -300,6 +307,14 @@
     }
   }
 
+  public void startLifecycleListeners() {
+    lifecycleManager.start();
+  }
+
+  public void stopLifecycleListeners() {
+    lifecycleManager.stop();
+  }
+
   public void onBeanParseStart() {
     for (Map.Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
       DynamicBean instance = e.getValue();
@@ -319,4 +334,9 @@
       }
     }
   }
+
+  @Override
+  public void close() {
+    stopLifecycleListeners();
+  }
 }
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index 14d74d0..75c7cda 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -17,11 +17,13 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.flogger.LazyArgs.lazy;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
@@ -46,8 +48,6 @@
 import java.net.SocketAddress;
 import java.net.URL;
 import java.util.Date;
-import java.util.HashMap;
-import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 import java.util.TimeZone;
@@ -105,12 +105,26 @@
       return create(null, id);
     }
 
+    @VisibleForTesting
+    @UsedAt(UsedAt.Project.GOOGLE)
+    public IdentifiedUser forTest(Account.Id id, PropertyMap properties) {
+      return runAs(null, id, null, properties);
+    }
+
     public IdentifiedUser create(SocketAddress remotePeer, Account.Id id) {
       return runAs(remotePeer, id, null);
     }
 
     public IdentifiedUser runAs(
         SocketAddress remotePeer, Account.Id id, @Nullable CurrentUser caller) {
+      return runAs(remotePeer, id, caller, PropertyMap.EMPTY);
+    }
+
+    private IdentifiedUser runAs(
+        SocketAddress remotePeer,
+        Account.Id id,
+        @Nullable CurrentUser caller,
+        PropertyMap properties) {
       return new IdentifiedUser(
           authConfig,
           realm,
@@ -121,7 +135,8 @@
           enableReverseDnsLookup,
           Providers.of(remotePeer),
           id,
-          caller);
+          caller,
+          properties);
     }
   }
 
@@ -163,20 +178,10 @@
     }
 
     public IdentifiedUser create(Account.Id id) {
-      return new IdentifiedUser(
-          authConfig,
-          realm,
-          anonymousCowardName,
-          canonicalUrl,
-          accountCache,
-          groupBackend,
-          enableReverseDnsLookup,
-          remotePeerProvider,
-          id,
-          null);
+      return create(id, PropertyMap.EMPTY);
     }
 
-    public IdentifiedUser runAs(Account.Id id, CurrentUser caller) {
+    public <T> IdentifiedUser create(Account.Id id, PropertyMap properties) {
       return new IdentifiedUser(
           authConfig,
           realm,
@@ -187,7 +192,23 @@
           enableReverseDnsLookup,
           remotePeerProvider,
           id,
-          caller);
+          null,
+          properties);
+    }
+
+    public IdentifiedUser runAs(Account.Id id, CurrentUser caller, PropertyMap properties) {
+      return new IdentifiedUser(
+          authConfig,
+          realm,
+          anonymousCowardName,
+          canonicalUrl,
+          accountCache,
+          groupBackend,
+          enableReverseDnsLookup,
+          remotePeerProvider,
+          id,
+          caller,
+          properties);
     }
   }
 
@@ -212,7 +233,6 @@
   private boolean loadedAllEmails;
   private Set<String> invalidEmails;
   private GroupMembership effectiveGroups;
-  private Map<PropertyKey<Object>, Object> properties;
 
   private IdentifiedUser(
       AuthConfig authConfig,
@@ -235,7 +255,8 @@
         enableReverseDnsLookup,
         remotePeerProvider,
         state.account().id(),
-        realUser);
+        realUser,
+        PropertyMap.EMPTY);
     this.state = state;
   }
 
@@ -249,7 +270,9 @@
       Boolean enableReverseDnsLookup,
       @Nullable Provider<SocketAddress> remotePeerProvider,
       Account.Id id,
-      @Nullable CurrentUser realUser) {
+      @Nullable CurrentUser realUser,
+      PropertyMap properties) {
+    super(properties);
     this.canonicalUrl = canonicalUrl;
     this.accountCache = accountCache;
     this.groupBackend = groupBackend;
@@ -390,6 +413,11 @@
     return effectiveGroups;
   }
 
+  @Override
+  public Object getCacheKey() {
+    return getAccountId();
+  }
+
   public PersonIdent newRefLogIdent() {
     return newRefLogIdent(new Date(), TimeZone.getDefault());
   }
@@ -458,40 +486,6 @@
     return true;
   }
 
-  @Override
-  public synchronized <T> Optional<T> get(PropertyKey<T> key) {
-    if (properties != null) {
-      @SuppressWarnings("unchecked")
-      T value = (T) properties.get(key);
-      return Optional.ofNullable(value);
-    }
-    return Optional.empty();
-  }
-
-  /**
-   * Store a property for later retrieval.
-   *
-   * @param key unique property key.
-   * @param value value to store; or {@code null} to clear the value.
-   */
-  @Override
-  public synchronized <T> void put(PropertyKey<T> key, @Nullable T value) {
-    if (properties == null) {
-      if (value == null) {
-        return;
-      }
-      properties = new HashMap<>();
-    }
-
-    @SuppressWarnings("unchecked")
-    PropertyKey<Object> k = (PropertyKey<Object>) key;
-    if (value != null) {
-      properties.put(k, value);
-    } else {
-      properties.remove(k);
-    }
-  }
-
   /**
    * Returns a materialized copy of the user with all dependencies.
    *
diff --git a/java/com/google/gerrit/server/InternalUser.java b/java/com/google/gerrit/server/InternalUser.java
index 821a0c6..381819d 100644
--- a/java/com/google/gerrit/server/InternalUser.java
+++ b/java/com/google/gerrit/server/InternalUser.java
@@ -36,6 +36,11 @@
   }
 
   @Override
+  public String getCacheKey() {
+    return "internal";
+  }
+
+  @Override
   public boolean isInternalUser() {
     return true;
   }
diff --git a/java/com/google/gerrit/server/PeerDaemonUser.java b/java/com/google/gerrit/server/PeerDaemonUser.java
index 8a8b67a..b27e05c 100644
--- a/java/com/google/gerrit/server/PeerDaemonUser.java
+++ b/java/com/google/gerrit/server/PeerDaemonUser.java
@@ -40,6 +40,11 @@
     return GroupMembership.EMPTY;
   }
 
+  @Override
+  public Object getCacheKey() {
+    return getRemoteAddress();
+  }
+
   public SocketAddress getRemoteAddress() {
     return peer;
   }
diff --git a/java/com/google/gerrit/server/PropertyMap.java b/java/com/google/gerrit/server/PropertyMap.java
new file mode 100644
index 0000000..da3a2495
--- /dev/null
+++ b/java/com/google/gerrit/server/PropertyMap.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2020 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.ImmutableMap;
+import java.util.Optional;
+
+/**
+ * Immutable map that holds a collection of random objects allowing for a type-safe retrieval.
+ *
+ * <p>Intended to be used in {@link CurrentUser} when the object is constructed during login and
+ * holds per-request state. This functionality allows plugins/extensions to contribute specific data
+ * to {@link CurrentUser} that is unknown to Gerrit core.
+ */
+public class PropertyMap {
+  /** Empty instance to be referenced once per JVM. */
+  public static final PropertyMap EMPTY = builder().build();
+
+  /**
+   * Typed key for {@link PropertyMap}. This class intentionally does not implement {@link
+   * Object#equals(Object)} and {@link Object#hashCode()} so that the same instance has to be used
+   * to retrieve a stored value.
+   *
+   * <p>We require the exact same key instance because {@link PropertyMap} is implemented in a
+   * type-safe fashion by using Java generics to guarantee the return type. The generic type can't
+   * be recovered at runtime, so there is no way to just use the type's full name as key - we'd have
+   * to pass additional arguments. At the same time, this is in-line with how we'd want callers to
+   * use {@link PropertyMap}: Instantiate a static, per-JVM key that is reused when setting and
+   * getting values.
+   */
+  public static class Key<T> {}
+
+  public static <T> Key<T> key() {
+    return new Key<>();
+  }
+
+  public static class Builder {
+    private ImmutableMap.Builder<Object, Object> mutableMap;
+
+    private Builder() {
+      this.mutableMap = ImmutableMap.builder();
+    }
+
+    /** Adds the provided {@code value} to the {@link PropertyMap} that is being built. */
+    public <T> Builder put(Key<T> key, T value) {
+      mutableMap.put(key, value);
+      return this;
+    }
+
+    /** Builds and returns an immutable {@link PropertyMap}. */
+    public PropertyMap build() {
+      return new PropertyMap(mutableMap.build());
+    }
+  }
+
+  private final ImmutableMap<Object, Object> map;
+
+  private PropertyMap(ImmutableMap<Object, Object> map) {
+    this.map = map;
+  }
+
+  /** Returns a new {@link Builder} instance. */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  /** Returns the requested value wrapped as {@link Optional}. */
+  @SuppressWarnings("unchecked")
+  public <T> Optional<T> get(Key<T> key) {
+    return Optional.ofNullable((T) map.get(key));
+  }
+}
diff --git a/java/com/google/gerrit/server/account/ServiceUserClassifier.java b/java/com/google/gerrit/server/account/ServiceUserClassifier.java
index c8314c8..2d2a646 100644
--- a/java/com/google/gerrit/server/account/ServiceUserClassifier.java
+++ b/java/com/google/gerrit/server/account/ServiceUserClassifier.java
@@ -17,6 +17,11 @@
 import com.google.gerrit.entities.Account;
 
 public interface ServiceUserClassifier {
+  /**
+   * Name of the Service Users group used by this class to determine whether an account is a service
+   * user; if an account is a part of this group, that account is considered a service user.
+   */
+  public static final String SERVICE_USERS = "Service Users";
   /** Returns {@code true} if the given user is considered a {@code Service User} user. */
   boolean isServiceUser(Account.Id user);
 
diff --git a/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java b/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
index 255467c..3ee2c54 100644
--- a/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
+++ b/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
@@ -63,7 +63,7 @@
 
   @Override
   public boolean isServiceUser(Account.Id user) {
-    Optional<InternalGroup> maybeGroup = groupCache.get(AccountGroup.nameKey("Service Users"));
+    Optional<InternalGroup> maybeGroup = groupCache.get(AccountGroup.nameKey(SERVICE_USERS));
     if (!maybeGroup.isPresent()) {
       return false;
     }
diff --git a/java/com/google/gerrit/server/cache/PerThreadCache.java b/java/com/google/gerrit/server/cache/PerThreadCache.java
new file mode 100644
index 0000000..b4f79d1
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/PerThreadCache.java
@@ -0,0 +1,146 @@
+// Copyright (C) 2018 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.cache;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.base.Objects;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Maps;
+import com.google.gerrit.common.Nullable;
+import java.util.Map;
+import java.util.function.Supplier;
+
+/**
+ * Caches object instances for a request as {@link ThreadLocal} in the serving thread.
+ *
+ * <p>This class is intended to cache objects that have a high instantiation cost, are specific to
+ * the current request and potentially need to be instantiated multiple times while serving a
+ * request.
+ *
+ * <p>This is different from the key-value storage in {@code CurrentUser}: {@code CurrentUser}
+ * offers a key-value storage by providing thread-safe {@code get} and {@code put} methods. Once the
+ * value is retrieved through {@code get} there is not thread-safety anymore - apart from the
+ * retrieved object guarantees. Depending on the implementation of {@code CurrentUser}, it might be
+ * shared between the request serving thread as well as sub- or background treads.
+ *
+ * <p>In comparison to that, this class guarantees thread safety even on non-thread-safe objects as
+ * its cache is tied to the serving thread only. While allowing to cache non-thread-safe objects, it
+ * has the downside of not sharing any objects with background threads or executors.
+ *
+ * <p>Lastly, this class offers a cache, that requires callers to also provide a {@code Supplier} in
+ * case the object is not present in the cache, while {@code CurrentUser} provides a storage where
+ * just retrieving stored values is a valid operation.
+ *
+ * <p>To prevent OOM errors on requests that would cache a lot of objects, this class enforces an
+ * internal limit after which no new elements are cached. All {@code get} calls are served by
+ * invoking the {@code Supplier} after that.
+ */
+public class PerThreadCache implements AutoCloseable {
+  private static final ThreadLocal<PerThreadCache> CACHE = new ThreadLocal<>();
+  /**
+   * Cache at maximum 25 values per thread. This value was chosen arbitrarily. Some endpoints (like
+   * ListProjects) break the assumption that the data cached in a request is limited. To prevent
+   * this class from accumulating an unbound number of objects, we enforce this limit.
+   */
+  private static final int PER_THREAD_CACHE_SIZE = 25;
+
+  /**
+   * Unique key for key-value mappings stored in PerThreadCache. The key is based on the value's
+   * class and a list of identifiers that in combination uniquely set the object apart form others
+   * of the same class.
+   */
+  public static final class Key<T> {
+    private final Class<T> clazz;
+    private final ImmutableList<Object> identifiers;
+
+    /**
+     * Returns a key based on the value's class and an identifier that uniquely identify the value.
+     * The identifier needs to implement {@code equals()} and {@hashCode()}.
+     */
+    public static <T> Key<T> create(Class<T> clazz, Object identifier) {
+      return new Key<>(clazz, ImmutableList.of(identifier));
+    }
+
+    /**
+     * Returns a key based on the value's class and a set of identifiers that uniquely identify the
+     * value. Identifiers need to implement {@code equals()} and {@hashCode()}.
+     */
+    public static <T> Key<T> create(Class<T> clazz, Object... identifiers) {
+      return new Key<>(clazz, ImmutableList.copyOf(identifiers));
+    }
+
+    private Key(Class<T> clazz, ImmutableList<Object> identifiers) {
+      this.clazz = clazz;
+      this.identifiers = identifiers;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hashCode(clazz, identifiers);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (!(o instanceof Key)) {
+        return false;
+      }
+      Key<?> other = (Key<?>) o;
+      return this.clazz == other.clazz && this.identifiers.equals(other.identifiers);
+    }
+  }
+
+  public static PerThreadCache create() {
+    checkState(CACHE.get() == null, "called create() twice on the same request");
+    PerThreadCache cache = new PerThreadCache();
+    CACHE.set(cache);
+    return cache;
+  }
+
+  @Nullable
+  public static PerThreadCache get() {
+    return CACHE.get();
+  }
+
+  public static <T> T getOrCompute(Key<T> key, Supplier<T> loader) {
+    PerThreadCache cache = get();
+    return cache != null ? cache.get(key, loader) : loader.get();
+  }
+
+  private final Map<Key<?>, Object> cache = Maps.newHashMapWithExpectedSize(PER_THREAD_CACHE_SIZE);
+
+  private PerThreadCache() {}
+
+  /**
+   * Returns an instance of {@code T} that was either loaded from the cache or obtained from the
+   * provided {@link Supplier}.
+   */
+  public <T> T get(Key<T> key, Supplier<T> loader) {
+    @SuppressWarnings("unchecked")
+    T value = (T) cache.get(key);
+    if (value == null) {
+      value = loader.get();
+      if (cache.size() < PER_THREAD_CACHE_SIZE) {
+        cache.put(key, value);
+      }
+    }
+    return value;
+  }
+
+  @Override
+  public void close() {
+    CACHE.remove();
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ChangeAttributeFactory.java b/java/com/google/gerrit/server/change/ChangeAttributeFactory.java
index 95355cf..663d7aa 100644
--- a/java/com/google/gerrit/server/change/ChangeAttributeFactory.java
+++ b/java/com/google/gerrit/server/change/ChangeAttributeFactory.java
@@ -32,6 +32,7 @@
  * href="https://gerrit-review.googlesource.com/Documentation/dev-plugins.html#query_attributes">plugin
  * developer documentation for more details and examples.
  */
+@Deprecated
 public interface ChangeAttributeFactory {
 
   /**
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index a086cb1..6091091 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -546,6 +546,7 @@
               cmd,
               projectState.getProject(),
               change.getDest().branch(),
+              ctx.getRepoView().getConfig(),
               ctx.getRevWalk().getObjectReader(),
               commitId,
               ctx.getIdentifiedUser())) {
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 014955c9..6ab0c61 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -39,6 +39,7 @@
 import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ListMultimap;
@@ -67,6 +68,7 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.extensions.common.ProblemInfo;
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
@@ -156,13 +158,17 @@
     }
 
     public ChangeJson create(Iterable<ListChangesOption> options) {
-      return factory.create(options, Optional.empty());
+      return factory.create(options, Optional.empty(), Optional.empty());
     }
 
     public ChangeJson create(
         Iterable<ListChangesOption> options,
-        PluginDefinedAttributesFactory pluginDefinedAttributesFactory) {
-      return factory.create(options, Optional.of(pluginDefinedAttributesFactory));
+        PluginDefinedAttributesFactory pluginDefinedAttributesFactory,
+        PluginDefinedInfosFactory pluginDefinedInfosFactory) {
+      return factory.create(
+          options,
+          Optional.of(pluginDefinedAttributesFactory),
+          Optional.of(pluginDefinedInfosFactory));
     }
 
     public ChangeJson create(ListChangesOption first, ListChangesOption... rest) {
@@ -173,7 +179,8 @@
   public interface AssistedFactory {
     ChangeJson create(
         Iterable<ListChangesOption> options,
-        Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory);
+        Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory,
+        Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory);
   }
 
   @Singleton
@@ -220,6 +227,7 @@
   private final Metrics metrics;
   private final RevisionJson revisionJson;
   private final Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory;
+  private final Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory;
   private final boolean includeMergeable;
   private final boolean lazyLoad;
 
@@ -236,14 +244,15 @@
       Provider<ConsistencyChecker> checkerProvider,
       ActionJson actionJson,
       ChangeNotes.Factory notesFactory,
-      LabelsJson.Factory labelsJsonFactory,
+      LabelsJson labelsJson,
       RemoveReviewerControl removeReviewerControl,
       TrackingFooters trackingFooters,
       Metrics metrics,
       RevisionJson.Factory revisionJsonFactory,
       @GerritServerConfig Config cfg,
       @Assisted Iterable<ListChangesOption> options,
-      @Assisted Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory) {
+      @Assisted Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory,
+      @Assisted Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory) {
     this.userProvider = user;
     this.changeDataFactory = cdf;
     this.permissionBackend = permissionBackend;
@@ -252,7 +261,7 @@
     this.checkerProvider = checkerProvider;
     this.actionJson = actionJson;
     this.notesFactory = notesFactory;
-    this.labelsJson = labelsJsonFactory.create(options);
+    this.labelsJson = labelsJson;
     this.removeReviewerControl = removeReviewerControl;
     this.trackingFooters = trackingFooters;
     this.metrics = metrics;
@@ -261,6 +270,7 @@
     this.includeMergeable = MergeabilityComputationBehavior.fromConfig(cfg).includeInApi();
     this.lazyLoad = containsAnyOf(this.options, REQUIRE_LAZY_LOAD);
     this.pluginDefinedAttributesFactory = pluginDefinedAttributesFactory;
+    this.pluginDefinedInfosFactory = pluginDefinedInfosFactory;
 
     logger.atFine().log("options = %s", options);
   }
@@ -279,12 +289,12 @@
   }
 
   public ChangeInfo format(ChangeData cd) {
-    return format(cd, Optional.empty(), true);
+    return format(cd, Optional.empty(), true, getPluginInfos(cd));
   }
 
   public ChangeInfo format(RevisionResource rsrc) {
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
-    return format(cd, Optional.of(rsrc.getPatchSet().id()), true);
+    return format(cd, Optional.of(rsrc.getPatchSet().id()), true, getPluginInfos(cd));
   }
 
   public List<List<ChangeInfo>> format(List<QueryResult<ChangeData>> in)
@@ -293,8 +303,10 @@
       accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
       List<List<ChangeInfo>> res = new ArrayList<>(in.size());
       Map<Change.Id, ChangeInfo> cache = Maps.newHashMapWithExpectedSize(in.size());
+      ImmutableListMultimap<Change.Id, PluginDefinedInfo> pluginInfosByChange =
+          getPluginInfos(in.stream().flatMap(e -> e.entities().stream()).collect(toList()));
       for (QueryResult<ChangeData> r : in) {
-        List<ChangeInfo> infos = toChangeInfos(r.entities(), cache);
+        List<ChangeInfo> infos = toChangeInfos(r.entities(), cache, pluginInfosByChange);
         if (!infos.isEmpty() && r.more()) {
           infos.get(infos.size() - 1)._moreChanges = true;
         }
@@ -309,8 +321,9 @@
     accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
     ensureLoaded(in);
     List<ChangeInfo> out = new ArrayList<>(in.size());
+    ImmutableListMultimap<Change.Id, PluginDefinedInfo> pluginInfosByChange = getPluginInfos(in);
     for (ChangeData cd : in) {
-      out.add(format(cd, Optional.empty(), false));
+      out.add(format(cd, Optional.empty(), false, pluginInfosByChange.get(cd.getId())));
     }
     accountLoader.fill();
     return out;
@@ -326,7 +339,8 @@
       }
       return checkOnly(changeDataFactory.create(project, id));
     }
-    return format(changeDataFactory.create(notes), Optional.empty(), true);
+    ChangeData cd = changeDataFactory.create(notes);
+    return format(cd, Optional.empty(), true, getPluginInfos(cd));
   }
 
   private static Collection<SubmitRequirementInfo> requirementsFor(ChangeData cd) {
@@ -358,15 +372,18 @@
   }
 
   private ChangeInfo format(
-      ChangeData cd, Optional<PatchSet.Id> limitToPsId, boolean fillAccountLoader) {
+      ChangeData cd,
+      Optional<PatchSet.Id> limitToPsId,
+      boolean fillAccountLoader,
+      List<PluginDefinedInfo> pluginInfosForChange) {
     try {
       if (fillAccountLoader) {
         accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
-        ChangeInfo res = toChangeInfo(cd, limitToPsId);
+        ChangeInfo res = toChangeInfo(cd, limitToPsId, pluginInfosForChange);
         accountLoader.fill();
         return res;
       }
-      return toChangeInfo(cd, limitToPsId);
+      return toChangeInfo(cd, limitToPsId, pluginInfosForChange);
     } catch (PatchListNotAvailableException
         | GpgException
         | IOException
@@ -404,7 +421,9 @@
   }
 
   private List<ChangeInfo> toChangeInfos(
-      List<ChangeData> changes, Map<Change.Id, ChangeInfo> cache) {
+      List<ChangeData> changes,
+      Map<Change.Id, ChangeInfo> cache,
+      ImmutableListMultimap<Change.Id, PluginDefinedInfo> pluginInfosByChange) {
     try (Timer0.Context ignored = metrics.toChangeInfosLatency.start()) {
       List<ChangeInfo> changeInfos = new ArrayList<>(changes.size());
       for (int i = 0; i < changes.size(); i++) {
@@ -425,7 +444,7 @@
         // Compute and cache if possible
         try {
           ensureLoaded(Collections.singleton(cd));
-          info = format(cd, Optional.empty(), false);
+          info = format(cd, Optional.empty(), false, pluginInfosByChange.get(cd.getId()));
           changeInfos.add(info);
           if (isCacheable) {
             cache.put(Change.id(info._number), info);
@@ -480,14 +499,18 @@
     return info;
   }
 
-  private ChangeInfo toChangeInfo(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
+  private ChangeInfo toChangeInfo(
+      ChangeData cd,
+      Optional<PatchSet.Id> limitToPsId,
+      List<PluginDefinedInfo> pluginInfosForChange)
       throws PatchListNotAvailableException, GpgException, PermissionBackendException, IOException {
     try (Timer0.Context ignored = metrics.toChangeInfoLatency.start()) {
-      return toChangeInfoImpl(cd, limitToPsId);
+      return toChangeInfoImpl(cd, limitToPsId, pluginInfosForChange);
     }
   }
 
-  private ChangeInfo toChangeInfoImpl(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
+  private ChangeInfo toChangeInfoImpl(
+      ChangeData cd, Optional<PatchSet.Id> limitToPsId, List<PluginDefinedInfo> pluginInfos)
       throws PatchListNotAvailableException, GpgException, PermissionBackendException, IOException {
     ChangeInfo out = new ChangeInfo();
     CurrentUser user = userProvider.get();
@@ -579,7 +602,9 @@
                 ? labelsJson.permittedLabels(user.getAccountId(), cd)
                 : ImmutableMap.of();
       }
+    }
 
+    if (has(LABELS) || has(DETAILED_LABELS)) {
       out.reviewers = reviewerMap(cd.reviewers(), cd.reviewersByEmail(), false);
       out.pendingReviewers = reviewerMap(cd.pendingReviewers(), cd.pendingReviewersByEmail(), true);
       out.removableReviewers = removableReviewers(cd, out);
@@ -589,6 +614,15 @@
     if (pluginDefinedAttributesFactory.isPresent()) {
       out.plugins = pluginDefinedAttributesFactory.get().create(cd);
     }
+
+    if (!pluginInfos.isEmpty()) {
+      if (out.plugins == null) {
+        out.plugins = pluginInfos;
+      } else {
+        out.plugins = new ArrayList<>(out.plugins);
+        out.plugins.addAll(pluginInfos);
+      }
+    }
     out.revertOf = cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null;
     out.submissionId = cd.change().getSubmissionId();
     out.cherryPickOfChange =
@@ -819,4 +853,16 @@
     }
     return map;
   }
+
+  private List<PluginDefinedInfo> getPluginInfos(ChangeData cd) {
+    return getPluginInfos(Collections.singleton(cd)).get(cd.getId());
+  }
+
+  private ImmutableListMultimap<Change.Id, PluginDefinedInfo> getPluginInfos(
+      Collection<ChangeData> cds) {
+    if (pluginDefinedInfosFactory.isPresent()) {
+      return pluginDefinedInfosFactory.get().createPluginDefinedInfos(cds);
+    }
+    return ImmutableListMultimap.of();
+  }
 }
diff --git a/java/com/google/gerrit/server/change/ChangeKeyAdapter.java b/java/com/google/gerrit/server/change/ChangeKeyAdapter.java
deleted file mode 100644
index 0db4cea..0000000
--- a/java/com/google/gerrit/server/change/ChangeKeyAdapter.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2019 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.entities.Change;
-import com.google.gson.JsonDeserializationContext;
-import com.google.gson.JsonDeserializer;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import com.google.gson.JsonParseException;
-import com.google.gson.JsonSerializationContext;
-import com.google.gson.JsonSerializer;
-import java.lang.reflect.Type;
-
-/**
- * Adapter that serializes {@link com.google.gerrit.entities.Change.Key}'s {@code key} field as
- * {@code id}, for backwards compatibility in stream-events.
- */
-// TODO(dborowitz): auto-value-gson should support this directly using @SerializedName on the
-// AutoValue method.
-public class ChangeKeyAdapter implements JsonSerializer<Change.Key>, JsonDeserializer<Change.Key> {
-  @Override
-  public JsonElement serialize(Change.Key src, Type typeOfSrc, JsonSerializationContext context) {
-    JsonObject obj = new JsonObject();
-    obj.addProperty("id", src.get());
-    return obj;
-  }
-
-  @Override
-  public Change.Key deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
-      throws JsonParseException {
-    JsonElement keyJson = json.getAsJsonObject().get("id");
-    if (keyJson == null || !keyJson.isJsonPrimitive() || !keyJson.getAsJsonPrimitive().isString()) {
-      throw new JsonParseException("Key is not a string: " + keyJson);
-    }
-    String key = keyJson.getAsJsonPrimitive().getAsString();
-    return Change.key(key);
-  }
-}
diff --git a/java/com/google/gerrit/server/change/ChangePluginDefinedInfoFactory.java b/java/com/google/gerrit/server/change/ChangePluginDefinedInfoFactory.java
new file mode 100644
index 0000000..c6ceb61
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ChangePluginDefinedInfoFactory.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2020 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.entities.Change;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.server.DynamicOptions.BeanProvider;
+import com.google.gerrit.server.query.change.ChangeData;
+import java.util.Collection;
+import java.util.Map;
+
+/**
+ * Interface for plugins to provide additional fields in {@link
+ * com.google.gerrit.extensions.common.ChangeInfo ChangeInfo}.
+ *
+ * <p>Register a {@code ChangePluginDefinedInfoFactory} in a plugin {@code Module} like this:
+ *
+ * <pre>
+ * DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class).to(YourClass.class);
+ * </pre>
+ *
+ * <p>See the <a
+ * href="https://gerrit-review.googlesource.com/Documentation/dev-plugins.html#query_attributes">
+ * plugin developer documentation for more details and examples.
+ */
+public interface ChangePluginDefinedInfoFactory {
+
+  /**
+   * Create a plugin-provided info field for each of the provided {@link ChangeData}s.
+   *
+   * <p>Typically, implementations will subclass {@code PluginDefinedInfo} to add additional fields.
+   *
+   * @param cds changes.
+   * @param beanProvider provider of {@code DynamicBean}s, which may be used for reading options.
+   * @param plugin plugin name.
+   * @return map of the plugin's special info for each change
+   */
+  Map<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+      Collection<ChangeData> cds, BeanProvider beanProvider, String plugin);
+}
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java
index b1d154c..76992e8 100644
--- a/java/com/google/gerrit/server/change/LabelsJson.java
+++ b/java/com/google/gerrit/server/change/LabelsJson.java
@@ -42,17 +42,15 @@
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.VotingRangeInfo;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 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.query.change.ChangeData;
 import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
+import com.google.inject.Singleton;
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -68,28 +66,15 @@
 /**
  * Produces label-related entities, like {@link LabelInfo}s, which is serialized to JSON afterwards.
  */
+@Singleton
 public class LabelsJson {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public interface Factory {
-    LabelsJson create(Iterable<ListChangesOption> options);
-  }
-
-  private final ApprovalsUtil approvalsUtil;
-  private final ChangeNotes.Factory notesFactory;
   private final PermissionBackend permissionBackend;
-  private final boolean lazyLoad;
 
   @Inject
-  LabelsJson(
-      ApprovalsUtil approvalsUtil,
-      ChangeNotes.Factory notesFactory,
-      PermissionBackend permissionBackend,
-      @Assisted Iterable<ListChangesOption> options) {
-    this.approvalsUtil = approvalsUtil;
-    this.notesFactory = notesFactory;
+  LabelsJson(PermissionBackend permissionBackend) {
     this.permissionBackend = permissionBackend;
-    this.lazyLoad = containsAnyOf(Sets.immutableEnumSet(options), ChangeJson.REQUIRE_LAZY_LOAD);
   }
 
   /**
@@ -253,14 +238,10 @@
 
   private Map<String, Short> currentLabels(Account.Id accountId, ChangeData cd) {
     Map<String, Short> result = new HashMap<>();
-    for (PatchSetApproval psa :
-        approvalsUtil.byPatchSetUser(
-            lazyLoad ? cd.notes() : notesFactory.createFromIndexedChange(cd.change()),
-            cd.change().currentPatchSetId(),
-            accountId,
-            null,
-            null)) {
-      result.put(psa.label(), psa.value());
+    for (PatchSetApproval psa : cd.currentApprovals()) {
+      if (psa.accountId().equals(accountId)) {
+        result.put(psa.label(), psa.value());
+      }
     }
     return result;
   }
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index 882352d..ef06ea1 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -342,6 +342,7 @@
                 .orElseThrow(illegalState(origNotes.getProjectName()))
                 .getProject(),
             origNotes.getChange().getDest().branch(),
+            ctx.getRepoView().getConfig(),
             ctx.getRevWalk().getObjectReader(),
             commitId,
             ctx.getIdentifiedUser())) {
diff --git a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java b/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
index 9928125..b474dab 100644
--- a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
+++ b/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
@@ -18,12 +18,15 @@
 import static java.util.concurrent.TimeUnit.MINUTES;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.server.DynamicOptions.BeanProvider;
 import com.google.gerrit.server.query.change.ChangeData;
+import java.util.Collection;
 import java.util.Objects;
 import java.util.stream.Stream;
 
@@ -60,5 +63,44 @@
     return pdi;
   }
 
+  public static ImmutableListMultimap<Change.Id, PluginDefinedInfo> createAll(
+      Collection<ChangeData> cds,
+      BeanProvider beanProvider,
+      Stream<Extension<ChangePluginDefinedInfoFactory>> infoFactories) {
+    ImmutableListMultimap.Builder<Change.Id, PluginDefinedInfo> pluginInfosByChangeBuilder =
+        ImmutableListMultimap.builder();
+    infoFactories.forEach(
+        e -> tryCreate(cds, beanProvider, e.getPluginName(), e.get(), pluginInfosByChangeBuilder));
+    ImmutableListMultimap<Change.Id, PluginDefinedInfo> result = pluginInfosByChangeBuilder.build();
+    return result;
+  }
+
+  private static void tryCreate(
+      Collection<ChangeData> cds,
+      BeanProvider beanProvider,
+      String plugin,
+      ChangePluginDefinedInfoFactory infoFactory,
+      ImmutableListMultimap.Builder<Change.Id, PluginDefinedInfo> pluginInfosByChangeBuilder) {
+    try {
+      infoFactory
+          .createPluginDefinedInfos(cds, beanProvider, plugin)
+          .forEach(
+              (id, pdi) -> {
+                if (pdi != null) {
+                  pdi.name = plugin;
+                  pluginInfosByChangeBuilder.put(id, pdi);
+                }
+              });
+    } catch (RuntimeException ex) {
+      /* Propagate runtime exceptions as structured API data types so that queries don't fail. */
+      logger.atWarning().atMostEvery(1, MINUTES).withCause(ex).log(
+          "error populating attribute on changes from plugin %s", plugin);
+      PluginDefinedInfo errorInfo = new PluginDefinedInfo();
+      errorInfo.name = plugin;
+      errorInfo.message = "Something went wrong in plugin: " + plugin;
+      cds.forEach(cd -> pluginInfosByChangeBuilder.put(cd.getId(), errorInfo));
+    }
+  }
+
   private PluginDefinedAttributesFactories() {}
 }
diff --git a/java/com/google/gerrit/server/change/PluginDefinedInfosFactory.java b/java/com/google/gerrit/server/change/PluginDefinedInfosFactory.java
new file mode 100644
index 0000000..db57e29
--- /dev/null
+++ b/java/com/google/gerrit/server/change/PluginDefinedInfosFactory.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2020 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.ImmutableListMultimap;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.server.query.change.ChangeData;
+import java.util.Collection;
+
+/**
+ * Interface to generate {@code PluginDefinedInfo}s from registered {@code
+ * ChangePluginDefinedInfoFactory}s.
+ *
+ * <p>See the <a
+ * href="https://gerrit-review.googlesource.com/Documentation/dev-plugins.html#query_attributes">
+ * plugin developer documentation for more details and examples.
+ */
+public interface PluginDefinedInfosFactory {
+
+  /**
+   * Create a plugin-provided info field from all the plugins for each of the provided {@link
+   * ChangeData}s.
+   *
+   * @param cds changes.
+   * @return map of the all plugin's special infos for each change.
+   */
+  ImmutableListMultimap<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+      Collection<ChangeData> cds);
+}
diff --git a/java/com/google/gerrit/server/change/ReviewerAdder.java b/java/com/google/gerrit/server/change/ReviewerAdder.java
index 3d986d2..5d55b4d 100644
--- a/java/com/google/gerrit/server/change/ReviewerAdder.java
+++ b/java/com/google/gerrit/server/change/ReviewerAdder.java
@@ -19,7 +19,6 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
-import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
@@ -140,12 +139,12 @@
     }
 
     logger.atFine().log(
-        "Adding account %d from author/committer identity of commit %s as reviewer to change %d",
+        "Adding account %d from author/committer identity of commit %s as cc to change %d",
         accountId.get(), commitId.name(), change.getChangeId());
 
     InternalAddReviewerInput in = new InternalAddReviewerInput();
     in.reviewer = accountId.toString();
-    in.state = REVIEWER;
+    in.state = CC;
     in.notify = notify;
     in.otherFailureBehavior = FailureBehavior.IGNORE;
     return Optional.of(in);
diff --git a/java/com/google/gerrit/server/restapi/change/SetTopicOp.java b/java/com/google/gerrit/server/change/SetTopicOp.java
similarity index 88%
rename from java/com/google/gerrit/server/restapi/change/SetTopicOp.java
rename to java/com/google/gerrit/server/change/SetTopicOp.java
index 9eff5c1..c4a49b0 100644
--- a/java/com/google/gerrit/server/restapi/change/SetTopicOp.java
+++ b/java/com/google/gerrit/server/change/SetTopicOp.java
@@ -12,12 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.restapi.change;
+package com.google.gerrit.server.change;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.extensions.api.changes.TopicInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.extensions.events.TopicEdited;
@@ -31,10 +31,10 @@
 
 public class SetTopicOp implements BatchUpdateOp {
   public interface Factory {
-    SetTopicOp create(TopicInput input);
+    SetTopicOp create(@Nullable String topic);
   }
 
-  private final TopicInput input;
+  private final String topic;
   private final TopicEdited topicEdited;
   private final ChangeMessagesUtil cmUtil;
 
@@ -44,8 +44,8 @@
 
   @Inject
   public SetTopicOp(
-      TopicEdited topicEdited, ChangeMessagesUtil cmUtil, @Assisted TopicInput input) {
-    this.input = input;
+      TopicEdited topicEdited, ChangeMessagesUtil cmUtil, @Nullable @Assisted String topic) {
+    this.topic = topic;
     this.topicEdited = topicEdited;
     this.cmUtil = cmUtil;
   }
@@ -54,7 +54,7 @@
   public boolean updateChange(ChangeContext ctx) throws BadRequestException {
     change = ctx.getChange();
     ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
-    newTopicName = Strings.nullToEmpty(input.topic);
+    newTopicName = Strings.nullToEmpty(topic);
     oldTopicName = Strings.nullToEmpty(change.getTopic());
     if (oldTopicName.equals(newTopicName)) {
       return false;
diff --git a/java/com/google/gerrit/server/comment/CommentContextCache.java b/java/com/google/gerrit/server/comment/CommentContextCache.java
new file mode 100644
index 0000000..8c40763
--- /dev/null
+++ b/java/com/google/gerrit/server/comment/CommentContextCache.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2020 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.comment;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.CommentContext;
+
+/**
+ * Caches the context lines of comments (source file content surrounding and including the lines
+ * where the comment was written)
+ */
+public interface CommentContextCache {
+
+  /**
+   * Returns the context lines for a single comment.
+   *
+   * @param key a key representing a subset of fields for a comment that serves as an identifier.
+   * @return a {@link CommentContext} object containing all line numbers and text of the context.
+   */
+  CommentContext get(CommentContextKey key);
+
+  /**
+   * Returns the context lines for multiple comments - identified by their {@code keys}.
+   *
+   * @param keys list of keys, where each key represents a single comment through its project,
+   *     change ID, patchset, path and ID. The keys can belong to different projects and changes.
+   * @return {@code Map} of {@code CommentContext} containing the context for all comments.
+   */
+  ImmutableMap<CommentContextKey, CommentContext> getAll(Iterable<CommentContextKey> keys);
+}
diff --git a/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
new file mode 100644
index 0000000..c4e29d8
--- /dev/null
+++ b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
@@ -0,0 +1,256 @@
+// Copyright (C) 2020 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.comment;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.cache.Weigher;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
+import com.google.common.hash.Hashing;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.CommentContext;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.CommentContextLoader;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.proto.Cache.AllCommentContextProto;
+import com.google.gerrit.server.cache.proto.Cache.AllCommentContextProto.CommentContextProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.name.Named;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/** Implementation of {@link CommentContextCache}. */
+public class CommentContextCacheImpl implements CommentContextCache {
+  private static final String CACHE_NAME = "comment_context";
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        persist(CACHE_NAME, CommentContextKey.class, CommentContext.class)
+            .version(1)
+            .diskLimit(1 << 30) // limit the total cache size to 1 GB
+            .maximumWeight(1 << 23) // Limit the size of the in-memory cache to 8 MB
+            .weigher(CommentContextWeigher.class)
+            .keySerializer(CommentContextKey.Serializer.INSTANCE)
+            .valueSerializer(CommentContextSerializer.INSTANCE)
+            .loader(Loader.class);
+
+        bind(CommentContextCache.class).to(CommentContextCacheImpl.class);
+      }
+    };
+  }
+
+  private final LoadingCache<CommentContextKey, CommentContext> contextCache;
+
+  @Inject
+  CommentContextCacheImpl(
+      @Named(CACHE_NAME) LoadingCache<CommentContextKey, CommentContext> contextCache) {
+    this.contextCache = contextCache;
+  }
+
+  @Override
+  public CommentContext get(CommentContextKey comment) {
+    return getAll(ImmutableList.of(comment)).get(comment);
+  }
+
+  @Override
+  public ImmutableMap<CommentContextKey, CommentContext> getAll(
+      Iterable<CommentContextKey> inputKeys) {
+    ImmutableMap.Builder<CommentContextKey, CommentContext> result = ImmutableMap.builder();
+
+    // Convert the input keys to the same keys but with their file paths hashed
+    Map<CommentContextKey, CommentContextKey> keysToCacheKeys =
+        Streams.stream(inputKeys)
+            .collect(
+                Collectors.toMap(
+                    Function.identity(),
+                    k -> k.toBuilder().path(Loader.hashPath(k.path())).build()));
+
+    try {
+      ImmutableMap<CommentContextKey, CommentContext> allContext =
+          contextCache.getAll(keysToCacheKeys.values());
+
+      for (CommentContextKey inputKey : inputKeys) {
+        CommentContextKey cacheKey = keysToCacheKeys.get(inputKey);
+        result.put(inputKey, allContext.get(cacheKey));
+      }
+      return result.build();
+    } catch (ExecutionException e) {
+      throw new StorageException("Failed to retrieve comments' context", e);
+    }
+  }
+
+  public enum CommentContextSerializer implements CacheSerializer<CommentContext> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(CommentContext commentContext) {
+      AllCommentContextProto.Builder allBuilder = AllCommentContextProto.newBuilder();
+
+      commentContext
+          .lines()
+          .entrySet()
+          .forEach(
+              c ->
+                  allBuilder.addContext(
+                      CommentContextProto.newBuilder()
+                          .setLineNumber(c.getKey())
+                          .setContextLine(c.getValue())));
+      return Protos.toByteArray(allBuilder.build());
+    }
+
+    @Override
+    public CommentContext deserialize(byte[] in) {
+      ImmutableMap.Builder<Integer, String> contextLinesMap = ImmutableMap.builder();
+      Protos.parseUnchecked(AllCommentContextProto.parser(), in).getContextList().stream()
+          .forEach(c -> contextLinesMap.put(c.getLineNumber(), c.getContextLine()));
+      return CommentContext.create(contextLinesMap.build());
+    }
+  }
+
+  static class Loader extends CacheLoader<CommentContextKey, CommentContext> {
+    private final ChangeNotes.Factory notesFactory;
+    private final CommentsUtil commentsUtil;
+    private final CommentContextLoader.Factory factory;
+
+    @Inject
+    Loader(
+        CommentsUtil commentsUtil,
+        ChangeNotes.Factory notesFactory,
+        CommentContextLoader.Factory factory) {
+      this.commentsUtil = commentsUtil;
+      this.notesFactory = notesFactory;
+      this.factory = factory;
+    }
+
+    @Override
+    public CommentContext load(CommentContextKey key) {
+      return loadAll(ImmutableList.of(key)).get(key);
+    }
+
+    @Override
+    public Map<CommentContextKey, CommentContext> loadAll(
+        Iterable<? extends CommentContextKey> keys) {
+      ImmutableMap.Builder<CommentContextKey, CommentContext> result =
+          ImmutableMap.builderWithExpectedSize(Iterables.size(keys));
+
+      Map<Project.NameKey, Map<Change.Id, List<CommentContextKey>>> groupedKeys =
+          Streams.stream(keys)
+              .distinct()
+              .map(k -> (CommentContextKey) k)
+              .collect(
+                  Collectors.groupingBy(
+                      CommentContextKey::project,
+                      Collectors.groupingBy(CommentContextKey::changeId)));
+
+      for (Map.Entry<Project.NameKey, Map<Change.Id, List<CommentContextKey>>> perProject :
+          groupedKeys.entrySet()) {
+        Map<Change.Id, List<CommentContextKey>> keysPerProject = perProject.getValue();
+
+        for (Map.Entry<Change.Id, List<CommentContextKey>> perChange : keysPerProject.entrySet()) {
+          Map<CommentContextKey, CommentContext> context =
+              loadForSameChange(perChange.getValue(), perProject.getKey(), perChange.getKey());
+          result.putAll(context);
+        }
+      }
+      return result.build();
+    }
+
+    /**
+     * Load the comment context for comments of the same project and change ID.
+     *
+     * @param keys a list of keys corresponding to some comments
+     * @param project a gerrit project/repository
+     * @param changeId an identifier for a change
+     * @return a map of the input keys to their corresponding {@link CommentContext}
+     */
+    private Map<CommentContextKey, CommentContext> loadForSameChange(
+        List<CommentContextKey> keys, Project.NameKey project, Change.Id changeId) {
+      ChangeNotes notes = notesFactory.createChecked(project, changeId);
+      List<HumanComment> humanComments = commentsUtil.publishedHumanCommentsByChange(notes);
+      CommentContextLoader loader = factory.create(project);
+      Map<Comment, CommentContextKey> commentsToKeys = new HashMap<>();
+      for (CommentContextKey key : keys) {
+        commentsToKeys.put(getCommentForKey(humanComments, key), key);
+      }
+      Map<Comment, CommentContext> allContext = loader.getContext(commentsToKeys.keySet());
+      return allContext.entrySet().stream()
+          .collect(Collectors.toMap(e -> commentsToKeys.get(e.getKey()), Map.Entry::getValue));
+    }
+
+    /**
+     * Return the single comment from the {@code allComments} input list corresponding to the key
+     * parameter.
+     *
+     * @param allComments a list of comments.
+     * @param key a key representing a single comment.
+     * @return the single comment corresponding to the key parameter.
+     */
+    private Comment getCommentForKey(List<HumanComment> allComments, CommentContextKey key) {
+      return allComments.stream()
+          .filter(
+              c ->
+                  key.id().equals(c.key.uuid)
+                      && key.patchset() == c.key.patchSetId
+                      && key.path().equals(hashPath(c.key.filename)))
+          .findFirst()
+          .orElseThrow(() -> new IllegalArgumentException("Unable to find comment for key " + key));
+    }
+
+    /**
+     * Hash an input String using the general {@link Hashing#murmur3_128()} hash.
+     *
+     * @param input the input String
+     * @return a hashed representation of the input String
+     */
+    static String hashPath(String input) {
+      return Hashing.murmur3_128().hashString(input, UTF_8).toString();
+    }
+  }
+
+  private static class CommentContextWeigher implements Weigher<CommentContextKey, CommentContext> {
+    @Override
+    public int weigh(CommentContextKey key, CommentContext commentContext) {
+      int size = 0;
+      size += key.id().length();
+      size += key.path().length();
+      size += key.project().get().length();
+      size += 4;
+      for (String line : commentContext.lines().values()) {
+        size += 4; // line number
+        size += line.length(); // number of characters in the context line
+      }
+      return size;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/comment/CommentContextKey.java b/java/com/google/gerrit/server/comment/CommentContextKey.java
new file mode 100644
index 0000000..e4a927a
--- /dev/null
+++ b/java/com/google/gerrit/server/comment/CommentContextKey.java
@@ -0,0 +1,82 @@
+package com.google.gerrit.server.comment;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import java.util.Collection;
+
+/**
+ * An identifier of a comment that should be used to load the comment context using {@link
+ * CommentContextCache#get(CommentContextKey)}, or {@link CommentContextCache#getAll(Collection)}.
+ *
+ * <p>The {@link CommentContextCacheImpl} implementation uses this class as the cache key, while
+ * replacing the {@link #path()} field with the hashed path.
+ */
+@AutoValue
+public abstract class CommentContextKey {
+  abstract Project.NameKey project();
+
+  abstract Change.Id changeId();
+
+  /** The unique comment ID. */
+  abstract String id();
+
+  /** File path at which the comment was written. */
+  abstract String path();
+
+  abstract Integer patchset();
+
+  abstract Builder toBuilder();
+
+  public static Builder builder() {
+    return new AutoValue_CommentContextKey.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder project(Project.NameKey nameKey);
+
+    public abstract Builder changeId(Change.Id changeId);
+
+    public abstract Builder id(String id);
+
+    public abstract Builder path(String path);
+
+    public abstract Builder patchset(Integer patchset);
+
+    public abstract CommentContextKey build();
+  }
+
+  public enum Serializer implements CacheSerializer<CommentContextKey> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(CommentContextKey key) {
+      return Protos.toByteArray(
+          Cache.CommentContextKeyProto.newBuilder()
+              .setProject(key.project().get())
+              .setChangeId(key.changeId().toString())
+              .setPatchset(key.patchset())
+              .setPathHash(key.path())
+              .setCommentId(key.id())
+              .build());
+    }
+
+    @Override
+    public CommentContextKey deserialize(byte[] in) {
+      Cache.CommentContextKeyProto proto =
+          Protos.parseUnchecked(Cache.CommentContextKeyProto.parser(), in);
+      return CommentContextKey.builder()
+          .project(Project.NameKey.parse(proto.getProject()))
+          .changeId(Change.Id.tryParse(proto.getChangeId()).get())
+          .patchset(proto.getPatchset())
+          .id(proto.getCommentId())
+          .path(proto.getPathHash())
+          .build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 22d02d2..6a25afd 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -110,10 +110,11 @@
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
-import com.google.gerrit.server.change.LabelsJson;
+import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
 import com.google.gerrit.server.change.ReviewerSuggestion;
 import com.google.gerrit.server.change.RevisionJson;
+import com.google.gerrit.server.comment.CommentContextCacheImpl;
 import com.google.gerrit.server.events.EventFactory;
 import com.google.gerrit.server.events.EventListener;
 import com.google.gerrit.server.events.EventsMetrics;
@@ -250,6 +251,7 @@
     install(TagCache.module());
     install(OAuthTokenCache.module());
     install(PureRevertCache.module());
+    install(CommentContextCacheImpl.module());
 
     install(new AccessControlModule());
     install(new CmdLineParserModule());
@@ -270,7 +272,6 @@
     factory(ChangeData.AssistedFactory.class);
     factory(ChangeJson.AssistedFactory.class);
     factory(ChangeIsVisibleToPredicate.Factory.class);
-    factory(LabelsJson.Factory.class);
     factory(MergeUtil.Factory.class);
     factory(PatchScriptFactory.Factory.class);
     factory(PatchScriptFactoryForAutoFix.Factory.class);
@@ -437,6 +438,7 @@
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeHasOperandFactory.class);
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeIsOperandFactory.class);
     DynamicSet.setOf(binder(), ChangeAttributeFactory.class);
+    DynamicSet.setOf(binder(), ChangePluginDefinedInfoFactory.class);
 
     install(new GitwebConfig.LegacyModule(cfg));
 
diff --git a/java/com/google/gerrit/server/events/CommitReceivedEvent.java b/java/com/google/gerrit/server/events/CommitReceivedEvent.java
index 6e43621..eb4d9ee 100644
--- a/java/com/google/gerrit/server/events/CommitReceivedEvent.java
+++ b/java/com/google/gerrit/server/events/CommitReceivedEvent.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import java.io.IOException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -28,6 +29,7 @@
   public ReceiveCommand command;
   public Project project;
   public String refName;
+  public Config repoConfig;
   public RevWalk revWalk;
   public RevCommit commit;
   public IdentifiedUser user;
@@ -40,6 +42,7 @@
       ReceiveCommand command,
       Project project,
       String refName,
+      Config repoConfig,
       ObjectReader reader,
       ObjectId commitId,
       IdentifiedUser user)
@@ -48,6 +51,7 @@
     this.command = command;
     this.project = project;
     this.refName = refName;
+    this.repoConfig = repoConfig;
     this.revWalk = new RevWalk(reader);
     this.commit = revWalk.parseCommit(commitId);
     this.user = user;
diff --git a/java/com/google/gerrit/server/events/EventGsonProvider.java b/java/com/google/gerrit/server/events/EventGsonProvider.java
index 688507b..72cf7be3 100644
--- a/java/com/google/gerrit/server/events/EventGsonProvider.java
+++ b/java/com/google/gerrit/server/events/EventGsonProvider.java
@@ -15,9 +15,8 @@
 package com.google.gerrit.server.events;
 
 import com.google.common.base.Supplier;
-import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.EntitiesAdapterFactory;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.server.change.ChangeKeyAdapter;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
 import com.google.inject.Provider;
@@ -29,8 +28,8 @@
         .registerTypeAdapter(Event.class, new EventDeserializer())
         .registerTypeAdapter(Supplier.class, new SupplierSerializer())
         .registerTypeAdapter(Supplier.class, new SupplierDeserializer())
-        .registerTypeAdapter(Change.Key.class, new ChangeKeyAdapter())
         .registerTypeAdapter(Project.NameKey.class, new ProjectNameKeyAdapter())
+        .registerTypeAdapterFactory(EntitiesAdapterFactory.create())
         .create();
   }
 }
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index 1f90187..439f53e 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -140,7 +140,8 @@
 
   private ChangeNotes getNotes(ChangeInfo info) {
     try {
-      return changeNotesFactory.createChecked(Change.id(info._number));
+      return changeNotesFactory.createChecked(
+          Project.nameKey(info.project), Change.id(info._number));
     } catch (NoSuchChangeException e) {
       throw new StorageException(e);
     }
diff --git a/java/com/google/gerrit/server/git/DelegateRepository.java b/java/com/google/gerrit/server/git/DelegateRepository.java
index 9c860c4..2816429 100644
--- a/java/com/google/gerrit/server/git/DelegateRepository.java
+++ b/java/com/google/gerrit/server/git/DelegateRepository.java
@@ -15,14 +15,40 @@
 package com.google.gerrit.server.git;
 
 import com.google.gerrit.common.UsedAt;
+import java.io.File;
 import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
 import org.eclipse.jgit.attributes.AttributesNodeProvider;
+import org.eclipse.jgit.dircache.DirCache;
+import org.eclipse.jgit.errors.AmbiguousObjectException;
+import org.eclipse.jgit.errors.CorruptObjectException;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.errors.NoWorkTreeException;
+import org.eclipse.jgit.errors.RevisionSyntaxException;
+import org.eclipse.jgit.events.ListenerList;
+import org.eclipse.jgit.events.RepositoryEvent;
+import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.BaseRepositoryBuilder;
 import org.eclipse.jgit.lib.ObjectDatabase;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.RebaseTodoLine;
+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.RepositoryState;
 import org.eclipse.jgit.lib.StoredConfig;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.util.FS;
 
 /** Wrapper around {@link Repository} that delegates all calls to the wrapped {@link Repository}. */
 @UsedAt(UsedAt.Project.PLUGIN_HIGH_AVAILABILITY)
@@ -90,4 +116,279 @@
 
     return new BaseRepositoryBuilder<>().setFS(repo.getFS()).setGitDir(repo.getDirectory());
   }
+
+  @Override
+  public ListenerList getListenerList() {
+    return delegate.getListenerList();
+  }
+
+  @Override
+  public void fireEvent(RepositoryEvent<?> event) {
+    delegate.fireEvent(event);
+  }
+
+  @Override
+  public void create() throws IOException {
+    delegate.create();
+  }
+
+  @Override
+  public File getDirectory() {
+    return delegate.getDirectory();
+  }
+
+  @Override
+  public ObjectInserter newObjectInserter() {
+    return delegate.newObjectInserter();
+  }
+
+  @Override
+  public ObjectReader newObjectReader() {
+    return delegate.newObjectReader();
+  }
+
+  @Override
+  public FS getFS() {
+    return delegate.getFS();
+  }
+
+  @Override
+  @Deprecated
+  public boolean hasObject(AnyObjectId objectId) {
+    return delegate.hasObject(objectId);
+  }
+
+  @Override
+  public ObjectLoader open(AnyObjectId objectId, int typeHint)
+      throws MissingObjectException, IncorrectObjectTypeException, IOException {
+    return delegate.open(objectId, typeHint);
+  }
+
+  @Override
+  public void incrementOpen() {
+    delegate.incrementOpen();
+  }
+
+  @Override
+  public void close() {
+    delegate.close();
+  }
+
+  @Override
+  public String getFullBranch() throws IOException {
+    return delegate.getFullBranch();
+  }
+
+  @Override
+  public String getBranch() throws IOException {
+    return delegate.getBranch();
+  }
+
+  @Override
+  @Deprecated
+  public Map<String, Ref> getAllRefs() {
+    return delegate.getAllRefs();
+  }
+
+  @Override
+  @Deprecated
+  public Map<String, Ref> getTags() {
+    return delegate.getTags();
+  }
+
+  @Override
+  public DirCache lockDirCache() throws NoWorkTreeException, CorruptObjectException, IOException {
+    return delegate.lockDirCache();
+  }
+
+  @Override
+  public void autoGC(ProgressMonitor monitor) {
+    delegate.autoGC(monitor);
+  }
+
+  @Override
+  public Set<ObjectId> getAdditionalHaves() {
+    return delegate.getAdditionalHaves();
+  }
+
+  @Override
+  public Map<AnyObjectId, Set<Ref>> getAllRefsByPeeledObjectId() {
+    return delegate.getAllRefsByPeeledObjectId();
+  }
+
+  @Override
+  public File getIndexFile() throws NoWorkTreeException {
+    return delegate.getIndexFile();
+  }
+
+  @Override
+  public RepositoryState getRepositoryState() {
+    return delegate.getRepositoryState();
+  }
+
+  @Override
+  public boolean isBare() {
+    return delegate.isBare();
+  }
+
+  @Override
+  public File getWorkTree() throws NoWorkTreeException {
+    return delegate.getWorkTree();
+  }
+
+  @Override
+  public String getRemoteName(String refName) {
+    return delegate.getRemoteName(refName);
+  }
+
+  @Override
+  public String getGitwebDescription() throws IOException {
+    return delegate.getGitwebDescription();
+  }
+
+  @Override
+  public Set<String> getRemoteNames() {
+    return delegate.getRemoteNames();
+  }
+
+  @Override
+  public ObjectLoader open(AnyObjectId objectId) throws MissingObjectException, IOException {
+    return delegate.open(objectId);
+  }
+
+  @Override
+  public RefUpdate updateRef(String ref) throws IOException {
+    return delegate.updateRef(ref);
+  }
+
+  @Override
+  public RefUpdate updateRef(String ref, boolean detach) throws IOException {
+    return delegate.updateRef(ref, detach);
+  }
+
+  @Override
+  public RefRename renameRef(String fromRef, String toRef) throws IOException {
+    return delegate.renameRef(fromRef, toRef);
+  }
+
+  @Override
+  public ObjectId resolve(String revstr)
+      throws AmbiguousObjectException, IncorrectObjectTypeException, RevisionSyntaxException,
+          IOException {
+    return delegate.resolve(revstr);
+  }
+
+  @Override
+  public String simplify(String revstr) throws AmbiguousObjectException, IOException {
+    return delegate.simplify(revstr);
+  }
+
+  @Override
+  @Deprecated
+  public Ref peel(Ref ref) {
+    return delegate.peel(ref);
+  }
+
+  @Override
+  public RevCommit parseCommit(AnyObjectId id)
+      throws IncorrectObjectTypeException, IOException, MissingObjectException {
+    return delegate.parseCommit(id);
+  }
+
+  @Override
+  public DirCache readDirCache() throws NoWorkTreeException, CorruptObjectException, IOException {
+    return delegate.readDirCache();
+  }
+
+  @Override
+  public String shortenRemoteBranchName(String refName) {
+    return delegate.shortenRemoteBranchName(refName);
+  }
+
+  @Override
+  public void setGitwebDescription(String description) throws IOException {
+    delegate.setGitwebDescription(description);
+  }
+
+  @Override
+  public String readMergeCommitMsg() throws IOException, NoWorkTreeException {
+    return delegate.readMergeCommitMsg();
+  }
+
+  @Override
+  public void writeMergeCommitMsg(String msg) throws IOException {
+    delegate.writeMergeCommitMsg(msg);
+  }
+
+  @Override
+  public String readCommitEditMsg() throws IOException, NoWorkTreeException {
+    return delegate.readCommitEditMsg();
+  }
+
+  @Override
+  public void writeCommitEditMsg(String msg) throws IOException {
+    delegate.writeCommitEditMsg(msg);
+  }
+
+  @Override
+  public List<ObjectId> readMergeHeads() throws IOException, NoWorkTreeException {
+    return delegate.readMergeHeads();
+  }
+
+  @Override
+  public void writeMergeHeads(List<? extends ObjectId> heads) throws IOException {
+    delegate.writeMergeHeads(heads);
+  }
+
+  @Override
+  public ObjectId readCherryPickHead() throws IOException, NoWorkTreeException {
+    return delegate.readCherryPickHead();
+  }
+
+  @Override
+  public ObjectId readRevertHead() throws IOException, NoWorkTreeException {
+    return delegate.readRevertHead();
+  }
+
+  @Override
+  public void writeCherryPickHead(ObjectId head) throws IOException {
+    delegate.writeCherryPickHead(head);
+  }
+
+  @Override
+  public void writeRevertHead(ObjectId head) throws IOException {
+    delegate.writeRevertHead(head);
+  }
+
+  @Override
+  public void writeOrigHead(ObjectId head) throws IOException {
+    delegate.writeOrigHead(head);
+  }
+
+  @Override
+  public ObjectId readOrigHead() throws IOException, NoWorkTreeException {
+    return delegate.readOrigHead();
+  }
+
+  @Override
+  public String readSquashCommitMsg() throws IOException {
+    return delegate.readSquashCommitMsg();
+  }
+
+  @Override
+  public void writeSquashCommitMsg(String msg) throws IOException {
+    delegate.writeSquashCommitMsg(msg);
+  }
+
+  @Override
+  public List<RebaseTodoLine> readRebaseTodo(String path, boolean includeComments)
+      throws IOException {
+    return delegate.readRebaseTodo(path, includeComments);
+  }
+
+  @Override
+  public void writeRebaseTodoFile(String path, List<RebaseTodoLine> steps, boolean append)
+      throws IOException {
+    delegate.writeRebaseTodoFile(path, steps, append);
+  }
 }
diff --git a/java/com/google/gerrit/server/git/GroupCollector.java b/java/com/google/gerrit/server/git/GroupCollector.java
index 9e0f2ee..5bbe5e2 100644
--- a/java/com/google/gerrit/server/git/GroupCollector.java
+++ b/java/com/google/gerrit/server/git/GroupCollector.java
@@ -29,7 +29,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.receive.ReceivePackRefCache;
@@ -43,7 +42,6 @@
 import java.util.Set;
 import java.util.TreeSet;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.revwalk.RevCommit;
 
 /**
@@ -231,7 +229,7 @@
 
   private boolean isGroupFromExistingPatchSet(RevCommit commit, String group) throws IOException {
     ObjectId id = parseGroup(commit, group);
-    return id != null && !receivePackRefCache.tipsFromObjectId(id, RefNames.REFS_CHANGES).isEmpty();
+    return id != null && !receivePackRefCache.patchSetIdsFromObjectId(id).isEmpty();
   }
 
   private Set<String> resolveGroups(ObjectId forCommit, Collection<String> candidates)
@@ -273,17 +271,13 @@
   private Iterable<String> resolveGroup(ObjectId forCommit, String group) throws IOException {
     ObjectId id = parseGroup(forCommit, group);
     if (id != null) {
-      Ref ref =
-          Iterables.getFirst(receivePackRefCache.tipsFromObjectId(id, RefNames.REFS_CHANGES), null);
-      if (ref != null) {
-        PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
-        if (psId != null) {
-          List<String> groups = groupLookup.lookup(psId);
-          // Group for existing patch set may be missing, e.g. if group has not
-          // been migrated yet.
-          if (groups != null && !groups.isEmpty()) {
-            return groups;
-          }
+      PatchSet.Id psId = Iterables.getFirst(receivePackRefCache.patchSetIdsFromObjectId(id), null);
+      if (psId != null) {
+        List<String> groups = groupLookup.lookup(psId);
+        // Group for existing patch set may be missing, e.g. if group has not
+        // been migrated yet.
+        if (groups != null && !groups.isEmpty()) {
+          return groups;
         }
       }
     }
diff --git a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
index 7b5f90bd..55261223 100644
--- a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
+++ b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
@@ -37,7 +37,9 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.ReceiveCommand;
@@ -94,6 +96,7 @@
   /**
    * Validates a single commit. If the commit does not validate, the command is rejected.
    *
+   * @param repository the repository
    * @param objectReader the object reader to use.
    * @param cmd the ReceiveCommand executing the push.
    * @param commit the commit being validated.
@@ -102,6 +105,7 @@
    * @return The validation {@link Result}.
    */
   Result validateCommit(
+      Repository repository,
       ObjectReader objectReader,
       ReceiveCommand cmd,
       RevCommit commit,
@@ -109,12 +113,14 @@
       NoteMap rejectCommits,
       @Nullable Change change)
       throws IOException {
-    return validateCommit(objectReader, cmd, commit, isMerged, rejectCommits, change, false);
+    return validateCommit(
+        repository, objectReader, cmd, commit, isMerged, rejectCommits, change, false);
   }
 
   /**
    * Validates a single commit. If the commit does not validate, the command is rejected.
    *
+   * @param repository the repository
    * @param objectReader the object reader to use.
    * @param cmd the ReceiveCommand executing the push.
    * @param commit the commit being validated.
@@ -124,6 +130,7 @@
    * @return The validation {@link Result}.
    */
   Result validateCommit(
+      Repository repository,
       ObjectReader objectReader,
       ReceiveCommand cmd,
       RevCommit commit,
@@ -135,7 +142,14 @@
     try (TraceTimer traceTimer = TraceContext.newTimer("BranchCommitValidator#validateCommit")) {
       ImmutableList.Builder<CommitValidationMessage> messages = new ImmutableList.Builder<>();
       try (CommitReceivedEvent receiveEvent =
-          new CommitReceivedEvent(cmd, project, branch.branch(), objectReader, commit, user)) {
+          new CommitReceivedEvent(
+              cmd,
+              project,
+              branch.branch(),
+              new Config(repository.getConfig()),
+              objectReader,
+              commit,
+              user)) {
         CommitValidators validators;
         if (isMerged) {
           validators =
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 69db066..fb8a9d3 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -112,6 +112,7 @@
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.SetHashtagsOp;
 import com.google.gerrit.server.change.SetPrivateOp;
+import com.google.gerrit.server.change.SetTopicOp;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.PluginConfig;
@@ -179,7 +180,6 @@
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.server.validators.ValidationException;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -344,7 +344,8 @@
   private final RequestScopePropagator requestScopePropagator;
   private final Sequences seq;
   private final SetHashtagsOp.Factory hashtagsFactory;
-  private final SubmissionListener superprojectUpdateSubmissionListener;
+  private final SetTopicOp.Factory setTopicFactory;
+  private final ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners;
   private final TagCache tagCache;
   private final ProjectConfig.Factory projectConfigFactory;
   private final SetPrivateOp.Factory setPrivateOpFactory;
@@ -426,7 +427,9 @@
       RequestScopePropagator requestScopePropagator,
       Sequences seq,
       SetHashtagsOp.Factory hashtagsFactory,
-      @SuperprojectUpdateOnSubmission SubmissionListener superprojectUpdateSubmissionListener,
+      SetTopicOp.Factory setTopicFactory,
+      @SuperprojectUpdateOnSubmission
+          ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners,
       TagCache tagCache,
       SetPrivateOp.Factory setPrivateOpFactory,
       ReplyAttentionSetUpdates replyAttentionSetUpdates,
@@ -452,6 +455,7 @@
     this.createGroupPermissionSyncer = createGroupPermissionSyncer;
     this.editUtil = editUtil;
     this.hashtagsFactory = hashtagsFactory;
+    this.setTopicFactory = setTopicFactory;
     this.indexer = indexer;
     this.initializers = initializers;
     this.mergeOpProvider = mergeOpProvider;
@@ -474,7 +478,7 @@
     this.retryHelper = retryHelper;
     this.requestScopePropagator = requestScopePropagator;
     this.seq = seq;
-    this.superprojectUpdateSubmissionListener = superprojectUpdateSubmissionListener;
+    this.superprojectUpdateSubmissionListeners = superprojectUpdateSubmissionListeners;
     this.tagCache = tagCache;
     this.projectConfigFactory = projectConfigFactory;
     this.setPrivateOpFactory = setPrivateOpFactory;
@@ -624,7 +628,7 @@
   private void processCommandsUnsafe(
       Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
     logger.atFine().log("Calling user: %s", user.getLoggableName());
-    logger.atFine().log("Groups: %s", user.getEffectiveGroups().getKnownGroups());
+    logger.atFine().log("Groups: %s", lazy(() -> user.getEffectiveGroups().getKnownGroups()));
 
     if (!projectState.getProject().getState().permitsWrite()) {
       for (ReceiveCommand cmd : commands) {
@@ -742,7 +746,7 @@
         logger.atFine().log("Added %d additional ref updates", added);
 
         SubmissionExecutor submissionExecutor =
-            new SubmissionExecutor(false, superprojectUpdateSubmissionListener);
+            new SubmissionExecutor(false, superprojectUpdateSubmissionListeners);
 
         submissionExecutor.execute(ImmutableList.of(bu));
 
@@ -1590,7 +1594,7 @@
         name = "--label",
         aliases = {"-l"},
         metaVar = "LABEL+VALUE",
-        usage = "label(s) to assign (defaults to +1 if no value provided")
+        usage = "label(s) to assign (defaults to +1 if no value provided)")
     void addLabel(String token) throws CmdLineException {
       LabelVote v = LabelVote.parse(token);
       try {
@@ -2145,15 +2149,15 @@
           receivePack.getRevWalk().parseBody(c);
           String name = c.name();
           groupCollector.visit(c);
-          Collection<Ref> existingRefs =
-              receivePackRefCache.tipsFromObjectId(c, RefNames.REFS_CHANGES);
+          Collection<PatchSet.Id> existingPatchSets =
+              receivePackRefCache.patchSetIdsFromObjectId(c);
 
           if (rejectImplicitMerges) {
             Collections.addAll(mergedParents, c.getParents());
             mergedParents.remove(c);
           }
 
-          boolean commitAlreadyTracked = !existingRefs.isEmpty();
+          boolean commitAlreadyTracked = !existingPatchSets.isEmpty();
           if (commitAlreadyTracked) {
             alreadyTracked++;
             // Corner cases where an existing commit might need a new group:
@@ -2169,9 +2173,7 @@
             //      A's group.
             // C) Commit is a PatchSet of a pre-existing change uploaded with a
             //    different target branch.
-            existingRefs.stream()
-                .map(r -> PatchSet.Id.fromRef(r.getName()))
-                .filter(Objects::nonNull)
+            existingPatchSets.stream()
                 .forEach(i -> updateGroups.add(new UpdateGroupsRequest(i, c)));
             if (!(newChangeForAllNotInTarget || magicBranch.base != null)) {
               continue;
@@ -2212,6 +2214,7 @@
 
           BranchCommitValidator.Result validationResult =
               validator.validateCommit(
+                  repo,
                   receivePack.getRevWalk().getObjectReader(),
                   magicBranch.cmd,
                   c,
@@ -2311,8 +2314,7 @@
 
             // In case the change look up from the index failed,
             // double check against the existing refs
-            if (foundInExistingRef(
-                receivePackRefCache.tipsFromObjectId(p.commit, RefNames.REFS_CHANGES))) {
+            if (foundInExistingPatchSets(receivePackRefCache.patchSetIdsFromObjectId(p.commit))) {
               if (pending.size() == 1) {
                 reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
                 return Collections.emptyList();
@@ -2360,11 +2362,10 @@
     }
   }
 
-  private boolean foundInExistingRef(Collection<Ref> existingRefs) {
-    try (TraceTimer traceTimer = newTimer("foundInExistingRef")) {
-      for (Ref ref : existingRefs) {
-        ChangeNotes notes =
-            notesFactory.create(project.getNameKey(), Change.Id.fromRef(ref.getName()));
+  private boolean foundInExistingPatchSets(Collection<PatchSet.Id> existingPatchSets) {
+    try (TraceTimer traceTimer = newTimer("foundInExistingPatchSet")) {
+      for (PatchSet.Id psId : existingPatchSets) {
+        ChangeNotes notes = notesFactory.create(project.getNameKey(), psId.changeId());
         Change change = notes.getChange();
         if (change.getDest().equals(magicBranch.dest)) {
           logger.atFine().log("Found change %s from existing refs.", change.getKey());
@@ -2597,7 +2598,7 @@
                     .setFireEvent(false));
           }
           if (!Strings.isNullOrEmpty(magicBranch.topic)) {
-            bu.addOp(changeId, new SetTopicOp(magicBranch.topic));
+            bu.addOp(changeId, setTopicFactory.create(magicBranch.topic));
           }
           bu.addOp(
               changeId,
@@ -2691,7 +2692,7 @@
   private void readChangesForReplace() {
     try (TraceTimer traceTimer = newTimer("readChangesForReplace")) {
       Collection<ChangeNotes> allNotes =
-          notesFactory.create(
+          notesFactory.createUsingIndexLookup(
               replaceByChange.values().stream().map(r -> r.ontoChange).collect(toList()));
       for (ChangeNotes notes : allNotes) {
         replaceByChange.get(notes.getChangeId()).notes = notes;
@@ -2838,15 +2839,15 @@
           return false;
         }
 
-        List<Ref> existingChangesWithSameCommit =
-            receivePackRefCache.tipsFromObjectId(newCommit, RefNames.REFS_CHANGES);
-        if (!existingChangesWithSameCommit.isEmpty()) {
+        List<PatchSet.Id> existingPatchSetsWithSameCommit =
+            receivePackRefCache.patchSetIdsFromObjectId(newCommit);
+        if (!existingPatchSetsWithSameCommit.isEmpty()) {
           // TODO(hiesel, hanwen): Remove this check entirely when Gerrit requires change IDs
           //  without the option to turn that off.
           reject(
               inputCommand,
               "commit already exists (in the project): "
-                  + existingChangesWithSameCommit.get(0).getName());
+                  + existingPatchSetsWithSameCommit.get(0).toRefName());
           return false;
         }
 
@@ -3225,13 +3226,13 @@
                     "more than %d commits, and %s not set", limit, PUSH_OPTION_SKIP_VALIDATION));
             return;
           }
-          if (!receivePackRefCache.tipsFromObjectId(c, RefNames.REFS_CHANGES).isEmpty()) {
+          if (!receivePackRefCache.patchSetIdsFromObjectId(c).isEmpty()) {
             continue;
           }
 
           BranchCommitValidator.Result validationResult =
               validator.validateCommit(
-                  walk.getObjectReader(), cmd, c, false, rejectCommits, null, skipValidation);
+                  repo, walk.getObjectReader(), cmd, c, false, rejectCommits, null, skipValidation);
           messages.addAll(validationResult.messages());
           if (!validationResult.isValid()) {
             break;
@@ -3294,12 +3295,8 @@
 
                       // Check if change refs point to this commit. Usually there are 0-1 change
                       // refs pointing to this commit.
-                      for (Ref ref :
-                          receivePackRefCache.tipsFromObjectId(c.copy(), RefNames.REFS_CHANGES)) {
-                        if (!PatchSet.isChangeRef(ref.getName())) {
-                          continue;
-                        }
-                        PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
+                      for (PatchSet.Id psId :
+                          receivePackRefCache.patchSetIdsFromObjectId(c.copy())) {
                         Optional<ChangeNotes> notes = getChangeNotes(psId.changeId());
                         if (notes.isPresent() && notes.get().getChange().getDest().equals(branch)) {
                           if (submissionId == null) {
@@ -3475,19 +3472,4 @@
     b.append(")\n");
     return b.toString();
   }
-
-  private static class SetTopicOp implements BatchUpdateOp {
-
-    private final String topic;
-
-    public SetTopicOp(String topic) {
-      this.topic = topic;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws ValidationException {
-      ctx.getUpdate(ctx.getChange().currentPatchSetId()).setTopic(topic);
-      return true;
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java b/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java
index 376ab2d..8568810 100644
--- a/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java
+++ b/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java
@@ -21,9 +21,11 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.RefNames;
 import java.io.IOException;
 import java.util.Map;
+import java.util.Objects;
 import org.eclipse.jgit.annotations.Nullable;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -58,8 +60,8 @@
     return new WithAdvertisedRefs(allRefsSupplier);
   }
 
-  /** Returns a list of refs whose name starts with {@code prefix} that point to {@code id}. */
-  ImmutableList<Ref> tipsFromObjectId(ObjectId id, @Nullable String prefix) throws IOException;
+  /** Returns a list of {@link com.google.gerrit.entities.PatchSet.Id}s that point to {@code id}. */
+  ImmutableList<PatchSet.Id> patchSetIdsFromObjectId(ObjectId id) throws IOException;
 
   /** Returns all refs whose name starts with {@code prefix}. */
   ImmutableList<Ref> byPrefix(String prefix) throws IOException;
@@ -76,10 +78,10 @@
     }
 
     @Override
-    public ImmutableList<Ref> tipsFromObjectId(ObjectId id, @Nullable String prefix)
-        throws IOException {
+    public ImmutableList<PatchSet.Id> patchSetIdsFromObjectId(ObjectId id) throws IOException {
       return delegate.getTipsWithSha1(id).stream()
-          .filter(r -> prefix == null || r.getName().startsWith(prefix))
+          .map(r -> PatchSet.Id.fromRef(r.getName()))
+          .filter(Objects::nonNull)
           .collect(toImmutableList());
     }
 
@@ -113,10 +115,11 @@
     }
 
     @Override
-    public ImmutableList<Ref> tipsFromObjectId(ObjectId id, String prefix) {
+    public ImmutableList<PatchSet.Id> patchSetIdsFromObjectId(ObjectId id) {
       lazilyInitRefMaps();
       return refsByObjectId.get(id).stream()
-          .filter(r -> prefix == null || r.getName().startsWith(prefix))
+          .map(r -> PatchSet.Id.fromRef(r.getName()))
+          .filter(Objects::nonNull)
           .collect(toImmutableList());
     }
 
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidationListener.java b/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
index b47d7d6..79d53ac 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.project.ProjectState;
 import org.eclipse.jgit.lib.Repository;
 
@@ -33,6 +34,7 @@
    * Validate a commit before it is merged.
    *
    * @param repo the repository
+   * @param revWalk the rev walk
    * @param commit commit details
    * @param destProject the destination project
    * @param destBranch the destination branch
@@ -42,6 +44,7 @@
    */
   void onPreMerge(
       Repository repo,
+      CodeReviewRevWalk revWalk,
       CodeReviewCommit commit,
       ProjectState destProject,
       BranchNameKey destBranch,
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidators.java b/java/com/google/gerrit/server/git/validators/MergeValidators.java
index 4e5ce0c..cbaa121 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -51,7 +52,6 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
  * Collection of validators that run inside Gerrit before a change is submitted. The main purpose is
@@ -92,6 +92,7 @@
    */
   public void validatePreMerge(
       Repository repo,
+      CodeReviewRevWalk revWalk,
       CodeReviewCommit commit,
       ProjectState destProject,
       BranchNameKey destBranch,
@@ -106,7 +107,7 @@
             groupValidatorFactory.create());
 
     for (MergeValidationListener validator : validators) {
-      validator.onPreMerge(repo, commit, destProject, destBranch, patchSetId, caller);
+      validator.onPreMerge(repo, revWalk, commit, destProject, destBranch, patchSetId, caller);
     }
   }
 
@@ -168,11 +169,12 @@
 
     @Override
     public void onPreMerge(
-        final Repository repo,
-        final CodeReviewCommit commit,
-        final ProjectState destProject,
-        final BranchNameKey destBranch,
-        final PatchSet.Id patchSetId,
+        Repository repo,
+        CodeReviewRevWalk revWalk,
+        CodeReviewCommit commit,
+        ProjectState destProject,
+        BranchNameKey destBranch,
+        PatchSet.Id patchSetId,
         IdentifiedUser caller)
         throws MergeValidationException {
       if (RefNames.REFS_CONFIG.equals(destBranch.branch())) {
@@ -260,6 +262,7 @@
     @Override
     public void onPreMerge(
         Repository repo,
+        CodeReviewRevWalk revWalk,
         CodeReviewCommit commit,
         ProjectState destProject,
         BranchNameKey destBranch,
@@ -267,7 +270,7 @@
         IdentifiedUser caller)
         throws MergeValidationException {
       mergeValidationListeners.runEach(
-          l -> l.onPreMerge(repo, commit, destProject, destBranch, patchSetId, caller),
+          l -> l.onPreMerge(repo, revWalk, commit, destProject, destBranch, patchSetId, caller),
           MergeValidationException.class);
     }
   }
@@ -294,6 +297,7 @@
     @Override
     public void onPreMerge(
         Repository repo,
+        CodeReviewRevWalk revWalk,
         CodeReviewCommit commit,
         ProjectState destProject,
         BranchNameKey destBranch,
@@ -316,8 +320,9 @@
         throw new MergeValidationException("account validation unavailable");
       }
 
-      try (RevWalk rw = new RevWalk(repo)) {
-        List<String> errorMessages = accountValidator.validate(accountId, repo, rw, null, commit);
+      try {
+        List<String> errorMessages =
+            accountValidator.validate(accountId, repo, revWalk, null, commit);
         if (!errorMessages.isEmpty()) {
           throw new MergeValidationException(
               "invalid account configuration: " + Joiner.on("; ").join(errorMessages));
@@ -345,6 +350,7 @@
     @Override
     public void onPreMerge(
         Repository repo,
+        CodeReviewRevWalk revWalk,
         CodeReviewCommit commit,
         ProjectState destProject,
         BranchNameKey destBranch,
diff --git a/java/com/google/gerrit/server/git/validators/ValidationMessage.java b/java/com/google/gerrit/server/git/validators/ValidationMessage.java
index 372fc17..b5d7eb1 100644
--- a/java/com/google/gerrit/server/git/validators/ValidationMessage.java
+++ b/java/com/google/gerrit/server/git/validators/ValidationMessage.java
@@ -22,6 +22,7 @@
  */
 public class ValidationMessage {
   public enum Type {
+    FATAL("FATAL: "),
     ERROR("ERROR: "),
     WARNING("WARNING: "),
     HINT("hint: "),
@@ -70,7 +71,7 @@
    * Returns {@true} if this message is an error. Used to decide if the operation should be aborted.
    */
   public boolean isError() {
-    return type == Type.ERROR;
+    return type == Type.FATAL || type == Type.ERROR;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/index/IndexUtils.java b/java/com/google/gerrit/server/index/IndexUtils.java
index 9e3d91c..ee8dfc8 100644
--- a/java/com/google/gerrit/server/index/IndexUtils.java
+++ b/java/com/google/gerrit/server/index/IndexUtils.java
@@ -30,7 +30,7 @@
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.group.GroupField;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.SingleGroupUser;
+import com.google.gerrit.server.query.change.GroupBackedUser;
 import java.io.IOException;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -107,7 +107,7 @@
     if (user.isIdentifiedUser()) {
       return user.getAccountId().toString();
     }
-    if (user instanceof SingleGroupUser) {
+    if (user instanceof GroupBackedUser) {
       return "group:" + user.getEffectiveGroups().getKnownGroups().iterator().next().toString();
     }
     return user.toString();
diff --git a/java/com/google/gerrit/server/logging/LoggingContext.java b/java/com/google/gerrit/server/logging/LoggingContext.java
index 671c224..a1b807d 100644
--- a/java/com/google/gerrit/server/logging/LoggingContext.java
+++ b/java/com/google/gerrit/server/logging/LoggingContext.java
@@ -42,11 +42,12 @@
   private static final ThreadLocal<MutableTags> tags = new ThreadLocal<>();
   private static final ThreadLocal<Boolean> forceLogging = new ThreadLocal<>();
   private static final ThreadLocal<Boolean> performanceLogging = new ThreadLocal<>();
+  private static final ThreadLocal<Boolean> aclLogging = new ThreadLocal<>();
 
   /**
-   * When copying the logging context to a new thread we need to ensure that the performance log
-   * records that are added in the new thread are added to the same {@link
-   * MutablePerformanceLogRecords} instance (see {@link LoggingContextAwareRunnable} and {@link
+   * When copying the logging context to a new thread we need to ensure that the mutable log records
+   * (performance logs and ACL logs) that are added in the new thread are added to the same multable
+   * log records instance (see {@link LoggingContextAwareRunnable} and {@link
    * LoggingContextAwareCallable}). This is important since performance log records are processed
    * only at the end of the request and performance log records that are created in another thread
    * should not get lost.
@@ -54,6 +55,8 @@
   private static final ThreadLocal<MutablePerformanceLogRecords> performanceLogRecords =
       new ThreadLocal<>();
 
+  private static final ThreadLocal<MutableAclLogRecords> aclLogRecords = new ThreadLocal<>();
+
   private LoggingContext() {}
 
   /** This method is expected to be called via reflection (and might otherwise be unused). */
@@ -67,7 +70,9 @@
     }
 
     return new LoggingContextAwareRunnable(
-        runnable, getInstance().getMutablePerformanceLogRecords());
+        runnable,
+        getInstance().getMutablePerformanceLogRecords(),
+        getInstance().getMutableAclRecords());
   }
 
   public static <T> Callable<T> copy(Callable<T> callable) {
@@ -76,14 +81,18 @@
     }
 
     return new LoggingContextAwareCallable<>(
-        callable, getInstance().getMutablePerformanceLogRecords());
+        callable,
+        getInstance().getMutablePerformanceLogRecords(),
+        getInstance().getMutableAclRecords());
   }
 
   public boolean isEmpty() {
     return tags.get() == null
         && forceLogging.get() == null
         && performanceLogging.get() == null
-        && performanceLogRecords.get() == null;
+        && performanceLogRecords.get() == null
+        && aclLogging.get() == null
+        && aclLogRecords.get() == null;
   }
 
   public void clear() {
@@ -91,6 +100,8 @@
     forceLogging.remove();
     performanceLogging.remove();
     performanceLogRecords.remove();
+    aclLogging.remove();
+    aclLogRecords.remove();
   }
 
   @Override
@@ -250,6 +261,101 @@
     return records;
   }
 
+  public boolean isAclLogging() {
+    Boolean isAclLogging = aclLogging.get();
+    return isAclLogging != null ? isAclLogging : false;
+  }
+
+  /**
+   * Enables ACL logging.
+   *
+   * <p>It's important to enable ACL logging only in a context that ensures to consume the captured
+   * ACL log records. Otherwise captured ACL log records might leak into other requests that are
+   * executed by the same thread (if a thread pool is used to process requests).
+   *
+   * @param enable whether ACL logging should be enabled.
+   * @return whether ACL logging was be enabled before invoking this method (old value).
+   */
+  boolean aclLogging(boolean enable) {
+    Boolean oldValue = aclLogging.get();
+    if (enable) {
+      aclLogging.set(true);
+    } else {
+      aclLogging.remove();
+    }
+    return oldValue != null ? oldValue : false;
+  }
+
+  /**
+   * Adds an ACL log record.
+   *
+   * @param aclLogRecord ACL log record
+   */
+  public void addAclLogRecord(String aclLogRecord) {
+    if (!isAclLogging()) {
+      return;
+    }
+
+    getMutableAclRecords().add(aclLogRecord);
+  }
+
+  ImmutableList<String> getAclLogRecords() {
+    MutableAclLogRecords records = aclLogRecords.get();
+    if (records != null) {
+      return records.list();
+    }
+    return ImmutableList.of();
+  }
+
+  void clearAclLogEntries() {
+    aclLogRecords.remove();
+  }
+
+  /**
+   * Set the ACL log records in this logging context. Existing log records are overwritten.
+   *
+   * <p>This method makes a defensive copy of the passed in list.
+   *
+   * @param newAclLogRecords ACL log records that should be set
+   */
+  void setAclLogRecords(List<String> newAclLogRecords) {
+    if (newAclLogRecords.isEmpty()) {
+      aclLogRecords.remove();
+      return;
+    }
+
+    getMutableAclRecords().set(newAclLogRecords);
+  }
+
+  /**
+   * Sets a {@link MutableAclLogRecords} instance for storing ACL log records.
+   *
+   * <p><strong>Attention:</strong> The passed in {@link MutableAclLogRecords} instance is directly
+   * stored in the logging context.
+   *
+   * <p>This method is intended to be only used when the logging context is copied to a new thread
+   * to ensure that the ACL log records that are added in the new thread are added to the same
+   * {@link MutableAclLogRecords} instance (see {@link LoggingContextAwareRunnable} and {@link
+   * LoggingContextAwareCallable}). This is important since ACL log records are processed only at
+   * the end of the request and ACL log records that are created in another thread should not get
+   * lost.
+   *
+   * @param mutableAclLogRecords the {@link MutableAclLogRecords} instance in which ACL log records
+   *     should be stored
+   */
+  void setMutableAclLogRecords(MutableAclLogRecords mutableAclLogRecords) {
+    aclLogRecords.set(requireNonNull(mutableAclLogRecords));
+  }
+
+  private MutableAclLogRecords getMutableAclRecords() {
+    MutableAclLogRecords records = aclLogRecords.get();
+    if (records == null) {
+      records = new MutableAclLogRecords();
+      aclLogRecords.set(records);
+    }
+    return records;
+  }
+
   @Override
   public String toString() {
     return MoreObjects.toStringHelper(this)
@@ -257,6 +363,8 @@
         .add("forceLogging", forceLogging.get())
         .add("performanceLogging", performanceLogging.get())
         .add("performanceLogRecords", performanceLogRecords.get())
+        .add("aclLogging", aclLogging.get())
+        .add("aclLogRecords", aclLogRecords.get())
         .toString();
   }
 }
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java b/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
index 1adee1b..ab5db02 100644
--- a/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
@@ -40,6 +40,8 @@
   private final boolean forceLogging;
   private final boolean performanceLogging;
   private final MutablePerformanceLogRecords mutablePerformanceLogRecords;
+  private final boolean aclLogging;
+  private final MutableAclLogRecords mutableAclLogRecords;
 
   /**
    * Creates a LoggingContextAwareCallable that wraps the given {@link Callable}.
@@ -47,15 +49,21 @@
    * @param callable Callable that should be wrapped.
    * @param mutablePerformanceLogRecords instance of {@link MutablePerformanceLogRecords} to which
    *     performance log records that are created from the runnable are added
+   * @param mutableAclLogRecords instance of {@link MutableAclLogRecords} to which ACL log records
+   *     that are created from the runnable are added
    */
   LoggingContextAwareCallable(
-      Callable<T> callable, MutablePerformanceLogRecords mutablePerformanceLogRecords) {
+      Callable<T> callable,
+      MutablePerformanceLogRecords mutablePerformanceLogRecords,
+      MutableAclLogRecords mutableAclLogRecords) {
     this.callable = callable;
     this.callingThread = Thread.currentThread();
     this.tags = LoggingContext.getInstance().getTagsAsMap();
     this.forceLogging = LoggingContext.getInstance().isLoggingForced();
     this.performanceLogging = LoggingContext.getInstance().isPerformanceLogging();
     this.mutablePerformanceLogRecords = mutablePerformanceLogRecords;
+    this.aclLogging = LoggingContext.getInstance().isAclLogging();
+    this.mutableAclLogRecords = mutableAclLogRecords;
   }
 
   @Override
@@ -76,6 +84,8 @@
     loggingCtx.forceLogging(forceLogging);
     loggingCtx.performanceLogging(performanceLogging);
     loggingCtx.setMutablePerformanceLogRecords(mutablePerformanceLogRecords);
+    loggingCtx.aclLogging(aclLogging);
+    loggingCtx.setMutableAclLogRecords(mutableAclLogRecords);
     try {
       return callable.call();
     } finally {
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
index d0559cc..3c4c563 100644
--- a/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
@@ -58,6 +58,8 @@
   private final boolean forceLogging;
   private final boolean performanceLogging;
   private final MutablePerformanceLogRecords mutablePerformanceLogRecords;
+  private final boolean aclLogging;
+  private final MutableAclLogRecords mutableAclLogRecords;
 
   /**
    * Creates a LoggingContextAwareRunnable that wraps the given {@link Runnable}.
@@ -65,15 +67,21 @@
    * @param runnable Runnable that should be wrapped.
    * @param mutablePerformanceLogRecords instance of {@link MutablePerformanceLogRecords} to which
    *     performance log records that are created from the runnable are added
+   * @param mutableAclLogRecords instance of {@link MutableAclLogRecords} to which ACL log records
+   *     that are created from the runnable are added
    */
   LoggingContextAwareRunnable(
-      Runnable runnable, MutablePerformanceLogRecords mutablePerformanceLogRecords) {
+      Runnable runnable,
+      MutablePerformanceLogRecords mutablePerformanceLogRecords,
+      MutableAclLogRecords mutableAclLogRecords) {
     this.runnable = runnable;
     this.callingThread = Thread.currentThread();
     this.tags = LoggingContext.getInstance().getTagsAsMap();
     this.forceLogging = LoggingContext.getInstance().isLoggingForced();
     this.performanceLogging = LoggingContext.getInstance().isPerformanceLogging();
     this.mutablePerformanceLogRecords = mutablePerformanceLogRecords;
+    this.aclLogging = LoggingContext.getInstance().isAclLogging();
+    this.mutableAclLogRecords = mutableAclLogRecords;
   }
 
   public Runnable unwrap() {
@@ -99,6 +107,8 @@
     loggingCtx.forceLogging(forceLogging);
     loggingCtx.performanceLogging(performanceLogging);
     loggingCtx.setMutablePerformanceLogRecords(mutablePerformanceLogRecords);
+    loggingCtx.aclLogging(aclLogging);
+    loggingCtx.setMutableAclLogRecords(mutableAclLogRecords);
     try {
       runnable.run();
     } finally {
diff --git a/java/com/google/gerrit/server/logging/MutableAclLogRecords.java b/java/com/google/gerrit/server/logging/MutableAclLogRecords.java
new file mode 100644
index 0000000..baa9b1f
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/MutableAclLogRecords.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2020 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.logging;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Thread-safe store for ACL log records.
+ *
+ * <p>This class is intended to keep track of user ACL records in {@link LoggingContext}. It needs
+ * to be thread-safe because it gets shared between threads when the logging context is copied to
+ * another thread (see {@link LoggingContextAwareRunnable} and {@link LoggingContextAwareCallable}.
+ * In this case the logging contexts of both threads share the same instance of this class. This is
+ * important since ACL log records are processed only at the end of a request and user ACL records
+ * that are created in another thread should not get lost.
+ */
+public class MutableAclLogRecords {
+  private final ArrayList<String> aclLogRecords = new ArrayList<>();
+
+  public synchronized void add(String record) {
+    aclLogRecords.add(record);
+  }
+
+  public synchronized void set(List<String> records) {
+    aclLogRecords.clear();
+    aclLogRecords.addAll(records);
+  }
+
+  public synchronized ImmutableList<String> list() {
+    return ImmutableList.copyOf(aclLogRecords);
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this).add("aclLogRecords", aclLogRecords).toString();
+  }
+}
diff --git a/java/com/google/gerrit/server/logging/TraceContext.java b/java/com/google/gerrit/server/logging/TraceContext.java
index 21a4ce6..2fc19b5 100644
--- a/java/com/google/gerrit/server/logging/TraceContext.java
+++ b/java/com/google/gerrit/server/logging/TraceContext.java
@@ -19,6 +19,7 @@
 import com.google.common.base.Stopwatch;
 import com.google.common.base.Strings;
 import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
@@ -222,9 +223,17 @@
   // Table<TAG_NAME, TAG_VALUE, REMOVE_ON_CLOSE>
   private final Table<String, String, Boolean> tags = HashBasedTable.create();
 
-  private boolean stopForceLoggingOnClose;
+  private final boolean oldAclLogging;
+  private final ImmutableList<String> oldAclLogRecords;
 
-  private TraceContext() {}
+  private boolean stopForceLoggingOnClose;
+  private boolean stopAclLoggingOnClose;
+
+  private TraceContext() {
+    // Just in case remember the old state and reset ACL log entries.
+    this.oldAclLogging = LoggingContext.getInstance().isAclLogging();
+    this.oldAclLogRecords = LoggingContext.getInstance().getAclLogRecords();
+  }
 
   public TraceContext addTag(RequestId.Type requestId, Object tagValue) {
     return addTag(requireNonNull(requestId, "request ID is required").name(), tagValue);
@@ -265,6 +274,23 @@
         .findFirst();
   }
 
+  public TraceContext enableAclLogging() {
+    if (stopAclLoggingOnClose) {
+      return this;
+    }
+
+    stopAclLoggingOnClose = !LoggingContext.getInstance().aclLogging(true);
+    return this;
+  }
+
+  public boolean isAclLoggingEnabled() {
+    return LoggingContext.getInstance().isAclLogging();
+  }
+
+  public ImmutableList<String> getAclLogRecords() {
+    return LoggingContext.getInstance().getAclLogRecords();
+  }
+
   @Override
   public void close() {
     for (Table.Cell<String, String, Boolean> cell : tags.cellSet()) {
@@ -275,5 +301,10 @@
     if (stopForceLoggingOnClose) {
       LoggingContext.getInstance().forceLogging(false);
     }
+
+    if (stopAclLoggingOnClose) {
+      LoggingContext.getInstance().aclLogging(oldAclLogging);
+      LoggingContext.getInstance().setAclLogRecords(oldAclLogRecords);
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/mail/EmailSettings.java b/java/com/google/gerrit/server/mail/EmailSettings.java
index c411af5..15b61d0 100644
--- a/java/com/google/gerrit/server/mail/EmailSettings.java
+++ b/java/com/google/gerrit/server/mail/EmailSettings.java
@@ -38,6 +38,7 @@
   public final Encryption encryption;
   public final long fetchInterval; // in milliseconds
   public final boolean sendNewPatchsetEmails;
+  public final boolean isAttentionSetEnabled;
 
   @Inject
   EmailSettings(@GerritServerConfig Config cfg) {
@@ -60,5 +61,6 @@
             TimeUnit.MILLISECONDS.convert(60, TimeUnit.SECONDS),
             TimeUnit.MILLISECONDS);
     sendNewPatchsetEmails = cfg.getBoolean("change", null, "sendNewPatchsetEmails", true);
+    isAttentionSetEnabled = cfg.getBoolean("change", null, "enableAttentionSet", true);
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 7b2bf12..a10021a 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.mail.send;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
 
 import com.google.common.base.Splitter;
@@ -33,9 +34,11 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.mail.MailHeader;
 import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchList;
@@ -56,6 +59,7 @@
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.TreeSet;
 import java.util.stream.Collectors;
@@ -75,6 +79,7 @@
     return ea.changeDataFactory.create(project, id);
   }
 
+  private final Set<Account.Id> currentAttentionSet;
   protected final Change change;
   protected final ChangeData changeData;
   protected ListMultimap<Account.Id, String> stars;
@@ -86,12 +91,15 @@
   protected ProjectState projectState;
   protected Set<Account.Id> authors;
   protected boolean emailOnlyAuthors;
+  protected boolean emailOnlyAttentionSetIfEnabled;
 
   protected ChangeEmail(EmailArguments args, String messageClass, ChangeData changeData) {
     super(args, messageClass, changeData.change().getDest());
     this.changeData = changeData;
     this.change = changeData.change();
     this.emailOnlyAuthors = false;
+    this.emailOnlyAttentionSetIfEnabled = true;
+    this.currentAttentionSet = getAttentionSet();
   }
 
   @Override
@@ -123,6 +131,10 @@
   /** Format the message body by calling {@link #appendText(String)}. */
   @Override
   protected void format() throws EmailException {
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("ChangeHeaderHtml"));
+    }
+    appendText(textTemplate("ChangeHeader"));
     formatChange();
     appendText(textTemplate("ChangeFooter"));
     if (useHtml()) {
@@ -387,9 +399,20 @@
 
   @Override
   protected void add(RecipientType rt, Account.Id to) {
-    if (!emailOnlyAuthors || authors.contains(to)) {
-      super.add(rt, to);
+    Optional<AccountState> accountState = args.accountCache.get(to);
+    if (!accountState.isPresent()) {
+      return;
     }
+    if (emailOnlyAttentionSetIfEnabled
+        && accountState.get().generalPreferences().getEmailStrategy()
+            == EmailStrategy.ATTENTION_SET_ONLY
+        && !currentAttentionSet.contains(to)) {
+      return;
+    }
+    if (emailOnlyAuthors && !authors.contains(to)) {
+      return;
+    }
+    super.add(rt, to);
   }
 
   @Override
@@ -487,8 +510,15 @@
     for (String reviewer : getEmailsByState(ReviewerStateInternal.CC)) {
       footers.add(MailHeader.CC.withDelimiter() + reviewer);
     }
-    for (String attentionUser : getAttentionSet()) {
-      footers.add(MailHeader.ATTENTION.withDelimiter() + attentionUser);
+    for (Account.Id attentionUser : currentAttentionSet) {
+      footers.add(MailHeader.ATTENTION.withDelimiter() + getNameEmailFor(attentionUser));
+    }
+    // Since this would be user visible, only show it if attention set is enabled
+    if (args.settings.isAttentionSetEnabled && !currentAttentionSet.isEmpty()) {
+      // We need names rather than account ids / emails to make it user readable.
+      soyContext.put(
+          "attentionSet",
+          currentAttentionSet.stream().map(this::getNameFor).collect(toImmutableSet()));
     }
   }
 
@@ -515,12 +545,12 @@
     return reviewers;
   }
 
-  private Set<String> getAttentionSet() {
-    Set<String> attentionSet = new TreeSet<>();
+  private Set<Account.Id> getAttentionSet() {
+    Set<Account.Id> attentionSet = new TreeSet<>();
     try {
       attentionSet =
           additionsOnly(changeData.attentionSet()).stream()
-              .map(a -> getNameEmailFor(a.account()))
+              .map(a -> a.account())
               .collect(Collectors.toSet());
     } catch (StorageException e) {
       logger.atWarning().withCause(e).log("Cannot get change attention set");
diff --git a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
index d5863a6..0de0dbe 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
@@ -61,7 +61,7 @@
     bccStarredBy();
     ccExistingReviewers();
     includeWatchers(NotifyType.ALL_COMMENTS);
-    add(RecipientType.TO, reviewers);
+    reviewers.stream().forEach(r -> add(RecipientType.TO, r));
     addByEmail(RecipientType.TO, reviewersByEmail);
     removeUsersThatIgnoredTheChange();
   }
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java b/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
index 623bdc2..1b58057 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
@@ -45,6 +45,8 @@
     "AddToAttentionSetHtml.soy",
     "ChangeFooter.soy",
     "ChangeFooterHtml.soy",
+    "ChangeHeader.soy",
+    "ChangeHeaderHtml.soy",
     "ChangeSubject.soy",
     "Comment.soy",
     "CommentHtml.soy",
@@ -60,7 +62,6 @@
     "InboundEmailRejectionHtml.soy",
     "Footer.soy",
     "FooterHtml.soy",
-    "HeaderHtml.soy",
     "HttpPasswordUpdate.soy",
     "HttpPasswordUpdateHtml.soy",
     "Merged.soy",
diff --git a/java/com/google/gerrit/server/mail/send/MergedSender.java b/java/com/google/gerrit/server/mail/send/MergedSender.java
index 928bdc3..9c2d6ff 100644
--- a/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ b/java/com/google/gerrit/server/mail/send/MergedSender.java
@@ -46,6 +46,9 @@
 
   @Override
   protected void init() throws EmailException {
+    // We want to send the submit email even if the "send only when in attention set" is enabled.
+    emailOnlyAttentionSetIfEnabled = false;
+
     super.init();
 
     ccAllApprovals();
diff --git a/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
index 0e97f7e..ee9a328 100644
--- a/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -65,11 +65,11 @@
         break;
       case ALL:
       default:
-        add(RecipientType.CC, extraCC);
+        extraCC.stream().forEach(cc -> add(RecipientType.CC, cc));
         extraCCByEmail.stream().forEach(cc -> add(RecipientType.CC, cc));
         // $FALL-THROUGH$
       case OWNER_REVIEWERS:
-        add(RecipientType.TO, reviewers, true);
+        reviewers.stream().forEach(r -> add(RecipientType.TO, r, true));
         addByEmail(RecipientType.TO, reviewersByEmail, true);
         break;
     }
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index 1eb274b..44453d5 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.EmailHeader;
 import com.google.gerrit.entities.EmailHeader.AddressList;
-import com.google.gerrit.entities.UserIdentity;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
@@ -116,9 +115,6 @@
     if (messageId == null) {
       throw new IllegalStateException("All emails must have a messageId");
     }
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("HeaderHtml"));
-    }
     format();
     appendText(textTemplate("Footer"));
     if (useHtml()) {
@@ -287,7 +283,7 @@
     setHeader(MailHeader.AUTO_SUBMITTED.fieldName(), "auto-generated");
 
     for (RecipientType recipientType : notify.accounts().keySet()) {
-      add(recipientType, notify.accounts().get(recipientType));
+      notify.accounts().get(recipientType).stream().forEach(a -> add(recipientType, a));
     }
 
     setHeader(MailHeader.MESSAGE_TYPE.fieldName(), messageClass);
@@ -474,40 +470,18 @@
     return true;
   }
 
-  /** Schedule this message for delivery to the listed accounts. */
-  protected void add(RecipientType rt, Collection<Account.Id> list) {
-    add(rt, list, false);
-  }
-
-  /** Schedule this message for delivery to the listed accounts. */
-  protected void add(RecipientType rt, Collection<Account.Id> list, boolean override) {
-    for (final Account.Id id : list) {
-      add(rt, id, override);
-    }
-  }
-
   /** Schedule this message for delivery to the listed address. */
-  protected void addByEmail(RecipientType rt, Collection<Address> list) {
+  protected final void addByEmail(RecipientType rt, Collection<Address> list) {
     addByEmail(rt, list, false);
   }
 
   /** Schedule this message for delivery to the listed address. */
-  protected void addByEmail(RecipientType rt, Collection<Address> list, boolean override) {
+  protected final void addByEmail(RecipientType rt, Collection<Address> list, boolean override) {
     for (final Address id : list) {
       add(rt, id, override);
     }
   }
 
-  protected void add(RecipientType rt, UserIdentity who) {
-    add(rt, who, false);
-  }
-
-  protected void add(RecipientType rt, UserIdentity who, boolean override) {
-    if (who != null && who.getAccount() != null) {
-      add(rt, who.getAccount(), override);
-    }
-  }
-
   /** Schedule delivery of this message to the given account. */
   protected void add(RecipientType rt, Account.Id to) {
     add(rt, to, false);
@@ -534,11 +508,11 @@
   }
 
   /** Schedule delivery of this message to the given account. */
-  protected void add(RecipientType rt, Address addr) {
+  protected final void add(RecipientType rt, Address addr) {
     add(rt, addr, false);
   }
 
-  protected void add(RecipientType rt, Address addr, boolean override) {
+  protected final void add(RecipientType rt, Address addr, boolean override) {
     if (addr != null && addr.email() != null && addr.email().length() > 0) {
       if (!args.validator.isValid(addr.email())) {
         logger.atWarning().log("Not emailing %s (invalid email address)", addr.email());
diff --git a/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index 0514337..173b121 100644
--- a/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -33,7 +33,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.query.change.SingleGroupUser;
+import com.google.gerrit.server.query.change.GroupBackedUser;
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
@@ -150,7 +150,7 @@
       throws QueryParseException {
     logger.atFine().log("Checking watchers for notify config %s from project %s", nc, projectName);
     for (GroupReference groupRef : nc.getGroups()) {
-      CurrentUser user = new SingleGroupUser(groupRef.getUUID());
+      CurrentUser user = new GroupBackedUser(ImmutableSet.of(groupRef.getUUID()));
       if (filterMatch(user, nc.getFilter())) {
         deliverToMembers(matching.list(nc.getHeader()), groupRef.getUUID());
         logger.atFine().log("Added watchers for group %s", groupRef);
diff --git a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
index 5caac37..9516b9f 100644
--- a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
@@ -64,8 +64,8 @@
     if (args.settings.sendNewPatchsetEmails) {
       if (notify.handling() == NotifyHandling.ALL
           || notify.handling() == NotifyHandling.OWNER_REVIEWERS) {
-        add(RecipientType.TO, reviewers);
-        add(RecipientType.CC, extraCC);
+        reviewers.stream().forEach(r -> add(RecipientType.TO, r));
+        extraCC.stream().forEach(cc -> add(RecipientType.CC, cc));
       }
       rcptToAuthors(RecipientType.CC);
     }
diff --git a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
index af00b20..1ad94be 100644
--- a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
@@ -391,11 +391,7 @@
   }
 
   private SMTPClient open() throws EmailException {
-    final AuthSMTPClient client = new AuthSMTPClient(UTF_8.name());
-
-    if (smtpEncryption == Encryption.SSL) {
-      client.enableSSL(sslVerify);
-    }
+    final AuthSMTPClient client = new AuthSMTPClient(smtpEncryption == Encryption.SSL, sslVerify);
 
     client.setConnectTimeout(connectTimeout);
     try {
@@ -411,7 +407,7 @@
       }
 
       if (smtpEncryption == Encryption.TLS) {
-        if (!client.startTLS(smtpHost, smtpPort, sslVerify)) {
+        if (!client.execTLS()) {
           throw new EmailException("SMTP server does not support TLS");
         }
         if (!client.login()) {
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index e83d4fd..d48cbc4 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -77,6 +77,9 @@
 import org.eclipse.jgit.lib.Repository;
 
 /** View of a single {@link Change} based on the log of its notes branch. */
+// TODO(paiking): This class should be refactored to get rid of potentially duplicate or unneeded
+// variables, such as allAttentionSetUpdates, reviewerUpdates, and others.
+
 public class ChangeNotes extends AbstractChangeNotes<ChangeNotes> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -115,7 +118,29 @@
       return new ChangeNotes(args, change, true, null).load();
     }
 
-    public ChangeNotes createChecked(Change.Id changeId) {
+    public static Change newChange(Project.NameKey project, Change.Id changeId) {
+      return new Change(
+          null, changeId, null, BranchNameKey.create(project, "INVALID_NOTE_DB_ONLY"), null);
+    }
+
+    public ChangeNotes create(Project.NameKey project, Change.Id changeId) {
+      checkArgument(project != null, "project is required");
+      return new ChangeNotes(args, newChange(project, changeId), true, null).load();
+    }
+
+    public ChangeNotes createForBatchUpdate(Change change, boolean shouldExist) {
+      return new ChangeNotes(args, change, shouldExist, null).load();
+    }
+
+    public ChangeNotes create(Change change, RefCache refs) {
+      return new ChangeNotes(args, change, true, refs).load();
+    }
+
+    /**
+     * Create change notes based on a {@link Change.Id}. This requires using the Change index and
+     * should only be used when {@link Project.NameKey} and the numeric change ID are not available.
+     */
+    public ChangeNotes createCheckedUsingIndexLookup(Change.Id changeId) {
       InternalChangeQuery query = queryProvider.get().noFields();
       List<ChangeData> changes = query.byLegacyChangeId(changeId);
       if (changes.isEmpty()) {
@@ -128,40 +153,16 @@
       return changes.get(0).notes();
     }
 
-    public static Change newChange(Project.NameKey project, Change.Id changeId) {
-      return new Change(
-          null, changeId, null, BranchNameKey.create(project, "INVALID_NOTE_DB_ONLY"), null);
-    }
-
-    public ChangeNotes create(Project.NameKey project, Change.Id changeId) {
-      checkArgument(project != null, "project is required");
-      return new ChangeNotes(args, newChange(project, changeId), true, null).load();
-    }
-
     /**
-     * Create change notes for a change that was loaded from index. This method should only be used
-     * when database access is harmful and potentially stale data from the index is acceptable.
-     *
-     * @param change change loaded from secondary index
-     * @return change notes
+     * Create change notes based on a list of {@link Change.Id}s. This requires using the Change
+     * index and should only be used when {@link Project.NameKey} and the numeric change ID are not
+     * available.
      */
-    public ChangeNotes createFromIndexedChange(Change change) {
-      return new ChangeNotes(args, change, true, null);
-    }
-
-    public ChangeNotes createForBatchUpdate(Change change, boolean shouldExist) {
-      return new ChangeNotes(args, change, shouldExist, null).load();
-    }
-
-    public ChangeNotes create(Change change, RefCache refs) {
-      return new ChangeNotes(args, change, true, refs).load();
-    }
-
-    public List<ChangeNotes> create(Collection<Change.Id> changeIds) {
+    public List<ChangeNotes> createUsingIndexLookup(Collection<Change.Id> changeIds) {
       List<ChangeNotes> notes = new ArrayList<>();
       for (Change.Id changeId : changeIds) {
         try {
-          notes.add(createChecked(changeId));
+          notes.add(createCheckedUsingIndexLookup(changeId));
         } catch (NoSuchChangeException e) {
           // Ignore missing changes to match Access#get(Iterable) behavior.
         }
@@ -391,6 +392,11 @@
     return state.attentionSet();
   }
 
+  /** Returns all updates for the attention set. */
+  public ImmutableList<AttentionSetUpdate> getAttentionSetUpdates() {
+    return state.allAttentionSetUpdates();
+  }
+
   /**
    * @return an ImmutableSet of Account.Ids of all users that have been assigned to this change. The
    *     order of the set is the order in which they were assigned.
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index 7fde297..220e683 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -121,12 +121,18 @@
     // Single Timestamp overhead.
     private static final int T = O + 8;
 
+    /**
+     * {@inheritDoc}
+     *
+     * <p>Take all columns and all collection sizes into account, but use estimated average element
+     * sizes rather than iterating over collections. Numbers are largely hand-wavy based on
+     * http://stackoverflow.com/questions/258120/what-is-the-memory-consumption-of-an-object-in-java
+     *
+     * <p>Should be kept up to date with {@link ChangeNotesState}. Please, keep weights listed in
+     * the same order as fields.
+     */
     @Override
     public int weigh(Key key, ChangeNotesState state) {
-      // Take all columns and all collection sizes into account, but use
-      // estimated average element sizes rather than iterating over collections.
-      // Numbers are largely hand-wavy based on
-      // http://stackoverflow.com/questions/258120/what-is-the-memory-consumption-of-an-object-in-java
       return P
           + O
           + 20 // metaId
@@ -138,6 +144,7 @@
           + K // owner
           + P
           + str(state.columns().branch())
+          + P // status
           + P
           + patchSetId() // currentPatchSetId
           + P
@@ -148,9 +155,16 @@
           + str(state.columns().originalSubject())
           + P
           + str(state.columns().submissionId())
-          + P // status
+          + 1 // isPrivate
+          + 1 // workInProgress
+          + 1 // reviewStarted
+          + P
+          + K // revertOf
+          + P
+          + patchSetId() // cherryPickOf
           + P
           + set(state.hashtags(), str(10))
+          + str(state.serverId()) // serverId
           + P
           + list(state.patchSets(), patchSet())
           + P
@@ -168,14 +182,15 @@
           + P
           + list(state.assigneeUpdates(), 4 * O + K + K)
           + P
+          + set(state.attentionSet(), 4 * O + K + I + str(15))
+          + P
+          + list(state.allAttentionSetUpdates(), 4 * O + K + I + str(15))
+          + P
           + list(state.submitRecords(), P + list(2, str(4) + P + K) + P)
           + P
           + list(state.changeMessages(), changeMessage())
           + P
           + map(state.publishedComments().asMap(), comment())
-          + 1 // isPrivate
-          + 1 // workInProgress
-          + 1 // reviewStarted
           + I; // updateCount
     }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index c92d236..fae29f8 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -118,6 +118,8 @@
   private final List<ReviewerStatusUpdate> reviewerUpdates;
   /** Holds only the most recent update per user. Older updates are discarded. */
   private final Map<Account.Id, AttentionSetUpdate> latestAttentionStatus;
+  /** Holds all updates to attention set. */
+  private final List<AttentionSetUpdate> allAttentionSetUpdates;
 
   private final List<AssigneeStatusUpdate> assigneeUpdates;
   private final List<SubmitRecord> submitRecords;
@@ -175,6 +177,7 @@
     allPastReviewers = new ArrayList<>();
     reviewerUpdates = new ArrayList<>();
     latestAttentionStatus = new HashMap<>();
+    allAttentionSetUpdates = new ArrayList<>();
     assigneeUpdates = new ArrayList<>();
     submitRecords = Lists.newArrayListWithExpectedSize(1);
     allChangeMessages = new ArrayList<>();
@@ -246,6 +249,7 @@
         allPastReviewers,
         buildReviewerUpdates(),
         ImmutableSet.copyOf(latestAttentionStatus.values()),
+        allAttentionSetUpdates,
         assigneeUpdates,
         submitRecords,
         buildAllMessages(),
@@ -589,6 +593,9 @@
       }
       // Processing is in reverse chronological order. Keep only the latest update.
       latestAttentionStatus.putIfAbsent(attentionStatus.get().account(), attentionStatus.get());
+
+      // Keep all updates as well.
+      allAttentionSetUpdates.add(attentionStatus.get());
     }
   }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 76c4678..27cfb70 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -81,9 +81,15 @@
  * <p>One instance is the output of a single {@link ChangeNotesParser}, and contains types required
  * to support public methods on {@link ChangeNotes}. It is intended to be cached in-process.
  *
+ * <p>When new fields are added to the {@link ChangeNotesState}, {@link
+ * ChangeNotesCache.Weigher#weigh} should be updated.
+ *
  * <p>Note that {@link ChangeNotes} contains more than just a single {@code ChangeNoteState}, such
  * as per-draft information, so that class is not cached directly.
  */
+// TODO(paiking): This class should be refactored to get rid of potentially duplicate or unneeded
+// variables, such as allAttentionSetUpdates, reviewerUpdates, and others.
+
 @AutoValue
 public abstract class ChangeNotesState {
 
@@ -120,6 +126,7 @@
       List<Account.Id> allPastReviewers,
       List<ReviewerStatusUpdate> reviewerUpdates,
       Set<AttentionSetUpdate> attentionSetUpdates,
+      List<AttentionSetUpdate> allAttentionSetUpdates,
       List<AssigneeStatusUpdate> assigneeUpdates,
       List<SubmitRecord> submitRecords,
       List<ChangeMessage> changeMessages,
@@ -171,6 +178,7 @@
         .allPastReviewers(allPastReviewers)
         .reviewerUpdates(reviewerUpdates)
         .attentionSet(attentionSetUpdates)
+        .allAttentionSetUpdates(allAttentionSetUpdates)
         .assigneeUpdates(assigneeUpdates)
         .submitRecords(submitRecords)
         .changeMessages(changeMessages)
@@ -305,9 +313,12 @@
 
   abstract ImmutableList<ReviewerStatusUpdate> reviewerUpdates();
 
-  /** Returns the most recent update (i.e. current status status) per user. */
+  /** Returns the most recent update (i.e. current status) per user. */
   abstract ImmutableSet<AttentionSetUpdate> attentionSet();
 
+  /** Returns all attention set updates. */
+  abstract ImmutableList<AttentionSetUpdate> allAttentionSetUpdates();
+
   abstract ImmutableList<AssigneeStatusUpdate> assigneeUpdates();
 
   abstract ImmutableList<SubmitRecord> submitRecords();
@@ -386,6 +397,7 @@
           .allPastReviewers(ImmutableList.of())
           .reviewerUpdates(ImmutableList.of())
           .attentionSet(ImmutableSet.of())
+          .allAttentionSetUpdates(ImmutableList.of())
           .assigneeUpdates(ImmutableList.of())
           .submitRecords(ImmutableList.of())
           .changeMessages(ImmutableList.of())
@@ -421,6 +433,8 @@
 
     abstract Builder attentionSet(Set<AttentionSetUpdate> attentionSetUpdates);
 
+    abstract Builder allAttentionSetUpdates(List<AttentionSetUpdate> attentionSetUpdates);
+
     abstract Builder assigneeUpdates(List<AssigneeStatusUpdate> assigneeUpdates);
 
     abstract Builder submitRecords(List<SubmitRecord> submitRecords);
@@ -489,6 +503,9 @@
       object.allPastReviewers().forEach(a -> b.addPastReviewer(a.get()));
       object.reviewerUpdates().forEach(u -> b.addReviewerUpdate(toReviewerStatusUpdateProto(u)));
       object.attentionSet().forEach(u -> b.addAttentionSetUpdate(toAttentionSetUpdateProto(u)));
+      object
+          .allAttentionSetUpdates()
+          .forEach(u -> b.addAllAttentionSetUpdate(toAttentionSetUpdateProto(u)));
       object.assigneeUpdates().forEach(u -> b.addAssigneeUpdate(toAssigneeStatusUpdateProto(u)));
       object
           .submitRecords()
@@ -623,6 +640,8 @@
                   proto.getPastReviewerList().stream().map(Account::id).collect(toImmutableList()))
               .reviewerUpdates(toReviewerStatusUpdateList(proto.getReviewerUpdateList()))
               .attentionSet(toAttentionSetUpdates(proto.getAttentionSetUpdateList()))
+              .allAttentionSetUpdates(
+                  toAllAttentionSetUpdates(proto.getAllAttentionSetUpdateList()))
               .assigneeUpdates(toAssigneeStatusUpdateList(proto.getAssigneeUpdateList()))
               .submitRecords(
                   proto.getSubmitRecordList().stream()
@@ -735,6 +754,20 @@
       return b.build();
     }
 
+    private static ImmutableList<AttentionSetUpdate> toAllAttentionSetUpdates(
+        List<AttentionSetUpdateProto> protos) {
+      ImmutableList.Builder<AttentionSetUpdate> b = ImmutableList.builder();
+      for (AttentionSetUpdateProto proto : protos) {
+        b.add(
+            AttentionSetUpdate.createFromRead(
+                Instant.ofEpochMilli(proto.getTimestampMillis()),
+                Account.id(proto.getAccount()),
+                AttentionSetUpdate.Operation.valueOf(proto.getOperation()),
+                proto.getReason()));
+      }
+      return b.build();
+    }
+
     private static ImmutableList<AssigneeStatusUpdate> toAssigneeStatusUpdateList(
         List<AssigneeStatusUpdateProto> protos) {
       ImmutableList.Builder<AssigneeStatusUpdate> b = ImmutableList.builder();
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index c3b0e79..6d75bd2 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -65,6 +65,7 @@
 import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.ServiceUserClassifier;
@@ -87,6 +88,7 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
@@ -821,6 +823,22 @@
         AttentionSetUtil.additionsOnly(getNotes().getAttentionSet()).stream()
             .map(AttentionSetUpdate::account)
             .collect(Collectors.toSet());
+
+    // Current reviewers/ccs are the reviewers/ccs before the update + the new reviewers/ccs - the
+    // deleted reviewers/ccs.
+    Set<Account.Id> currentReviewers =
+        Stream.concat(
+                getNotes().getReviewers().all().stream(),
+                reviewers.entrySet().stream()
+                    .filter(r -> r.getValue().asReviewerState() != ReviewerState.REMOVED)
+                    .map(r -> r.getKey()))
+            .collect(Collectors.toSet());
+    currentReviewers.removeAll(
+        reviewers.entrySet().stream()
+            .filter(r -> r.getValue().asReviewerState() == ReviewerState.REMOVED)
+            .map(r -> r.getKey())
+            .collect(ImmutableSet.toImmutableSet()));
+
     for (AttentionSetUpdate attentionSetUpdate : plannedAttentionSetUpdates.values()) {
       if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD
           && currentUsersInAttentionSet.contains(attentionSetUpdate.account())) {
@@ -848,11 +866,27 @@
         continue;
       }
 
+      // Don't add accounts that are not active in the change to the attention set.
+      if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD
+          && !isActiveOnChange(currentReviewers, attentionSetUpdate.account())) {
+        continue;
+      }
+
       addFooter(msg, FOOTER_ATTENTION, noteUtil.attentionSetUpdateToJson(attentionSetUpdate));
     }
   }
 
   /**
+   * Returns whether {@code accountId} is active on a change based on the {@code currentReviewers}.
+   * Activity is defined as being a part of the reviewers, an uploader, or an owner of a change.
+   */
+  private boolean isActiveOnChange(Set<Account.Id> currentReviewers, Account.Id accountId) {
+    return currentReviewers.contains(accountId)
+        || getChange().getOwner().equals(accountId)
+        || getNotes().getCurrentPatchSet().uploader().equals(accountId);
+  }
+
+  /**
    * When set, default attention set rules are ignored (E.g, adding reviewers -> adds to attention
    * set, etc).
    */
diff --git a/java/com/google/gerrit/server/patch/DiffMappings.java b/java/com/google/gerrit/server/patch/DiffMappings.java
index 921d66e..a6f026e 100644
--- a/java/com/google/gerrit/server/patch/DiffMappings.java
+++ b/java/com/google/gerrit/server/patch/DiffMappings.java
@@ -15,12 +15,17 @@
 package com.google.gerrit.server.patch;
 
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Patch;
 import com.google.gerrit.server.patch.GitPositionTransformer.FileMapping;
 import com.google.gerrit.server.patch.GitPositionTransformer.Mapping;
 import com.google.gerrit.server.patch.GitPositionTransformer.Range;
 import com.google.gerrit.server.patch.GitPositionTransformer.RangeMapping;
+import com.google.gerrit.server.patch.entities.Edit;
+import com.google.gerrit.server.patch.entities.FileEdits;
+import java.util.List;
 
 /** Mappings derived from diffs. */
 public class DiffMappings {
@@ -33,31 +38,47 @@
     return Mapping.create(fileMapping, rangeMappings);
   }
 
-  private static FileMapping toFileMapping(PatchListEntry patchListEntry) {
-    switch (patchListEntry.getChangeType()) {
+  public static Mapping toMapping(FileEdits fileEdits) {
+    FileMapping fileMapping = FileMapping.forFile(fileEdits.oldPath(), fileEdits.newPath());
+    ImmutableSet<RangeMapping> rangeMappings = toRangeMappings(fileEdits.edits());
+    return Mapping.create(fileMapping, rangeMappings);
+  }
+
+  private static FileMapping toFileMapping(PatchListEntry ple) {
+    return toFileMapping(ple.getChangeType(), ple.getOldName(), ple.getNewName());
+  }
+
+  private static FileMapping toFileMapping(
+      Patch.ChangeType changeType, String oldName, String newName) {
+    switch (changeType) {
       case ADDED:
-        return FileMapping.forAddedFile(patchListEntry.getNewName());
+        return FileMapping.forAddedFile(newName);
       case MODIFIED:
       case REWRITE:
-        return FileMapping.forModifiedFile(patchListEntry.getNewName());
+        return FileMapping.forModifiedFile(newName);
       case DELETED:
         // Name of deleted file is mentioned as newName.
-        return FileMapping.forDeletedFile(patchListEntry.getNewName());
+        return FileMapping.forDeletedFile(newName);
       case RENAMED:
       case COPIED:
-        return FileMapping.forRenamedFile(patchListEntry.getOldName(), patchListEntry.getNewName());
+        return FileMapping.forRenamedFile(oldName, newName);
       default:
-        throw new IllegalStateException("Unmapped diff type: " + patchListEntry.getChangeType());
+        throw new IllegalStateException("Unmapped diff type: " + changeType);
     }
   }
 
   private static ImmutableSet<RangeMapping> toRangeMappings(PatchListEntry patchListEntry) {
-    return patchListEntry.getEdits().stream()
+    return toRangeMappings(
+        patchListEntry.getEdits().stream().map(Edit::fromJGitEdit).collect(toList()));
+  }
+
+  private static ImmutableSet<RangeMapping> toRangeMappings(List<Edit> edits) {
+    return edits.stream()
         .map(
             edit ->
                 RangeMapping.create(
-                    Range.create(edit.getBeginA(), edit.getEndA()),
-                    Range.create(edit.getBeginB(), edit.getEndB())))
+                    Range.create(edit.beginA(), edit.endA()),
+                    Range.create(edit.beginB(), edit.endB())))
         .collect(toImmutableSet());
   }
 }
diff --git a/java/com/google/gerrit/server/patch/DiffNotAvailableException.java b/java/com/google/gerrit/server/patch/DiffNotAvailableException.java
new file mode 100644
index 0000000..34e1577
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffNotAvailableException.java
@@ -0,0 +1,28 @@
+//  Copyright (C) 2020 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.patch;
+
+import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
+
+/**
+ * Thrown by the diff caches - the {@link GitModifiedFilesCache} and the {@link ModifiedFilesCache},
+ * if the implementations failed to retrieve the modified files between the 2 commits.
+ */
+public class DiffNotAvailableException extends Exception {
+  public DiffNotAvailableException(Throwable cause) {
+    super(cause);
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/DiffUtil.java b/java/com/google/gerrit/server/patch/DiffUtil.java
new file mode 100644
index 0000000..9198666
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffUtil.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2020 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.patch;
+
+import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
+import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * A utility class used by the diff cache interfaces {@link GitModifiedFilesCache} and {@link
+ * ModifiedFilesCache}.
+ */
+public class DiffUtil {
+
+  /**
+   * Returns the Git tree object ID pointed to by the commitId parameter.
+   *
+   * @param rw a {@link RevWalk} of an opened repository that is used to walk the commit graph.
+   * @param commitId 20 bytes commitId SHA-1 hash.
+   * @return Git tree object ID pointed to by the commitId.
+   */
+  public static ObjectId getTreeId(RevWalk rw, ObjectId commitId) throws IOException {
+    RevCommit current = rw.parseCommit(commitId);
+    return current.getTree().getId();
+  }
+
+  /**
+   * Returns the RevCommit object given the 20 bytes commitId SHA-1 hash.
+   *
+   * @param rw a {@link RevWalk} of an opened repository that is used to walk the commit graph.
+   * @param commitId 20 bytes commitId SHA-1 hash
+   * @return The RevCommit representing the commit in Git
+   * @throws IOException a pack file or loose object could not be read while parsing the commits.
+   */
+  public static RevCommit getRevCommit(RevWalk rw, ObjectId commitId) throws IOException {
+    return rw.parseCommit(commitId);
+  }
+
+  /**
+   * Returns true if the commitA and commitB parameters are parent/child, if they have a common
+   * parent, or if any of them is a root or merge commit.
+   */
+  public static boolean areRelated(RevCommit commitA, RevCommit commitB) {
+    return commitA == null
+        || isRootOrMergeCommit(commitA)
+        || isRootOrMergeCommit(commitB)
+        || areParentAndChild(commitA, commitB)
+        || haveCommonParent(commitA, commitB);
+  }
+
+  private static boolean isRootOrMergeCommit(RevCommit commit) {
+    return commit.getParentCount() != 1;
+  }
+
+  private static boolean areParentAndChild(RevCommit commitA, RevCommit commitB) {
+    return ObjectId.isEqual(commitA.getParent(0), commitB)
+        || ObjectId.isEqual(commitB.getParent(0), commitA);
+  }
+
+  private static boolean haveCommonParent(RevCommit commitA, RevCommit commitB) {
+    return ObjectId.isEqual(commitA.getParent(0), commitB.getParent(0));
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/EditTransformer.java b/java/com/google/gerrit/server/patch/EditTransformer.java
index 6288270..06e6f72 100644
--- a/java/com/google/gerrit/server/patch/EditTransformer.java
+++ b/java/com/google/gerrit/server/patch/EditTransformer.java
@@ -19,7 +19,6 @@
 import static com.google.common.collect.Multimaps.toMultimap;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.base.MoreObjects;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -31,12 +30,13 @@
 import com.google.gerrit.server.patch.GitPositionTransformer.Position;
 import com.google.gerrit.server.patch.GitPositionTransformer.PositionedEntity;
 import com.google.gerrit.server.patch.GitPositionTransformer.Range;
+import com.google.gerrit.server.patch.entities.Edit;
+import com.google.gerrit.server.patch.entities.FileEdits;
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.function.Function;
 import java.util.stream.Stream;
-import org.eclipse.jgit.diff.Edit;
 
 /**
  * Transformer of edits regarding their base trees. An edit describes a difference between {@code
@@ -54,40 +54,42 @@
 
   /**
    * Creates a new {@code EditTransformer} for the edits contained in the specified {@code
-   * PatchListEntry}s.
+   * FileEdits}s.
    *
-   * @param patchListEntries a list of {@code PatchListEntry}s containing the edits
+   * @param fileEdits a list of {@code FileEdits}s containing the edits
    */
-  public EditTransformer(List<PatchListEntry> patchListEntries) {
-    edits = patchListEntries.stream().flatMap(EditTransformer::toEdits).collect(toImmutableList());
+  public EditTransformer(List<FileEdits> fileEdits) {
+    // TODO(ghareeb): Can we replace FileEdits with another entity from the new refactored
+    // diff cache implementation? e.g. one of the GitFileDiffCache entities
+    edits = fileEdits.stream().flatMap(EditTransformer::toEdits).collect(toImmutableList());
   }
 
   /**
    * Transforms the references of side A of the edits. If the edits describe differences between
-   * {@code treeA} and {@code treeB} and the specified {@code PatchListEntry}s define a
-   * transformation from {@code treeA} to {@code treeA'}, the resulting edits will be defined as
-   * differences between {@code treeA'} and {@code treeB}. Edits which can't be transformed due to
-   * conflicts with the transformation are omitted.
+   * {@code treeA} and {@code treeB} and the specified {@code FileEdits}s define a transformation
+   * from {@code treeA} to {@code treeA'}, the resulting edits will be defined as differences
+   * between {@code treeA'} and {@code treeB}. Edits which can't be transformed due to conflicts
+   * with the transformation are omitted.
    *
-   * @param transformationEntries a list of {@code PatchListEntry}s defining the transformation of
-   *     {@code treeA} to {@code treeA'}
+   * @param transformingEntries a list of {@code FileEdits}s defining the transformation of {@code
+   *     treeA} to {@code treeA'}
    */
-  public void transformReferencesOfSideA(List<PatchListEntry> transformationEntries) {
-    transformEdits(transformationEntries, SideAStrategy.INSTANCE);
+  public void transformReferencesOfSideA(ImmutableList<FileEdits> transformingEntries) {
+    transformEdits(transformingEntries, SideAStrategy.INSTANCE);
   }
 
   /**
    * Transforms the references of side B of the edits. If the edits describe differences between
-   * {@code treeA} and {@code treeB} and the specified {@code PatchListEntry}s define a
-   * transformation from {@code treeB} to {@code treeB'}, the resulting edits will be defined as
-   * differences between {@code treeA} and {@code treeB'}. Edits which can't be transformed due to
-   * conflicts with the transformation are omitted.
+   * {@code treeA} and {@code treeB} and the specified {@code FileEdits}s define a transformation
+   * from {@code treeB} to {@code treeB'}, the resulting edits will be defined as differences
+   * between {@code treeA} and {@code treeB'}. Edits which can't be transformed due to conflicts
+   * with the transformation are omitted.
    *
-   * @param transformationEntries a list of {@code PatchListEntry}s defining the transformation of
+   * @param transformingEntries a list of {@code PatchListEntry}s defining the transformation of
    *     {@code treeB} to {@code treeB'}
    */
-  public void transformReferencesOfSideB(List<PatchListEntry> transformationEntries) {
-    transformEdits(transformationEntries, SideBStrategy.INSTANCE);
+  public void transformReferencesOfSideB(ImmutableList<FileEdits> transformingEntries) {
+    transformEdits(transformingEntries, SideBStrategy.INSTANCE);
   }
 
   /**
@@ -99,25 +101,33 @@
     return edits.stream()
         .collect(
             toMultimap(
-                ContextAwareEdit::getNewFilePath, Function.identity(), ArrayListMultimap::create));
+                c -> {
+                  String path =
+                      c.getNewFilePath().isPresent()
+                          ? c.getNewFilePath().get()
+                          : c.getOldFilePath().get();
+                  return path;
+                },
+                Function.identity(),
+                ArrayListMultimap::create));
   }
 
-  public static Stream<ContextAwareEdit> toEdits(PatchListEntry patchListEntry) {
-    ImmutableList<Edit> edits = patchListEntry.getEdits();
+  public static Stream<ContextAwareEdit> toEdits(FileEdits in) {
+    List<Edit> edits = in.edits();
     if (edits.isEmpty()) {
-      return Stream.of(ContextAwareEdit.createForNoContentEdit(patchListEntry));
+      return Stream.of(ContextAwareEdit.createForNoContentEdit(in.oldPath(), in.newPath()));
     }
 
-    return edits.stream().map(edit -> ContextAwareEdit.create(patchListEntry, edit));
+    return edits.stream().map(edit -> ContextAwareEdit.create(in.oldPath(), in.newPath(), edit));
   }
 
-  private void transformEdits(List<PatchListEntry> transformingEntries, SideStrategy sideStrategy) {
+  private void transformEdits(List<FileEdits> inputs, SideStrategy sideStrategy) {
     ImmutableList<PositionedEntity<ContextAwareEdit>> positionedEdits =
         edits.stream()
             .map(edit -> toPositionedEntity(edit, sideStrategy))
             .collect(toImmutableList());
     ImmutableSet<Mapping> mappings =
-        transformingEntries.stream().map(DiffMappings::toMapping).collect(toImmutableSet());
+        inputs.stream().map(DiffMappings::toMapping).collect(toImmutableSet());
 
     edits =
         positionTransformer.transform(positionedEdits, mappings).stream()
@@ -133,41 +143,41 @@
 
   @AutoValue
   abstract static class ContextAwareEdit {
-    static ContextAwareEdit create(PatchListEntry patchListEntry, Edit edit) {
+    static ContextAwareEdit create(Optional<String> oldPath, Optional<String> newPath, Edit edit) {
+      // TODO(ghareeb): Look if the new FileEdits class is capable of representing renames/copies
+      // and in this case we can get rid of the ContextAwareEdit class.
       return create(
-          patchListEntry.getOldName(),
-          patchListEntry.getNewName(),
-          edit.getBeginA(),
-          edit.getEndA(),
-          edit.getBeginB(),
-          edit.getEndB(),
-          false);
+          oldPath, newPath, edit.beginA(), edit.endA(), edit.beginB(), edit.endB(), false);
     }
 
-    static ContextAwareEdit createForNoContentEdit(PatchListEntry patchListEntry) {
+    static ContextAwareEdit createForNoContentEdit(
+        Optional<String> oldPath, Optional<String> newPath) {
       // Remove the warning in createEditAtNewPosition() if we switch to an empty range instead of
       // (-1:-1, -1:-1) in the future.
-      return create(
-          patchListEntry.getOldName(), patchListEntry.getNewName(), -1, -1, -1, -1, false);
+      return create(oldPath, newPath, -1, -1, -1, -1, false);
     }
 
     static ContextAwareEdit create(
-        String oldFilePath,
-        String newFilePath,
+        Optional<String> oldFilePath,
+        Optional<String> newFilePath,
         int beginA,
         int endA,
         int beginB,
         int endB,
         boolean filePathAdjusted) {
-      String adjustedOldFilePath = MoreObjects.firstNonNull(oldFilePath, newFilePath);
-      boolean implicitRename = !Objects.equals(oldFilePath, newFilePath) && filePathAdjusted;
+      Optional<String> adjustedFilePath = oldFilePath.isPresent() ? oldFilePath : newFilePath;
+      boolean implicitRename =
+          newFilePath.isPresent()
+              && oldFilePath.isPresent()
+              && !Objects.equals(oldFilePath.get(), newFilePath.get())
+              && filePathAdjusted;
       return new AutoValue_EditTransformer_ContextAwareEdit(
-          adjustedOldFilePath, newFilePath, beginA, endA, beginB, endB, implicitRename);
+          adjustedFilePath, newFilePath, beginA, endA, beginB, endB, implicitRename);
     }
 
-    public abstract String getOldFilePath();
+    public abstract Optional<String> getOldFilePath();
 
-    public abstract String getNewFilePath();
+    public abstract Optional<String> getNewFilePath();
 
     public abstract int getBeginA();
 
@@ -180,12 +190,13 @@
     // Used for equals(), for which this value is important.
     public abstract boolean isImplicitRename();
 
-    public Optional<Edit> toEdit() {
+    public Optional<org.eclipse.jgit.diff.Edit> toEdit() {
       if (getBeginA() < 0) {
         return Optional.empty();
       }
 
-      return Optional.of(new Edit(getBeginA(), getEndA(), getBeginB(), getEndB()));
+      return Optional.of(
+          new org.eclipse.jgit.diff.Edit(getBeginA(), getEndA(), getBeginB(), getEndB()));
     }
   }
 
@@ -200,8 +211,12 @@
 
     @Override
     public Position extractPosition(ContextAwareEdit edit) {
+      String filePath =
+          edit.getOldFilePath().isPresent()
+              ? edit.getOldFilePath().get()
+              : edit.getNewFilePath().get();
       return Position.builder()
-          .filePath(edit.getOldFilePath())
+          .filePath(filePath)
           .lineRange(Range.create(edit.getBeginA(), edit.getEndA()))
           .build();
     }
@@ -227,13 +242,13 @@
             newPosition);
       }
       return ContextAwareEdit.create(
-          updatedFilePath,
+          Optional.of(updatedFilePath),
           edit.getNewFilePath(),
           updatedRange.start(),
           updatedRange.end(),
           edit.getBeginB(),
           edit.getEndB(),
-          !Objects.equals(edit.getOldFilePath(), updatedFilePath));
+          !Objects.equals(edit.getOldFilePath(), Optional.of(updatedFilePath)));
     }
   }
 
@@ -242,8 +257,12 @@
 
     @Override
     public Position extractPosition(ContextAwareEdit edit) {
+      String filePath =
+          edit.getNewFilePath().isPresent()
+              ? edit.getNewFilePath().get()
+              : edit.getOldFilePath().get();
       return Position.builder()
-          .filePath(edit.getNewFilePath())
+          .filePath(filePath)
           .lineRange(Range.create(edit.getBeginB(), edit.getEndB()))
           .build();
     }
@@ -255,7 +274,8 @@
       // in the future.
       Range updatedRange = newPosition.lineRange().orElseGet(() -> Range.create(-1, -1));
       // Same as far the range above. PATCHSET_LEVEL is a safe fallback.
-      String updatedFilePath = newPosition.filePath().orElse(Patch.PATCHSET_LEVEL);
+      Optional<String> updatedFilePath =
+          Optional.of(newPosition.filePath().orElse(Patch.PATCHSET_LEVEL));
       return ContextAwareEdit.create(
           edit.getOldFilePath(),
           updatedFilePath,
diff --git a/java/com/google/gerrit/server/patch/GitPositionTransformer.java b/java/com/google/gerrit/server/patch/GitPositionTransformer.java
index d890bc2..f00f909 100644
--- a/java/com/google/gerrit/server/patch/GitPositionTransformer.java
+++ b/java/com/google/gerrit/server/patch/GitPositionTransformer.java
@@ -330,6 +330,11 @@
       return new AutoValue_GitPositionTransformer_FileMapping(
           Optional.of(oldPath), Optional.of(newPath));
     }
+
+    /** Creates a {@link FileMapping} using the old and new paths. */
+    public static FileMapping forFile(Optional<String> oldPath, Optional<String> newPath) {
+      return new AutoValue_GitPositionTransformer_FileMapping(oldPath, newPath);
+    }
   }
 
   /**
diff --git a/java/com/google/gerrit/server/patch/PatchListLoader.java b/java/com/google/gerrit/server/patch/PatchListLoader.java
index be0895b..df34aa6 100644
--- a/java/com/google/gerrit/server/patch/PatchListLoader.java
+++ b/java/com/google/gerrit/server/patch/PatchListLoader.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.patch.EditTransformer.ContextAwareEdit;
+import com.google.gerrit.server.patch.entities.FileEdits;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -306,13 +307,39 @@
         getRelevantPatchListEntries(
             parentDiffEntries, parentCommitA, parentCommitB, touchedFilePaths, df);
 
-    EditTransformer editTransformer = new EditTransformer(parentPatchListEntries);
-    editTransformer.transformReferencesOfSideA(oldPatches);
-    editTransformer.transformReferencesOfSideB(newPatches);
+    EditTransformer editTransformer = new EditTransformer(toFileEditsList(parentPatchListEntries));
+    editTransformer.transformReferencesOfSideA(toFileEditsList(oldPatches));
+    editTransformer.transformReferencesOfSideB(toFileEditsList(newPatches));
     return EditsDueToRebaseResult.create(
         relevantDiffEntries, editTransformer.getEditsPerFilePath());
   }
 
+  private ImmutableList<FileEdits> toFileEditsList(List<PatchListEntry> entries) {
+    return entries.stream().map(PatchListLoader::toFileEdits).collect(toImmutableList());
+  }
+
+  private static FileEdits toFileEdits(PatchListEntry patchListEntry) {
+    Optional<String> oldName = Optional.empty();
+    Optional<String> newName = Optional.empty();
+    switch (patchListEntry.getChangeType()) {
+      case DELETED:
+        oldName = Optional.of(patchListEntry.getNewName());
+        break;
+      case ADDED:
+      case MODIFIED:
+      case REWRITE:
+        newName = Optional.of(patchListEntry.getNewName());
+        break;
+
+      case COPIED:
+      case RENAMED:
+        oldName = Optional.of(patchListEntry.getOldName());
+        newName = Optional.of(patchListEntry.getNewName());
+        break;
+    }
+    return FileEdits.create(patchListEntry.getEdits(), oldName, newName);
+  }
+
   private static boolean isRootOrMergeCommit(RevCommit commit) {
     return commit.getParentCount() != 1;
   }
@@ -405,7 +432,7 @@
     PatchListEntry patchListEntry =
         newEntry(treeA, fileHeader, contentEditsDueToRebase, newSize, newSize - oldSize);
     // All edits in a file are due to rebase -> exclude the file from the diff.
-    if (EditTransformer.toEdits(patchListEntry).allMatch(editsDueToRebase::contains)) {
+    if (EditTransformer.toEdits(toFileEdits(patchListEntry)).allMatch(editsDueToRebase::contains)) {
       return Optional.empty();
     }
     return Optional.of(patchListEntry);
@@ -611,15 +638,16 @@
           rw.parseBody(r);
           return r;
         }
-      case 2:
+      default:
         if (key.getParentNum() != null) {
           RevCommit r = b.getParent(key.getParentNum() - 1);
           rw.parseBody(r);
           return r;
         }
-        return autoMerger.merge(repo, rw, ins, b, mergeStrategy);
-      default:
-        // TODO(sop) handle an octopus merge.
+        // Only support auto-merge for 2 parents, not octopus merges
+        if (b.getParentCount() == 2) {
+          return autoMerger.merge(repo, rw, ins, b, mergeStrategy);
+        }
         return null;
     }
   }
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 60a0688..02f46df 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -182,7 +182,6 @@
 
         ObjectId aId = getAId().orElse(null);
         ObjectId bId = getBId().orElse(null);
-        boolean changeEdit = false;
         if (bId == null) {
           // Change edit: create synthetic PatchSet corresponding to the edit.
           Optional<ChangeEdit> edit = editReader.byChange(notes);
@@ -190,7 +189,6 @@
             throw new NoSuchChangeException(notes.getChangeId());
           }
           bId = edit.get().getEditCommit();
-          changeEdit = true;
         }
 
         final PatchList list = listFor(keyFor(aId, bId, diffPrefs.ignoreWhitespace));
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java
new file mode 100644
index 0000000..bcae238
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java
@@ -0,0 +1,45 @@
+//  Copyright (C) 2020 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.patch.diff;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheImpl;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheKey;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+
+/**
+ * A cache for the list of Git modified files between 2 commits (patchsets) with extra Gerrit logic.
+ *
+ * <p>The loader uses the underlying {@link GitModifiedFilesCacheImpl} to retrieve the git modified
+ * files.
+ *
+ * <p>If the {@link ModifiedFilesCacheKey#aCommit()} is equal to {@link
+ * org.eclipse.jgit.lib.Constants#EMPTY_TREE_ID}, the diff will be evaluated against the empty tree,
+ * and the result will be exactly the same as the caller can get from {@link
+ * GitModifiedFilesCache#get(GitModifiedFilesCacheKey)}
+ */
+public interface ModifiedFilesCache {
+
+  /**
+   * @param key used to identify two git commits and contains other attributes to control the diff
+   *     calculation.
+   * @return the list of {@link ModifiedFile}s between the 2 git commits identified by the key.
+   * @throws DiffNotAvailableException the supplied commits IDs of the key do no exist, are not IDs
+   *     of a commit, or an exception occurred while reading a pack file.
+   */
+  ImmutableList<ModifiedFile> get(ModifiedFilesCacheKey key) throws DiffNotAvailableException;
+}
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
new file mode 100644
index 0000000..6023c0e
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
@@ -0,0 +1,206 @@
+//  Copyright (C) 2020 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.patch.diff;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Streams;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffUtil;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheImpl;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheKey;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * A cache for the list of Git modified files between 2 commits (patchsets) with extra Gerrit logic.
+ *
+ * <p>The loader of this cache wraps a {@link GitModifiedFilesCache} to retrieve the git modified
+ * files.
+ *
+ * <p>If the {@link ModifiedFilesCacheKey#aCommit()} is equal to {@link
+ * org.eclipse.jgit.lib.Constants#EMPTY_TREE_ID}, the diff will be evaluated against the empty tree,
+ * and the result will be exactly the same as the caller can get from {@link
+ * GitModifiedFilesCache#get(GitModifiedFilesCacheKey)}
+ */
+public class ModifiedFilesCacheImpl implements ModifiedFilesCache {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final String MODIFIED_FILES = "modified_files";
+
+  private final LoadingCache<ModifiedFilesCacheKey, ImmutableList<ModifiedFile>> cache;
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        bind(ModifiedFilesCache.class).to(ModifiedFilesCacheImpl.class);
+
+        // The documentation has some defaults and recommendations for setting the cache
+        // attributes:
+        // https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#cache.
+        // The cache is using the default disk limit as per section cache.<name>.diskLimit
+        // in the cache documentation link.
+        persist(
+                ModifiedFilesCacheImpl.MODIFIED_FILES,
+                ModifiedFilesCacheKey.class,
+                new TypeLiteral<ImmutableList<ModifiedFile>>() {})
+            .keySerializer(ModifiedFilesCacheKey.Serializer.INSTANCE)
+            .valueSerializer(GitModifiedFilesCacheImpl.ValueSerializer.INSTANCE)
+            .maximumWeight(10 << 20)
+            .weigher(ModifiedFilesWeigher.class)
+            .version(1)
+            .loader(ModifiedFilesLoader.class);
+      }
+    };
+  }
+
+  @Inject
+  public ModifiedFilesCacheImpl(
+      @Named(ModifiedFilesCacheImpl.MODIFIED_FILES)
+          LoadingCache<ModifiedFilesCacheKey, ImmutableList<ModifiedFile>> cache) {
+    this.cache = cache;
+  }
+
+  @Override
+  public ImmutableList<ModifiedFile> get(ModifiedFilesCacheKey key)
+      throws DiffNotAvailableException {
+    try {
+      return cache.get(key);
+    } catch (Exception e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  static class ModifiedFilesLoader
+      extends CacheLoader<ModifiedFilesCacheKey, ImmutableList<ModifiedFile>> {
+    private final GitModifiedFilesCache gitCache;
+    private final GitRepositoryManager repoManager;
+
+    @Inject
+    ModifiedFilesLoader(GitModifiedFilesCache gitCache, GitRepositoryManager repoManager) {
+      this.gitCache = gitCache;
+      this.repoManager = repoManager;
+    }
+
+    @Override
+    public ImmutableList<ModifiedFile> load(ModifiedFilesCacheKey key)
+        throws IOException, DiffNotAvailableException {
+      try (Repository repo = repoManager.openRepository(key.project());
+          RevWalk rw = new RevWalk(repo.newObjectReader())) {
+        return loadModifiedFiles(key, rw);
+      }
+    }
+
+    private ImmutableList<ModifiedFile> loadModifiedFiles(ModifiedFilesCacheKey key, RevWalk rw)
+        throws IOException, DiffNotAvailableException {
+      ObjectId aTree =
+          key.aCommit().equals(EMPTY_TREE_ID)
+              ? key.aCommit()
+              : DiffUtil.getTreeId(rw, key.aCommit());
+      ObjectId bTree = DiffUtil.getTreeId(rw, key.bCommit());
+      GitModifiedFilesCacheKey gitKey =
+          GitModifiedFilesCacheKey.builder()
+              .project(key.project())
+              .aTree(aTree)
+              .bTree(bTree)
+              .renameScore(key.renameScore())
+              .build();
+      List<ModifiedFile> modifiedFiles = gitCache.get(gitKey);
+      if (key.aCommit().equals(EMPTY_TREE_ID)) {
+        return ImmutableList.copyOf(modifiedFiles);
+      }
+      RevCommit revCommitA = DiffUtil.getRevCommit(rw, key.aCommit());
+      RevCommit revCommitB = DiffUtil.getRevCommit(rw, key.bCommit());
+      if (DiffUtil.areRelated(revCommitA, revCommitB)) {
+        return ImmutableList.copyOf(modifiedFiles);
+      }
+      Set<String> touchedFiles =
+          getTouchedFilesWithParents(
+              key, revCommitA.getParent(0).getId(), revCommitB.getParent(0).getId(), rw);
+      return modifiedFiles.stream()
+          .filter(f -> isTouched(touchedFiles, f))
+          .collect(toImmutableList());
+    }
+
+    /**
+     * Returns the paths of files that were modified between the old and new commits versus their
+     * parents (i.e. old commit vs. its parent, and new commit vs. its parent).
+     *
+     * @param key the {@link ModifiedFilesCacheKey} representing the commits we are diffing
+     * @param rw a {@link RevWalk} for the repository
+     * @return The list of modified files between the old/new commits and their parents
+     */
+    private Set<String> getTouchedFilesWithParents(
+        ModifiedFilesCacheKey key, ObjectId parentOfA, ObjectId parentOfB, RevWalk rw)
+        throws IOException {
+      try {
+        // TODO(ghareeb): as an enhancement: the 3 calls of the underlying git cache can be combined
+        GitModifiedFilesCacheKey oldVsBaseKey =
+            GitModifiedFilesCacheKey.create(
+                key.project(), parentOfA, key.aCommit(), key.renameScore(), rw);
+        List<ModifiedFile> oldVsBase = gitCache.get(oldVsBaseKey);
+
+        GitModifiedFilesCacheKey newVsBaseKey =
+            GitModifiedFilesCacheKey.create(
+                key.project(), parentOfB, key.bCommit(), key.renameScore(), rw);
+        List<ModifiedFile> newVsBase = gitCache.get(newVsBaseKey);
+
+        return Sets.union(getOldAndNewPaths(oldVsBase), getOldAndNewPaths(newVsBase));
+      } catch (DiffNotAvailableException e) {
+        logger.atWarning().log(
+            "Failed to retrieve the touched files' commits (%s, %s) and parents (%s, %s): %s",
+            key.aCommit(), key.bCommit(), parentOfA, parentOfB, e.getMessage());
+        return ImmutableSet.of();
+      }
+    }
+
+    private ImmutableSet<String> getOldAndNewPaths(List<ModifiedFile> files) {
+      return files.stream()
+          .flatMap(
+              file -> Stream.concat(Streams.stream(file.oldPath()), Streams.stream(file.newPath())))
+          .collect(ImmutableSet.toImmutableSet());
+    }
+
+    private static boolean isTouched(Set<String> touchedFilePaths, ModifiedFile modifiedFile) {
+      String oldFilePath = modifiedFile.oldPath().orElse(null);
+      String newFilePath = modifiedFile.newPath().orElse(null);
+      // One of the above file paths could be /dev/null but we need not explicitly check for this
+      // value as the set of file paths shouldn't contain it.
+      return touchedFilePaths.contains(oldFilePath) || touchedFilePaths.contains(newFilePath);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheKey.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheKey.java
new file mode 100644
index 0000000..5aa31ec
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheKey.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2020 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.patch.diff;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache.ModifiedFilesKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Cache key for the {@link com.google.gerrit.server.patch.diff.ModifiedFilesCache} */
+@AutoValue
+public abstract class ModifiedFilesCacheKey {
+
+  /** A specific git project / repository. */
+  public abstract Project.NameKey project();
+
+  /** @return the old commit ID used in the git tree diff */
+  public abstract ObjectId aCommit();
+
+  /** @return the new commit ID used in the git tree diff */
+  public abstract ObjectId bCommit();
+
+  /**
+   * Percentage score used to identify a file as a "rename". A special value of -1 means that the
+   * computation will ignore renames and rename detection will be disabled.
+   */
+  public abstract int renameScore();
+
+  public boolean renameDetectionEnabled() {
+    return renameScore() != -1;
+  }
+
+  /** Returns the size of the object in bytes */
+  public int weight() {
+    return stringSize(project().get()) // project
+        + 20 * 2 // aCommit and bCommit
+        + 4; // renameScore
+  }
+
+  public static ModifiedFilesCacheKey.Builder builder() {
+    return new AutoValue_ModifiedFilesCacheKey.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract ModifiedFilesCacheKey.Builder project(NameKey value);
+
+    public abstract ModifiedFilesCacheKey.Builder aCommit(ObjectId value);
+
+    public abstract ModifiedFilesCacheKey.Builder bCommit(ObjectId value);
+
+    public ModifiedFilesCacheKey.Builder disableRenameDetection() {
+      renameScore(-1);
+      return this;
+    }
+
+    public abstract ModifiedFilesCacheKey.Builder renameScore(int value);
+
+    public abstract ModifiedFilesCacheKey build();
+  }
+
+  public enum Serializer implements CacheSerializer<ModifiedFilesCacheKey> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(ModifiedFilesCacheKey key) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return Protos.toByteArray(
+          ModifiedFilesKeyProto.newBuilder()
+              .setProject(key.project().get())
+              .setACommit(idConverter.toByteString(key.aCommit()))
+              .setBCommit(idConverter.toByteString(key.bCommit()))
+              .setRenameScore(key.renameScore())
+              .build());
+    }
+
+    @Override
+    public ModifiedFilesCacheKey deserialize(byte[] in) {
+      ModifiedFilesKeyProto proto = Protos.parseUnchecked(ModifiedFilesKeyProto.parser(), in);
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return ModifiedFilesCacheKey.builder()
+          .project(NameKey.parse(proto.getProject()))
+          .aCommit(idConverter.fromByteString(proto.getACommit()))
+          .bCommit(idConverter.fromByteString(proto.getBCommit()))
+          .renameScore(proto.getRenameScore())
+          .build();
+    }
+  }
+
+  private static int stringSize(String str) {
+    if (str != null) {
+      // each character in the string occupies 2 bytes. Ignoring the fixed overhead for the string
+      // (length, offset and hash code) since they are negligible and do not
+      // affect the comparison of 2 strings
+      return str.length() * 2;
+    }
+    return 0;
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java
new file mode 100644
index 0000000..512da6f
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java
@@ -0,0 +1,31 @@
+//  Copyright (C) 2020 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.patch.diff;
+
+import com.google.common.cache.Weigher;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+
+public class ModifiedFilesWeigher
+    implements Weigher<ModifiedFilesCacheKey, ImmutableList<ModifiedFile>> {
+  @Override
+  public int weigh(ModifiedFilesCacheKey key, ImmutableList<ModifiedFile> modifiedFiles) {
+    int weight = key.weight();
+    for (ModifiedFile modifiedFile : modifiedFiles) {
+      weight += modifiedFile.weight();
+    }
+    return weight;
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/entities/Edit.java b/java/com/google/gerrit/server/patch/entities/Edit.java
new file mode 100644
index 0000000..683bbec
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/entities/Edit.java
@@ -0,0 +1,46 @@
+//  Copyright (C) 2020 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.patch.entities;
+
+import com.google.auto.value.AutoValue;
+
+/**
+ * A modified region between 2 versions of the same content. This is the Gerrit entity class
+ * corresponding to {@link org.eclipse.jgit.diff.Edit} and is needed to ensure immutability when
+ * included as fields of the diff persisted caches.
+ */
+@AutoValue
+public abstract class Edit {
+  static Edit create(int beginA, int endA, int beginB, int endB) {
+    return new AutoValue_Edit(beginA, endA, beginB, endB);
+  }
+
+  public static Edit fromJGitEdit(org.eclipse.jgit.diff.Edit jgitEdit) {
+    return create(
+        jgitEdit.getBeginA(), jgitEdit.getEndA(), jgitEdit.getBeginB(), jgitEdit.getEndB());
+  }
+
+  /** Start of a region in sequence A. */
+  public abstract int beginA();
+
+  /** End of a region in sequence A. */
+  public abstract int endA();
+
+  /** Start of a region in sequence B. */
+  public abstract int beginB();
+
+  /** End of a region in sequence B. */
+  public abstract int endB();
+}
diff --git a/java/com/google/gerrit/server/patch/entities/FileEdits.java b/java/com/google/gerrit/server/patch/entities/FileEdits.java
new file mode 100644
index 0000000..c914919
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/entities/FileEdits.java
@@ -0,0 +1,44 @@
+//  Copyright (C) 2020 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.patch.entities;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * An entity class containing the list of edits between 2 commits for a file, and the old and new
+ * paths.
+ */
+@AutoValue
+public abstract class FileEdits {
+  public static FileEdits create(
+      List<org.eclipse.jgit.diff.Edit> jgitEdits,
+      Optional<String> oldPath,
+      Optional<String> newPath) {
+    ImmutableList<Edit> edits =
+        jgitEdits.stream().map(Edit::fromJGitEdit).collect(toImmutableList());
+    return new AutoValue_FileEdits(edits, oldPath, newPath);
+  }
+
+  public abstract ImmutableList<Edit> edits();
+
+  public abstract Optional<String> oldPath();
+
+  public abstract Optional<String> newPath();
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java
new file mode 100644
index 0000000..d178f22
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java
@@ -0,0 +1,40 @@
+//  Copyright (C) 2020 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.patch.gitdiff;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
+
+/**
+ * A cache interface for identifying the list of Git modified files between 2 different git trees.
+ * This cache does not read the actual file contents, nor does it include the edits (modified
+ * regions) of the file.
+ *
+ * <p>The other {@link ModifiedFilesCache} is similar to this cache, and includes other extra Gerrit
+ * logic that we need to add with the list of modified files.
+ */
+public interface GitModifiedFilesCache {
+
+  /**
+   * Computes the list of of {@link ModifiedFile}s between the 2 git trees.
+   *
+   * @param key used to identify two git trees and contains other attributes to control the diff
+   *     calculation.
+   * @return the list of {@link ModifiedFile}s between the 2 git trees identified by the key.
+   * @throws DiffNotAvailableException trees cannot be read or file contents cannot be read.
+   */
+  ImmutableList<ModifiedFile> get(GitModifiedFilesCacheKey key) throws DiffNotAvailableException;
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
new file mode 100644
index 0000000..b3b82bb
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
@@ -0,0 +1,177 @@
+//  Copyright (C) 2020 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.patch.gitdiff;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.proto.Cache.ModifiedFilesProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffEntry.ChangeType;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.util.io.DisabledOutputStream;
+
+/** Implementation of the {@link GitModifiedFilesCache} */
+public class GitModifiedFilesCacheImpl implements GitModifiedFilesCache {
+  private static final String GIT_MODIFIED_FILES = "git_modified_files";
+  private static final ImmutableMap<ChangeType, Patch.ChangeType> changeTypeMap =
+      ImmutableMap.of(
+          DiffEntry.ChangeType.ADD,
+          Patch.ChangeType.ADDED,
+          DiffEntry.ChangeType.MODIFY,
+          Patch.ChangeType.MODIFIED,
+          DiffEntry.ChangeType.DELETE,
+          Patch.ChangeType.DELETED,
+          DiffEntry.ChangeType.RENAME,
+          Patch.ChangeType.RENAMED,
+          DiffEntry.ChangeType.COPY,
+          Patch.ChangeType.COPIED);
+
+  private LoadingCache<GitModifiedFilesCacheKey, ImmutableList<ModifiedFile>> cache;
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        bind(GitModifiedFilesCache.class).to(GitModifiedFilesCacheImpl.class);
+
+        persist(
+                GIT_MODIFIED_FILES,
+                GitModifiedFilesCacheKey.class,
+                new TypeLiteral<ImmutableList<ModifiedFile>>() {})
+            .keySerializer(GitModifiedFilesCacheKey.Serializer.INSTANCE)
+            .valueSerializer(ValueSerializer.INSTANCE)
+            // The documentation has some defaults and recommendations for setting the cache
+            // attributes:
+            // https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#cache.
+            .maximumWeight(10 << 20)
+            .weigher(GitModifiedFilesWeigher.class)
+            // The cache is using the default disk limit as per section cache.<name>.diskLimit
+            // in the cache documentation link.
+            .version(1)
+            .loader(GitModifiedFilesCacheImpl.Loader.class);
+      }
+    };
+  }
+
+  @Inject
+  public GitModifiedFilesCacheImpl(
+      @Named(GIT_MODIFIED_FILES)
+          LoadingCache<GitModifiedFilesCacheKey, ImmutableList<ModifiedFile>> cache) {
+    this.cache = cache;
+  }
+
+  @Override
+  public ImmutableList<ModifiedFile> get(GitModifiedFilesCacheKey key)
+      throws DiffNotAvailableException {
+    try {
+      return cache.get(key);
+    } catch (ExecutionException e) {
+      throw new DiffNotAvailableException(e);
+    }
+  }
+
+  static class Loader extends CacheLoader<GitModifiedFilesCacheKey, ImmutableList<ModifiedFile>> {
+    private final GitRepositoryManager repoManager;
+
+    @Inject
+    Loader(GitRepositoryManager repoManager) {
+      this.repoManager = repoManager;
+    }
+
+    @Override
+    public ImmutableList<ModifiedFile> load(GitModifiedFilesCacheKey key) throws IOException {
+      try (Repository repo = repoManager.openRepository(key.project());
+          ObjectReader reader = repo.newObjectReader()) {
+        List<DiffEntry> entries = getGitTreeDiff(repo, reader, key);
+
+        return entries.stream().map(Loader::toModifiedFile).collect(toImmutableList());
+      }
+    }
+
+    private List<DiffEntry> getGitTreeDiff(
+        Repository repo, ObjectReader reader, GitModifiedFilesCacheKey key) throws IOException {
+      try (DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
+        df.setReader(reader, repo.getConfig());
+        if (key.renameDetection()) {
+          df.setDetectRenames(true);
+          df.getRenameDetector().setRenameScore(key.renameScore());
+        }
+        // The scan method only returns the file paths that are different. Callers may choose to
+        // format these paths themselves.
+        return df.scan(key.aTree(), key.bTree());
+      }
+    }
+
+    private static ModifiedFile toModifiedFile(DiffEntry entry) {
+      String oldPath = entry.getOldPath();
+      String newPath = entry.getNewPath();
+      return ModifiedFile.builder()
+          .changeType(toChangeType(entry.getChangeType()))
+          .oldPath(oldPath.equals(DiffEntry.DEV_NULL) ? Optional.empty() : Optional.of(oldPath))
+          .newPath(newPath.equals(DiffEntry.DEV_NULL) ? Optional.empty() : Optional.of(newPath))
+          .build();
+    }
+
+    private static Patch.ChangeType toChangeType(DiffEntry.ChangeType changeType) {
+      if (!changeTypeMap.containsKey(changeType)) {
+        throw new IllegalArgumentException("Unsupported type " + changeType);
+      }
+      return changeTypeMap.get(changeType);
+    }
+  }
+
+  public enum ValueSerializer implements CacheSerializer<ImmutableList<ModifiedFile>> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(ImmutableList<ModifiedFile> modifiedFiles) {
+      ModifiedFilesProto.Builder builder = ModifiedFilesProto.newBuilder();
+      modifiedFiles.forEach(
+          f -> builder.addModifiedFile(ModifiedFile.Serializer.INSTANCE.toProto(f)));
+      return Protos.toByteArray(builder.build());
+    }
+
+    @Override
+    public ImmutableList<ModifiedFile> deserialize(byte[] in) {
+      ImmutableList.Builder<ModifiedFile> modifiedFiles = ImmutableList.builder();
+      ModifiedFilesProto modifiedFilesProto =
+          Protos.parseUnchecked(ModifiedFilesProto.parser(), in);
+      modifiedFilesProto
+          .getModifiedFileList()
+          .forEach(f -> modifiedFiles.add(ModifiedFile.Serializer.INSTANCE.fromProto(f)));
+      return modifiedFiles.build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheKey.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheKey.java
new file mode 100644
index 0000000..f94f2c9
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheKey.java
@@ -0,0 +1,137 @@
+// Copyright (C) 2020 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.patch.gitdiff;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache.GitModifiedFilesKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.patch.DiffUtil;
+import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Cache key for the {@link GitModifiedFilesCache}. */
+@AutoValue
+public abstract class GitModifiedFilesCacheKey {
+
+  /** A specific git project / repository. */
+  public abstract Project.NameKey project();
+
+  /**
+   * The git SHA-1 {@link ObjectId} of the first git tree object for which the diff should be
+   * computed.
+   */
+  public abstract ObjectId aTree();
+
+  /**
+   * The git SHA-1 {@link ObjectId} of the second git tree object for which the diff should be
+   * computed.
+   */
+  public abstract ObjectId bTree();
+
+  /**
+   * Percentage score used to identify a file as a rename. This value is only available if {@link
+   * #renameDetection()} is true. Otherwise, this method will return -1.
+   *
+   * <p>This value will be used to set the rename score of {@link
+   * org.eclipse.jgit.diff.DiffFormatter#getRenameDetector()}.
+   */
+  public abstract int renameScore();
+
+  /** Returns true if rename detection was set for this key. */
+  public boolean renameDetection() {
+    return renameScore() != -1;
+  }
+
+  public static GitModifiedFilesCacheKey create(
+      Project.NameKey project, ObjectId aCommit, ObjectId bCommit, int renameScore, RevWalk rw)
+      throws IOException {
+    ObjectId aTree = DiffUtil.getTreeId(rw, aCommit);
+    ObjectId bTree = DiffUtil.getTreeId(rw, bCommit);
+    return builder().project(project).aTree(aTree).bTree(bTree).renameScore(renameScore).build();
+  }
+
+  public static Builder builder() {
+    return new AutoValue_GitModifiedFilesCacheKey.Builder();
+  }
+
+  /** Returns the size of the object in bytes */
+  public int weight() {
+    return stringSize(project().get())
+        + 20 * 2 // old and new tree IDs
+        + 4; // rename score
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder project(NameKey value);
+
+    public abstract Builder aTree(ObjectId value);
+
+    public abstract Builder bTree(ObjectId value);
+
+    public abstract Builder renameScore(int value);
+
+    public Builder disableRenameDetection() {
+      renameScore(-1);
+      return this;
+    }
+
+    public abstract GitModifiedFilesCacheKey build();
+  }
+
+  public enum Serializer implements CacheSerializer<GitModifiedFilesCacheKey> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(GitModifiedFilesCacheKey key) {
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return Protos.toByteArray(
+          GitModifiedFilesKeyProto.newBuilder()
+              .setProject(key.project().get())
+              .setATree(idConverter.toByteString(key.aTree()))
+              .setBTree(idConverter.toByteString(key.bTree()))
+              .setRenameScore(key.renameScore())
+              .build());
+    }
+
+    @Override
+    public GitModifiedFilesCacheKey deserialize(byte[] in) {
+      GitModifiedFilesKeyProto proto = Protos.parseUnchecked(GitModifiedFilesKeyProto.parser(), in);
+      ObjectIdConverter idConverter = ObjectIdConverter.create();
+      return GitModifiedFilesCacheKey.builder()
+          .project(NameKey.parse(proto.getProject()))
+          .aTree(idConverter.fromByteString(proto.getATree()))
+          .bTree(idConverter.fromByteString(proto.getBTree()))
+          .renameScore(proto.getRenameScore())
+          .build();
+    }
+  }
+
+  private static int stringSize(String str) {
+    if (str != null) {
+      // each character in the string occupies 2 bytes. Ignoring the fixed overhead for the string
+      // (length, offset and hash code) since they are negligible and do not
+      // affect the comparison of 2 strings
+      return str.length() * 2;
+    }
+    return 0;
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java
new file mode 100644
index 0000000..a678379
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java
@@ -0,0 +1,26 @@
+//  Copyright (C) 2020 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.patch.gitdiff;
+
+import com.google.common.cache.Weigher;
+import com.google.common.collect.ImmutableList;
+
+public class GitModifiedFilesWeigher
+    implements Weigher<GitModifiedFilesCacheKey, ImmutableList<ModifiedFile>> {
+  @Override
+  public int weigh(GitModifiedFilesCacheKey key, ImmutableList<ModifiedFile> modifiedFiles) {
+    return key.weight() + modifiedFiles.stream().mapToInt(ModifiedFile::weight).sum();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
new file mode 100644
index 0000000..800bd41
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
@@ -0,0 +1,123 @@
+//  Copyright (C) 2020 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.patch.gitdiff;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache.ModifiedFileProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.protobuf.Descriptors.FieldDescriptor;
+import java.util.Optional;
+
+/**
+ * An entity representing a Modified file due to a diff between 2 git trees. This entity contains
+ * the change type and the old & new paths, but does not include any actual content diff of the
+ * file.
+ */
+@AutoValue
+public abstract class ModifiedFile {
+  /**
+   * Returns the change type (i.e. add, delete, modify, rename, etc...) associated with this
+   * modified file.
+   */
+  public abstract ChangeType changeType();
+
+  /**
+   * Returns the old name associated with this file. An empty optional is returned if {@link
+   * #changeType()} is equal to {@link ChangeType#ADDED}.
+   */
+  public abstract Optional<String> oldPath();
+
+  /**
+   * Returns the new name associated with this file. An empty optional is returned if {@link
+   * #changeType()} is equal to {@link ChangeType#DELETED}
+   */
+  public abstract Optional<String> newPath();
+
+  public static Builder builder() {
+    return new AutoValue_ModifiedFile.Builder();
+  }
+
+  /** Computes this object's weight, which is its size in bytes. */
+  public int weight() {
+    int weight = 1; // the changeType field
+    if (oldPath().isPresent()) {
+      weight += oldPath().get().length();
+    }
+    if (newPath().isPresent()) {
+      weight += newPath().get().length();
+    }
+    return weight;
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder changeType(ChangeType value);
+
+    public abstract Builder oldPath(Optional<String> value);
+
+    public abstract Builder newPath(Optional<String> value);
+
+    public abstract ModifiedFile build();
+  }
+
+  enum Serializer implements CacheSerializer<ModifiedFile> {
+    INSTANCE;
+
+    private static final FieldDescriptor oldPathDescriptor =
+        ModifiedFileProto.getDescriptor().findFieldByName("old_path");
+
+    private static final FieldDescriptor newPathDescriptor =
+        ModifiedFileProto.getDescriptor().findFieldByName("new_path");
+
+    @Override
+    public byte[] serialize(ModifiedFile modifiedFile) {
+      return Protos.toByteArray(toProto(modifiedFile));
+    }
+
+    public ModifiedFileProto toProto(ModifiedFile modifiedFile) {
+      ModifiedFileProto.Builder builder = ModifiedFileProto.newBuilder();
+      builder.setChangeType(modifiedFile.changeType().toString());
+      if (modifiedFile.oldPath().isPresent()) {
+        builder.setOldPath(modifiedFile.oldPath().get());
+      }
+      if (modifiedFile.newPath().isPresent()) {
+        builder.setNewPath(modifiedFile.newPath().get());
+      }
+      return builder.build();
+    }
+
+    @Override
+    public ModifiedFile deserialize(byte[] in) {
+      ModifiedFileProto modifiedFileProto = Protos.parseUnchecked(ModifiedFileProto.parser(), in);
+      return fromProto(modifiedFileProto);
+    }
+
+    public ModifiedFile fromProto(ModifiedFileProto modifiedFileProto) {
+      ModifiedFile.Builder builder = ModifiedFile.builder();
+      builder.changeType(ChangeType.valueOf(modifiedFileProto.getChangeType()));
+
+      if (modifiedFileProto.hasField(oldPathDescriptor)) {
+        builder.oldPath(Optional.of(modifiedFileProto.getOldPath()));
+      }
+      if (modifiedFileProto.hasField(newPathDescriptor)) {
+        builder.newPath(Optional.of(modifiedFileProto.getNewPath()));
+      }
+      return builder.build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index 0b4828b..37c773a 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -61,11 +61,12 @@
   }
 
   /** Can this user see this change? */
-  private boolean isVisible(ChangeData cd) {
-    if (getChange().isPrivate() && !isPrivateVisible(cd)) {
+  boolean isVisible() {
+    if (getChange().isPrivate() && !isPrivateVisible(changeData)) {
       return false;
     }
-    return refControl.isVisible();
+    // Does the user have READ permission on the destination?
+    return refControl.asForRef().testOrFalse(RefPermission.READ);
   }
 
   /** Can this user abandon this change? */
@@ -201,17 +202,13 @@
 
     private ForChangeImpl() {}
 
-    private ChangeData changeData() {
-      return changeData;
-    }
-
     @Override
     public String resourcePath() {
       if (resourcePath == null) {
         resourcePath =
             String.format(
                 "/projects/%s/+changes/%s",
-                getProjectControl().getProjectState().getName(), changeData().getId().get());
+                getProjectControl().getProjectState().getName(), changeData.getId().get());
       }
       return resourcePath;
     }
@@ -256,7 +253,7 @@
       try {
         switch (perm) {
           case READ:
-            return isVisible(changeData());
+            return isVisible();
           case ABANDON:
             return canAbandon();
           case DELETE:
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index 1b029b1..41db9ee 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PeerDaemonUser;
 import com.google.gerrit.server.account.CapabilityCollection;
+import com.google.gerrit.server.cache.PerThreadCache;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
@@ -48,8 +49,6 @@
 public class DefaultPermissionBackend extends PermissionBackend {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final CurrentUser.PropertyKey<Boolean> IS_ADMIN = CurrentUser.PropertyKey.create();
-
   private final Provider<CurrentUser> currentUser;
   private final ProjectCache projectCache;
   private final ProjectControl.Factory projectControlFactory;
@@ -83,8 +82,15 @@
 
   @Override
   public WithUser absentUser(Account.Id id) {
-    IdentifiedUser identifiedUser = identifiedUserFactory.create(requireNonNull(id, "user"));
-    return new WithUserImpl(identifiedUser);
+    requireNonNull(id, "user");
+    Optional<Account.Id> user = getAccountIdOfIdentifiedUser();
+    if (user.isPresent() && id.equals(user.get())) {
+      // What looked liked an absent user is actually the current caller. Use the per-request
+      // singleton IdentifiedUser instead of constructing a new object to leverage caching in member
+      // variables of IdentifiedUser.
+      return new WithUserImpl(currentUser.get().asIdentifiedUser());
+    }
+    return new WithUserImpl(identifiedUserFactory.create(requireNonNull(id, "user")));
   }
 
   @Override
@@ -92,6 +98,21 @@
     return true;
   }
 
+  /**
+   * Returns the {@link Account.Id} of the current user if a user is signed in. Catches exceptions
+   * so that background jobs don't get impacted.
+   */
+  private Optional<Account.Id> getAccountIdOfIdentifiedUser() {
+    try {
+      return currentUser.get().isIdentifiedUser()
+          ? Optional.of(currentUser.get().getAccountId())
+          : Optional.empty();
+    } catch (Exception e) {
+      logger.atFine().withCause(e).log("Unable to get current user");
+      return Optional.empty();
+    }
+  }
+
   class WithUserImpl extends WithUser {
     private final CurrentUser user;
     private Boolean admin;
@@ -104,7 +125,11 @@
     public ForProject project(Project.NameKey project) {
       try {
         ProjectState state = projectCache.get(project).orElseThrow(illegalState(project));
-        return projectControlFactory.create(user, state).asForProject();
+        ProjectControl control =
+            PerThreadCache.getOrCompute(
+                PerThreadCache.Key.create(ProjectControl.class, project, user.getCacheKey()),
+                () -> projectControlFactory.create(user, state));
+        return control.asForProject();
       } catch (Exception e) {
         Throwable cause = e.getCause() != null ? e.getCause() : e;
         return FailedPermissionBackend.project(
@@ -197,21 +222,13 @@
     }
 
     private Boolean computeAdmin() {
-      Optional<Boolean> r = user.get(IS_ADMIN);
-      if (r.isPresent()) {
-        return r.get();
-      }
-
-      boolean isAdmin;
       if (user.isImpersonating()) {
-        isAdmin = false;
-      } else if (user instanceof PeerDaemonUser) {
-        isAdmin = true;
-      } else {
-        isAdmin = allow(capabilities().administrateServer);
+        return false;
       }
-      user.put(IS_ADMIN, isAdmin);
-      return isAdmin;
+      if (user instanceof PeerDaemonUser) {
+        return true;
+      }
+      return allow(capabilities().administrateServer);
     }
 
     private boolean canEmailReviewers() {
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index 37de0d1..edd3cb1 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -16,10 +16,8 @@
 
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.entities.RefNames.REFS_CACHE_AUTOMERGE;
+import static com.google.common.flogger.LazyArgs.lazy;
 import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
-import static com.google.gerrit.entities.RefNames.REFS_USERS_SELF;
-import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toCollection;
 
 import com.google.auto.value.AutoValue;
@@ -29,8 +27,6 @@
 import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
@@ -41,13 +37,10 @@
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.TagMatcher;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -79,8 +72,8 @@
   private final TagCache tagCache;
   private final ChangeNotes.Factory changeNotesFactory;
   @Nullable private final SearchingChangeCacheImpl changeCache;
-  private final GroupCache groupCache;
   private final PermissionBackend permissionBackend;
+  private final RefVisibilityControl refVisibilityControl;
   private final ProjectControl projectControl;
   private final CurrentUser user;
   private final ProjectState projectState;
@@ -96,16 +89,16 @@
       TagCache tagCache,
       ChangeNotes.Factory changeNotesFactory,
       @Nullable SearchingChangeCacheImpl changeCache,
-      GroupCache groupCache,
       PermissionBackend permissionBackend,
+      RefVisibilityControl refVisibilityControl,
       @GerritServerConfig Config config,
       MetricMaker metricMaker,
       @Assisted ProjectControl projectControl) {
     this.tagCache = tagCache;
     this.changeNotesFactory = changeNotesFactory;
     this.changeCache = changeCache;
-    this.groupCache = groupCache;
     this.permissionBackend = permissionBackend;
+    this.refVisibilityControl = refVisibilityControl;
     this.skipFullRefEvaluationIfAllRefsAreVisible =
         config.getBoolean("auth", "skipFullRefEvaluationIfAllRefsAreVisible", true);
     this.projectControl = projectControl;
@@ -134,7 +127,7 @@
         "Filter refs for repository %s by visibility (options = %s, refs = %s)",
         projectState.getNameKey(), opts, refs);
     logger.atFinest().log("Calling user: %s", user.getLoggableName());
-    logger.atFinest().log("Groups: %s", user.getEffectiveGroups().getKnownGroups());
+    logger.atFinest().log("Groups: %s", lazy(() -> user.getEffectiveGroups().getKnownGroups()));
     logger.atFinest().log(
         "auth.skipFullRefEvaluationIfAllRefsAreVisible = %s",
         skipFullRefEvaluationIfAllRefsAreVisible);
@@ -226,131 +219,56 @@
     logger.atFinest().log("Doing full ref filtering");
     fullFilterCount.increment();
 
-    boolean viewMetadata;
-    boolean isAdmin;
-    Account.Id userId;
-    IdentifiedUser identifiedUser;
-    PermissionBackend.WithUser withUser = permissionBackend.user(user);
-    if (user.isIdentifiedUser()) {
-      viewMetadata = withUser.testOrFalse(GlobalPermission.ACCESS_DATABASE);
-      isAdmin = withUser.testOrFalse(GlobalPermission.ADMINISTRATE_SERVER);
-      identifiedUser = user.asIdentifiedUser();
-      userId = identifiedUser.getAccountId();
-      logger.atFinest().log(
-          "Account = %d; can view metadata = %s; is admin = %s",
-          userId.get(), viewMetadata, isAdmin);
-    } else {
-      logger.atFinest().log("User is anonymous");
-      viewMetadata = false;
-      isAdmin = false;
-      userId = null;
-      identifiedUser = null;
-    }
-
+    boolean hasAccessDatabase =
+        permissionBackend
+            .user(projectControl.getUser())
+            .testOrFalse(GlobalPermission.ACCESS_DATABASE);
     List<Ref> resultRefs = new ArrayList<>(refs.size());
     List<Ref> deferredTags = new ArrayList<>();
     for (Ref ref : refs) {
-      String name = ref.getName();
+      String refName = ref.getName();
       Change.Id changeId;
-      Account.Id accountId;
-      AccountGroup.UUID accountGroupUuid;
-      if (name.startsWith(REFS_CACHE_AUTOMERGE)) {
-        continue;
-      } else if (opts.filterMeta() && isMetadata(name)) {
-        logger.atFinest().log("Filter out metadata ref %s", name);
-        continue;
-      } else if (RefNames.isRefsEdit(name)) {
-        // Edits are visible only to the owning user, if change is visible.
-        if (viewMetadata || visibleEdit(repo, name)) {
-          logger.atFinest().log("Include edit ref %s", name);
-          resultRefs.add(ref);
-        } else {
-          logger.atFinest().log("Filter out edit ref %s", name);
-        }
-      } else if ((changeId = Change.Id.fromRef(name)) != null) {
-        // Change ref is visible only if the change is visible.
-        if (viewMetadata || visible(repo, changeId)) {
-          logger.atFinest().log("Include change ref %s", name);
-          resultRefs.add(ref);
-        } else {
-          logger.atFinest().log("Filter out change ref %s", name);
-        }
-      } else if ((accountId = Account.Id.fromRef(name)) != null) {
-        // Account ref is visible only to the corresponding account.
-        if (viewMetadata || (accountId.equals(userId) && canReadRef(name))) {
-          logger.atFinest().log("Include user ref %s", name);
-          resultRefs.add(ref);
-        } else {
-          logger.atFinest().log("Filter out user ref %s", name);
-        }
-      } else if ((accountGroupUuid = AccountGroup.UUID.fromRef(name)) != null) {
-        // Group ref is visible only to the corresponding owner group.
-        InternalGroup group = groupCache.get(accountGroupUuid).orElse(null);
-        if (viewMetadata
-            || (group != null
-                && isGroupOwner(group, identifiedUser, isAdmin)
-                && canReadRef(name))) {
-          logger.atFinest().log("Include group ref %s", name);
-          resultRefs.add(ref);
-        } else {
-          logger.atFinest().log("Filter out group ref %s", name);
-        }
+      if (opts.filterMeta() && isMetadata(refName)) {
+        logger.atFinest().log("Filter out metadata ref %s", refName);
       } else if (isTag(ref)) {
         if (hasReadOnRefsStar) {
-          // The user has READ on refs/*. This is the broadest permission one can assign. There is
-          // no way to grant access to (specific) tags in Gerrit, so we have to assume that these
-          // users can see all tags because there could be tags that aren't reachable by any visible
-          // ref while the user can see all non-Gerrit refs. This matches Gerrit's historic
-          // behavior.
+          // The user has READ on refs/* with no effective block permission. This is the broadest
+          // permission one can assign. There is no way to grant access to (specific) tags in
+          // Gerrit,
+          // so we have to assume that these users can see all tags because there could be tags that
+          // aren't reachable by any visible ref while the user can see all non-Gerrit refs. This
+          // matches Gerrit's historic behavior.
           // This makes it so that these users could see commits that they can't see otherwise
           // (e.g. a private change ref) if a tag was attached to it. Tags are meant to be used on
           // the regular Git tree that users interact with, not on any of the Gerrit trees, so this
           // is a negligible risk.
-          logger.atFinest().log("Include tag ref %s because user has read on refs/*", name);
+          logger.atFinest().log("Include tag ref %s because user has read on refs/*", refName);
           resultRefs.add(ref);
         } else {
           // If its a tag, consider it later.
           if (ref.getObjectId() != null) {
-            logger.atFinest().log("Defer tag ref %s", name);
+            logger.atFinest().log("Defer tag ref %s", refName);
             deferredTags.add(ref);
           } else {
-            logger.atFinest().log("Filter out tag ref %s that is not a tag", name);
+            logger.atFinest().log("Filter out tag ref %s that is not a tag", refName);
           }
         }
-      } else if (name.startsWith(RefNames.REFS_SEQUENCES)) {
-        // Sequences are internal database implementation details.
-        if (viewMetadata) {
-          logger.atFinest().log("Include sequence ref %s", name);
+      } else if ((changeId = Change.Id.fromRef(refName)) != null) {
+        // This is a mere performance optimization. RefVisibilityControl could determine the
+        // visibility of these refs just fine. But instead, we use highly-optimized logic that
+        // looks only on the last 10k most recent changes using the change index and a cache.
+        if (hasAccessDatabase) {
           resultRefs.add(ref);
+        } else if (!visible(repo, changeId)) {
+          logger.atFinest().log("Filter out invisible change ref %s", refName);
+        } else if (RefNames.isRefsEdit(refName) && !visibleEdit(repo, refName)) {
+          logger.atFinest().log("Filter out invisible change edit ref %s", refName);
         } else {
-          logger.atFinest().log("Filter out sequence ref %s", name);
-        }
-      } else if (projectState.isAllUsers()
-          && (name.equals(RefNames.REFS_EXTERNAL_IDS) || name.equals(RefNames.REFS_GROUPNAMES))) {
-        // The notes branches with the external IDs / group names must not be exposed to normal
-        // users.
-        if (viewMetadata) {
-          logger.atFinest().log("Include external IDs branch %s", name);
+          // Change is visible
           resultRefs.add(ref);
-        } else {
-          logger.atFinest().log("Filter out external IDs branch %s", name);
         }
-      } else if (canReadRef(ref.getLeaf().getName())) {
-        // Use the leaf to lookup the control data. If the reference is
-        // symbolic we want the control around the final target. If its
-        // not symbolic then getLeaf() is a no-op returning ref itself.
-        logger.atFinest().log(
-            "Include ref %s because its leaf %s is readable", name, ref.getLeaf().getName());
+      } else if (refVisibilityControl.isVisible(projectControl, ref.getLeaf().getName())) {
         resultRefs.add(ref);
-      } else if (isRefsUsersSelf(ref)) {
-        // viewMetadata allows to see all account refs, hence refs/users/self should be included as
-        // well
-        if (viewMetadata) {
-          logger.atFinest().log("Include ref %s", REFS_USERS_SELF);
-          resultRefs.add(ref);
-        }
-      } else {
-        logger.atFinest().log("Filter out ref %s", name);
       }
     }
     Result result = new AutoValue_DefaultRefFilter_Result(resultRefs, deferredTags);
@@ -373,7 +291,8 @@
               r ->
                   !RefNames.isGerritRef(r.getName())
                       && !r.getName().startsWith(RefNames.REFS_TAGS)
-                      && !r.isSymbolic())
+                      && !r.isSymbolic()
+                      && !r.getName().equals(RefNames.REFS_CONFIG))
           // Don't use the default Java Collections.toList() as that is not size-aware and would
           // expand an array list as new elements are added. Instead, provide a list that has the
           // right size. This spares incremental list expansion which is quadratic in complexity.
@@ -519,10 +438,6 @@
     return ref.getLeaf().getName().startsWith(Constants.R_TAGS);
   }
 
-  private static boolean isRefsUsersSelf(Ref ref) {
-    return ref.getName().startsWith(REFS_USERS_SELF);
-  }
-
   private boolean canReadRef(String ref) throws PermissionBackendException {
     try {
       permissionBackendForProject.ref(ref).check(RefPermission.READ);
@@ -543,17 +458,6 @@
     return true;
   }
 
-  private boolean isGroupOwner(
-      InternalGroup group, @Nullable IdentifiedUser user, boolean isAdmin) {
-    requireNonNull(group);
-
-    // Keep this logic in sync with GroupControl#isOwner().
-    boolean isGroupOwner =
-        isAdmin || (user != null && user.getEffectiveGroups().contains(group.getOwnerGroupUUID()));
-    logger.atFinest().log("User is owner of group %s = %s", group.getGroupUUID(), isGroupOwner);
-    return isGroupOwner;
-  }
-
   /**
    * Returns true if the user can see the provided change ref. Uses NoteDb for evaluation, hence
    * does not suffer from the limitations documented in {@link SearchingChangeCacheImpl}.
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index 724017db..a92fde0 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -36,8 +36,10 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GitReceivePackGroups;
 import com.google.gerrit.server.config.GitUploadPackGroups;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
@@ -68,11 +70,14 @@
   private final Set<AccountGroup.UUID> uploadGroups;
   private final Set<AccountGroup.UUID> receiveGroups;
   private final PermissionBackend permissionBackend;
+  private final RefVisibilityControl refVisibilityControl;
+  private final GitRepositoryManager repositoryManager;
   private final CurrentUser user;
   private final ProjectState state;
   private final PermissionCollection.Factory permissionFilter;
   private final DefaultRefFilter.Factory refFilterFactory;
   private final ChangeData.Factory changeDataFactory;
+  private final AllUsersName allUsersName;
 
   private List<SectionMatcher> allSections;
   private Map<String, RefControl> refControls;
@@ -84,16 +89,22 @@
       @GitReceivePackGroups Set<AccountGroup.UUID> receiveGroups,
       PermissionCollection.Factory permissionFilter,
       PermissionBackend permissionBackend,
+      RefVisibilityControl refVisibilityControl,
+      GitRepositoryManager repositoryManager,
       DefaultRefFilter.Factory refFilterFactory,
       ChangeData.Factory changeDataFactory,
+      AllUsersName allUsersName,
       @Assisted CurrentUser who,
       @Assisted ProjectState ps) {
     this.uploadGroups = uploadGroups;
     this.receiveGroups = receiveGroups;
     this.permissionFilter = permissionFilter;
     this.permissionBackend = permissionBackend;
+    this.refVisibilityControl = refVisibilityControl;
+    this.repositoryManager = repositoryManager;
     this.refFilterFactory = refFilterFactory;
     this.changeDataFactory = changeDataFactory;
+    this.allUsersName = allUsersName;
     user = who;
     state = ps;
   }
@@ -117,7 +128,9 @@
     RefControl ctl = refControls.get(refName);
     if (ctl == null) {
       PermissionCollection relevant = permissionFilter.filter(access(), refName, user);
-      ctl = new RefControl(changeDataFactory, this, refName, relevant);
+      ctl =
+          new RefControl(
+              changeDataFactory, refVisibilityControl, this, repositoryManager, refName, relevant);
       refControls.put(refName, ctl);
     }
     return ctl;
@@ -164,7 +177,9 @@
   }
 
   boolean allRefsAreVisible(Set<String> ignore) {
-    return user.isInternalUser() || canPerformOnAllRefs(Permission.READ, ignore);
+    return user.isInternalUser()
+        || (!getProject().getNameKey().equals(allUsersName)
+            && canPerformOnAllRefs(Permission.READ, ignore));
   }
 
   /** Can the user run upload pack? */
@@ -442,7 +457,7 @@
           return canPushToAtLeastOneRef();
 
         case READ_CONFIG:
-          return controlForRef(RefNames.REFS_CONFIG).isVisible();
+          return controlForRef(RefNames.REFS_CONFIG).hasReadPermissionOnRef(false);
 
         case BAN_COMMIT:
         case READ_REFLOG:
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index bc802cc..ad4188f 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Permission;
@@ -28,23 +29,32 @@
 import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.logging.CallerFinder;
+import com.google.gerrit.server.logging.LoggingContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
 import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.util.MagicBranch;
+import java.io.IOException;
 import java.util.Collection;
 import java.util.EnumSet;
 import java.util.List;
 import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
 
 /** Manages access control for Git references (aka branches, tags). */
 class RefControl {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final ChangeData.Factory changeDataFactory;
+  private final RefVisibilityControl refVisibilityControl;
   private final ProjectControl projectControl;
+  private final GitRepositoryManager repositoryManager;
   private final String refName;
 
   /** All permissions that apply to this reference. */
@@ -57,15 +67,19 @@
   private Boolean owner;
   private Boolean canForgeAuthor;
   private Boolean canForgeCommitter;
-  private Boolean isVisible;
+  private Boolean hasReadPermissionOnRef;
 
   RefControl(
       ChangeData.Factory changeDataFactory,
+      RefVisibilityControl refVisibilityControl,
       ProjectControl projectControl,
+      GitRepositoryManager repositoryManager,
       String ref,
       PermissionCollection relevant) {
     this.changeDataFactory = changeDataFactory;
+    this.refVisibilityControl = refVisibilityControl;
     this.projectControl = projectControl;
+    this.repositoryManager = repositoryManager;
     this.refName = ref;
     this.relevant = relevant;
     this.callerFinder =
@@ -98,12 +112,27 @@
     return owner;
   }
 
-  /** Can this user see this reference exists? */
-  boolean isVisible() {
-    if (isVisible == null) {
-      isVisible = getUser().isInternalUser() || canPerform(Permission.READ);
+  /**
+   * Returns {@code true} if the user has permission to read the ref. This method evaluates {@link
+   * RefPermission#READ} only. Hence, it is not authoritative. For example, it does not tell if the
+   * user can see NoteDb refs such as {@code refs/meta/external-ids} which requires {@link
+   * GlobalPermission#ACCESS_DATABASE} and deny access in this case.
+   */
+  boolean hasReadPermissionOnRef(boolean allowNoteDbRefs) {
+    // Don't allow checking for NoteDb refs unless instructed otherwise.
+    if (!allowNoteDbRefs
+        && (refName.startsWith(Constants.R_TAGS) || RefNames.isGerritRef(refName))) {
+      logger.atWarning().atMostEvery(30, TimeUnit.SECONDS).log(
+          "%s: Can't determine visibility of %s in RefControl. Denying access. "
+              + "This case should have been handled before.",
+          projectControl.getProject().getName(), refName);
+      return false;
     }
-    return isVisible;
+
+    if (hasReadPermissionOnRef == null) {
+      hasReadPermissionOnRef = getUser().isInternalUser() || canPerform(Permission.READ);
+    }
+    return hasReadPermissionOnRef;
   }
 
   /** @return true if this user can add a new patch set to this ref */
@@ -397,40 +426,52 @@
   /** True if the user has this permission. */
   private boolean canPerform(String permissionName, boolean isChangeOwner, boolean withForce) {
     if (isBlocked(permissionName, isChangeOwner, withForce)) {
-      logger.atFine().log(
-          "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'"
-              + " because this permission is blocked (caller: %s)",
-          getUser().getLoggableName(),
-          permissionName,
-          withForce,
-          projectControl.getProject().getName(),
-          refName,
-          callerFinder.findCallerLazy());
+      if (logger.atFine().isEnabled() || LoggingContext.getInstance().isAclLogging()) {
+        String logMessage =
+            String.format(
+                "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'"
+                    + " because this permission is blocked",
+                getUser().getLoggableName(),
+                permissionName,
+                withForce,
+                projectControl.getProject().getName(),
+                refName);
+        LoggingContext.getInstance().addAclLogRecord(logMessage);
+        logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
+      }
       return false;
     }
 
     for (PermissionRule pr : relevant.getAllowRules(permissionName)) {
       if (isAllow(pr, withForce) && projectControl.match(pr, isChangeOwner)) {
-        logger.atFine().log(
-            "'%s' can perform '%s' with force=%s on project '%s' for ref '%s' (caller: %s)",
-            getUser().getLoggableName(),
-            permissionName,
-            withForce,
-            projectControl.getProject().getName(),
-            refName,
-            callerFinder.findCallerLazy());
+        if (logger.atFine().isEnabled() || LoggingContext.getInstance().isAclLogging()) {
+          String logMessage =
+              String.format(
+                  "'%s' can perform '%s' with force=%s on project '%s' for ref '%s'",
+                  getUser().getLoggableName(),
+                  permissionName,
+                  withForce,
+                  projectControl.getProject().getName(),
+                  refName);
+          LoggingContext.getInstance().addAclLogRecord(logMessage);
+          logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
+        }
         return true;
       }
     }
 
-    logger.atFine().log(
-        "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s' (caller: %s)",
-        getUser().getLoggableName(),
-        permissionName,
-        withForce,
-        projectControl.getProject().getName(),
-        refName,
-        callerFinder.findCallerLazy());
+    if (logger.atFine().isEnabled() || LoggingContext.getInstance().isAclLogging()) {
+      String logMessage =
+          String.format(
+              "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'",
+              getUser().getLoggableName(),
+              permissionName,
+              withForce,
+              projectControl.getProject().getName(),
+              refName);
+      LoggingContext.getInstance().addAclLogRecord(logMessage);
+      logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
+    }
     return false;
   }
 
@@ -578,7 +619,10 @@
     private boolean can(RefPermission perm) throws PermissionBackendException {
       switch (perm) {
         case READ:
-          return isVisible();
+          if (refName.startsWith(Constants.R_TAGS)) {
+            return isTagVisible();
+          }
+          return refVisibilityControl.isVisible(projectControl, refName);
         case CREATE:
           // TODO This isn't an accurate test.
           return canPerform(refPermissionName(perm));
@@ -628,6 +672,38 @@
       }
       throw new PermissionBackendException(perm + " unsupported");
     }
+
+    private boolean isTagVisible() throws PermissionBackendException {
+      if (projectControl.asForProject().test(ProjectPermission.READ)) {
+        // The user has READ on refs/* with no effective block permission. This is the broadest
+        // permission one can assign. There is no way to grant access to (specific) tags in Gerrit,
+        // so we have to assume that these users can see all tags because there could be tags that
+        // aren't reachable by any visible ref while the user can see all non-Gerrit refs. This
+        // matches Gerrit's historic behavior.
+        // This makes it so that these users could see commits that they can't see otherwise
+        // (e.g. a private change ref) if a tag was attached to it. Tags are meant to be used on
+        // the regular Git tree that users interact with, not on any of the Gerrit trees, so this
+        // is a negligible risk.
+        return true;
+      }
+
+      try (Repository repo =
+          repositoryManager.openRepository(projectControl.getProject().getNameKey())) {
+        // Tag visibility requires going through RefFilter because it entails loading all taggable
+        // refs and filtering them all by visibility.
+        Ref resolvedRef = repo.getRefDatabase().exactRef(refName);
+        if (resolvedRef == null) {
+          return false;
+        }
+        return projectControl.asForProject()
+            .filter(
+                ImmutableList.of(resolvedRef), repo, PermissionBackend.RefFilterOptions.defaults())
+            .stream()
+            .anyMatch(r -> refName.equals(r.getName()));
+      } catch (IOException e) {
+        throw new PermissionBackendException(e);
+      }
+    }
   }
 
   private static String refPermissionName(RefPermission refPermission) {
diff --git a/java/com/google/gerrit/server/permissions/RefVisibilityControl.java b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
new file mode 100644
index 0000000..4744037
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
@@ -0,0 +1,181 @@
+// Copyright (C) 2020 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.checkState;
+import static com.google.gerrit.entities.RefNames.REFS_CACHE_AUTOMERGE;
+
+import com.google.common.base.Throwables;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.NoSuchGroupException;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.query.change.ChangeData;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import org.eclipse.jgit.lib.Constants;
+
+/**
+ * This class is a component that is internal to {@link DefaultPermissionBackend}. It can
+ * authoritatively tell if a ref is accessible by a user.
+ */
+@Singleton
+class RefVisibilityControl {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final PermissionBackend permissionBackend;
+  private final GroupControl.GenericFactory groupControlFactory;
+  private final ChangeData.Factory changeDataFactory;
+
+  @Inject
+  RefVisibilityControl(
+      PermissionBackend permissionBackend,
+      GroupControl.GenericFactory groupControlFactory,
+      ChangeData.Factory changeDataFactory) {
+    this.permissionBackend = permissionBackend;
+    this.groupControlFactory = groupControlFactory;
+    this.changeDataFactory = changeDataFactory;
+  }
+
+  /**
+   * Returns an authoritative answer if the ref is visible to the user. Does not have support for
+   * tags and will throw a {@link PermissionBackendException} if asked for tags visibility.
+   */
+  boolean isVisible(ProjectControl projectControl, String refName)
+      throws PermissionBackendException {
+    if (refName.startsWith(Constants.R_TAGS)) {
+      throw new PermissionBackendException(
+          "can't check tags through RefVisibilityControl. Use PermissionBackend#filter instead.");
+    }
+    if (!RefNames.isGerritRef(refName)) {
+      // This is not a special Gerrit ref and not a NoteDb ref. Likely, it's just a ref under
+      // refs/heads or another ref the user created. Apply the regular permissions with inheritance.
+      return projectControl.controlForRef(refName).hasReadPermissionOnRef(false);
+    }
+
+    if (refName.startsWith(REFS_CACHE_AUTOMERGE)) {
+      // Internal cache state that is accessible to no one.
+      return false;
+    }
+
+    boolean hasAccessDatabase =
+        permissionBackend
+            .user(projectControl.getUser())
+            .testOrFalse(GlobalPermission.ACCESS_DATABASE);
+    if (hasAccessDatabase) {
+      return true;
+    }
+
+    // Change and change edit visibility
+    Change.Id changeId;
+    if ((changeId = Change.Id.fromRef(refName)) != null) {
+      // Change ref is visible only if the change is visible.
+      ChangeData cd;
+      try {
+        cd = changeDataFactory.create(projectControl.getProject().getNameKey(), changeId);
+        checkState(cd.change().getId().equals(changeId));
+      } catch (StorageException e) {
+        if (Throwables.getCausalChain(e).stream()
+            .anyMatch(e2 -> e2 instanceof NoSuchChangeException)) {
+          // The change was deleted or is otherwise not accessible anymore.
+          // If the caller can see all refs and is allowed to see private changes on refs/, allow
+          // access. This is an escape hatch for receivers of "ref deleted" events.
+          PermissionBackend.ForProject forProject = projectControl.asForProject();
+          return forProject.test(ProjectPermission.READ)
+              && forProject.ref("refs/").test(RefPermission.READ_PRIVATE_CHANGES);
+        }
+        throw new PermissionBackendException(e);
+      }
+      if (RefNames.isRefsEdit(refName)) {
+        // Edits are visible only to the owning user, if change is visible.
+        return visibleEdit(refName, projectControl, cd);
+      }
+      return projectControl.controlFor(cd).isVisible();
+    }
+
+    // Account visibility
+    CurrentUser user = projectControl.getUser();
+    Account.Id currentUserAccountId = user.isIdentifiedUser() ? user.getAccountId() : null;
+    Account.Id accountId;
+    if ((accountId = Account.Id.fromRef(refName)) != null) {
+      // Account ref is visible only to the corresponding account.
+      if (accountId.equals(currentUserAccountId)
+          && projectControl.controlForRef(refName).hasReadPermissionOnRef(true)) {
+        return true;
+      }
+      return false;
+    }
+
+    // Group visibility
+    AccountGroup.UUID accountGroupUuid;
+    if ((accountGroupUuid = AccountGroup.UUID.fromRef(refName)) != null) {
+      // Group ref is visible only to the corresponding owner group.
+      try {
+        return projectControl.controlForRef(refName).hasReadPermissionOnRef(true)
+            && groupControlFactory.controlFor(user, accountGroupUuid).isOwner();
+      } catch (NoSuchGroupException e) {
+        // The group is broken, but the ref is still around. Pretend the ref is not visible.
+        logger.atWarning().withCause(e).log("Found group ref %s but group isn't parsable", refName);
+        return false;
+      }
+    }
+
+    // We are done checking all cases where we would allow access to Gerrit-managed refs. Deny
+    // access in case we got this far.
+    logger.atFine().log(
+        "Denying access to %s because user doesn't have access to this Gerrit ref", refName);
+    return false;
+  }
+
+  private boolean visibleEdit(String refName, ProjectControl projectControl, ChangeData cd)
+      throws PermissionBackendException {
+    Change.Id id = Change.Id.fromEditRefPart(refName);
+    if (id == null) {
+      throw new IllegalStateException("unable to parse change id from edit ref " + refName);
+    }
+
+    if (!projectControl.controlFor(cd).isVisible()) {
+      // The user can't see the change so they can't see any edits.
+      return false;
+    }
+
+    if (projectControl.getUser().isIdentifiedUser()
+        && refName.startsWith(
+            RefNames.refsEditPrefix(projectControl.getUser().asIdentifiedUser().getAccountId()))) {
+      logger.atFinest().log("Own change edit ref is visible: %s", refName);
+      return true;
+    }
+
+    try {
+      // Default to READ_PRIVATE_CHANGES as there is no special permission for reading edits.
+      projectControl
+          .asForProject()
+          .ref(cd.change().getDest().branch())
+          .check(RefPermission.READ_PRIVATE_CHANGES);
+      logger.atFinest().log("Foreign change edit ref is visible: %s", refName);
+      return true;
+    } catch (AuthException e) {
+      logger.atFinest().log("Foreign change edit ref is not visible: %s", refName);
+      return false;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 54d9176..89038e2 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -1425,7 +1425,7 @@
           if (group.getUUID() != null) {
             keepGroups.add(group.getUUID());
           }
-          rules.add(rule.asString(needRange));
+          rules.add(rule.toBuilder().setGroup(group).build().asString(needRange));
         }
         rc.setStringList(CAPABILITY, null, permission.getName(), rules);
       }
@@ -1470,7 +1470,7 @@
           if (group.getUUID() != null) {
             keepGroups.add(group.getUUID());
           }
-          rules.add(rule.asString(needRange));
+          rules.add(rule.toBuilder().setGroup(group).build().asString(needRange));
         }
         rc.setStringList(ACCESS, refName, permission.getName(), rules);
       }
diff --git a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
index c6bcd60..a66c43ae 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
@@ -88,7 +88,7 @@
             ? permissionBackend.absentUser(user.getAccountId())
             : permissionBackend.user(
                 Optional.of(user)
-                    .filter(u -> u instanceof SingleGroupUser || u instanceof InternalUser)
+                    .filter(u -> u instanceof GroupBackedUser || u instanceof InternalUser)
                     .orElseGet(anonymousUserProvider::get));
     try {
       withUser.change(cd).check(ChangePermission.READ);
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 464ba81..ff90c3f 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -199,6 +199,7 @@
   public static final String FIELD_CHERRY_PICK_OF_CHANGE = "cherrypickofchange";
   public static final String FIELD_CHERRY_PICK_OF_PATCHSET = "cherrypickofpatchset";
 
+  public static final String ARG_ID_NAME = "name";
   public static final String ARG_ID_USER = "user";
   public static final String ARG_ID_GROUP = "group";
   public static final String ARG_ID_OWNER = "owner";
@@ -1026,7 +1027,7 @@
       for (GroupReference ref : suggestions) {
         ids.add(ref.getUUID());
       }
-      return visibleto(new SingleGroupUser(ids));
+      return visibleto(new GroupBackedUser(ids));
     }
 
     throw error("No user or group matches \"" + who + "\".");
@@ -1224,9 +1225,36 @@
   }
 
   @Operator
-  public Predicate<ChangeData> query(String name) throws QueryParseException {
+  public Predicate<ChangeData> query(String value) throws QueryParseException {
+    // [name=]<name>[,user=<user>] || [user=<user>,][name=]<name>
+    PredicateArgs inputArgs = new PredicateArgs(value);
+    String name = null;
+    Account.Id account = null;
+
     try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
-      VersionedAccountQueries q = VersionedAccountQueries.forUser(self());
+      // [name=]<name>
+      if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
+        name = inputArgs.keyValue.get(ARG_ID_NAME);
+      } else if (inputArgs.positional.size() == 1) {
+        name = Iterables.getOnlyElement(inputArgs.positional);
+      } else if (inputArgs.positional.size() > 1) {
+        throw new QueryParseException("Error parsing named query: " + value);
+      }
+
+      // [,user=<user>]
+      if (inputArgs.keyValue.containsKey(ARG_ID_USER)) {
+        Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER));
+        if (accounts != null && accounts.size() > 1) {
+          throw error(
+              String.format(
+                  "\"%s\" resolves to multiple accounts", inputArgs.keyValue.get(ARG_ID_USER)));
+        }
+        account = (accounts == null ? self() : Iterables.getOnlyElement(accounts));
+      } else {
+        account = self();
+      }
+
+      VersionedAccountQueries q = VersionedAccountQueries.forUser(account);
       q.load(args.allUsersName, git);
       String query = q.getQueryList().getQuery(name);
       if (query != null) {
@@ -1236,7 +1264,7 @@
       throw new QueryParseException(
           "Unknown named query (no " + args.allUsersName + " repo): " + name, e);
     } catch (IOException | ConfigInvalidException e) {
-      throw new QueryParseException("Error parsing named query: " + name, e);
+      throw new QueryParseException("Error parsing named query: " + value, e);
     }
     throw new QueryParseException("Unknown named query: " + name);
   }
@@ -1248,19 +1276,46 @@
   }
 
   @Operator
-  public Predicate<ChangeData> destination(String name) throws QueryParseException {
+  public Predicate<ChangeData> destination(String value) throws QueryParseException {
+    // [name=]<name>[,user=<user>] || [user=<user>,][name=]<name>
+    PredicateArgs inputArgs = new PredicateArgs(value);
+    String name = null;
+    Account.Id account = null;
+
     try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
-      VersionedAccountDestinations d = VersionedAccountDestinations.forUser(self());
+      // [name=]<name>
+      if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
+        name = inputArgs.keyValue.get(ARG_ID_NAME);
+      } else if (inputArgs.positional.size() == 1) {
+        name = Iterables.getOnlyElement(inputArgs.positional);
+      } else if (inputArgs.positional.size() > 1) {
+        throw new QueryParseException("Error parsing named destination: " + value);
+      }
+
+      // [,user=<user>]
+      if (inputArgs.keyValue.containsKey(ARG_ID_USER)) {
+        Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER));
+        if (accounts != null && accounts.size() > 1) {
+          throw error(
+              String.format(
+                  "\"%s\" resolves to multiple accounts", inputArgs.keyValue.get(ARG_ID_USER)));
+        }
+        account = (accounts == null ? self() : Iterables.getOnlyElement(accounts));
+      } else {
+        account = self();
+      }
+
+      VersionedAccountDestinations d = VersionedAccountDestinations.forUser(account);
       d.load(args.allUsersName, git);
       Set<BranchNameKey> destinations = d.getDestinationList().getDestinations(name);
       if (destinations != null && !destinations.isEmpty()) {
-        return new DestinationPredicate(destinations, name);
+        return new DestinationPredicate(destinations, value);
       }
     } catch (RepositoryNotFoundException e) {
       throw new QueryParseException(
           "Unknown named destination (no " + args.allUsersName + " repo): " + name, e);
     } catch (IOException | ConfigInvalidException e) {
-      throw new QueryParseException("Error parsing named destination: " + name, e);
+      throw new QueryParseException("Error parsing named destination: " + value, e);
     }
     throw new QueryParseException("Unknown named destination: " + name);
   }
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
index 40c0477..370bc75 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
@@ -19,6 +19,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.Extension;
@@ -33,15 +34,20 @@
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
 import com.google.gerrit.server.account.AccountLimits;
 import com.google.gerrit.server.change.ChangeAttributeFactory;
+import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
 import com.google.gerrit.server.change.PluginDefinedAttributesFactories;
 import com.google.gerrit.server.change.PluginDefinedAttributesFactory;
+import com.google.gerrit.server.change.PluginDefinedInfosFactory;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import java.util.ArrayList;
+import java.util.Collection;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
@@ -52,11 +58,13 @@
  * holding on to a single instance.
  */
 public class ChangeQueryProcessor extends QueryProcessor<ChangeData>
-    implements DynamicOptions.BeanReceiver, DynamicOptions.BeanProvider {
+    implements DynamicOptions.BeanReceiver, DynamicOptions.BeanProvider, PluginDefinedInfosFactory {
   private final Provider<CurrentUser> userProvider;
   private final ImmutableListMultimap<String, ChangeAttributeFactory> attributeFactoriesByPlugin;
   private final ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory;
   private final Map<String, DynamicBean> dynamicBeans = new HashMap<>();
+  private final List<Extension<ChangePluginDefinedInfoFactory>>
+      changePluginDefinedInfoFactoriesByPlugin = new ArrayList<>();
 
   static {
     // It is assumed that basic rewrites do not touch visibleto predicates.
@@ -74,7 +82,8 @@
       ChangeIndexCollection indexes,
       ChangeIndexRewriter rewriter,
       DynamicSet<ChangeAttributeFactory> attributeFactories,
-      ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory) {
+      ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory,
+      DynamicSet<ChangePluginDefinedInfoFactory> changePluginDefinedInfoFactories) {
     super(
         metricMaker,
         ChangeSchemaDefinitions.INSTANCE,
@@ -88,10 +97,15 @@
 
     ImmutableListMultimap.Builder<String, ChangeAttributeFactory> factoriesBuilder =
         ImmutableListMultimap.builder();
+    ImmutableListMultimap.Builder<String, ChangePluginDefinedInfoFactory> infosFactoriesBuilder =
+        ImmutableListMultimap.builder();
     // Eagerly call Extension#get() rather than storing Extensions, since that method invokes the
     // Provider on every call, which could be expensive if we invoke it once for every change.
     attributeFactories.entries().forEach(e -> factoriesBuilder.put(e.getPluginName(), e.get()));
     attributeFactoriesByPlugin = factoriesBuilder.build();
+    changePluginDefinedInfoFactories
+        .entries()
+        .forEach(e -> changePluginDefinedInfoFactoriesByPlugin.add(e));
   }
 
   @Override
@@ -128,6 +142,17 @@
             .map(e -> new Extension<>(e.getKey(), e::getValue)));
   }
 
+  public PluginDefinedInfosFactory getInfosFactory() {
+    return this::createPluginDefinedInfos;
+  }
+
+  @Override
+  public ImmutableListMultimap<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+      Collection<ChangeData> cds) {
+    return PluginDefinedAttributesFactories.createAll(
+        cds, this, changePluginDefinedInfoFactoriesByPlugin.stream());
+  }
+
   @Override
   protected Predicate<ChangeData> enforceVisibility(Predicate<ChangeData> pred) {
     return new AndChangeSource(
diff --git a/java/com/google/gerrit/server/query/change/GroupBackedUser.java b/java/com/google/gerrit/server/query/change/GroupBackedUser.java
new file mode 100644
index 0000000..3960813
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/GroupBackedUser.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2009 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.entities.AccountGroup;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.account.ListGroupMembership;
+import java.util.Set;
+
+/**
+ * Representation of a user that does not have a Gerrit account.
+ *
+ * <p>This user representation is intended to be used for two purposes:
+ *
+ * <ol>
+ *   <li>Checking permissions for groups: There are occasions where we need to check if a resource -
+ *       such as a change - is accessible by a group. Our entire {@link
+ *       com.google.gerrit.server.permissions.PermissionBackend} works solely with {@link
+ *       CurrentUser}. This class can be used to check permissions on a synthetic user with the
+ *       given group memberships. Any real Gerrit user with the same group memberships would receive
+ *       the same permission check results.
+ *   <li>Checking permissions for an external user: In installations with external group systems,
+ *       one might want to check what Gerrit permissions a user has, before or even without creating
+ *       a Gerrit account. Such an external user has external group memberships only as well as
+ *       internal groups that contain the user's external groups as subgroups. This class can be
+ *       used to represent such an external user.
+ * </ol>
+ */
+public final class GroupBackedUser extends CurrentUser {
+  private final GroupMembership groups;
+
+  /**
+   * Creates a new instance
+   *
+   * @param groups this set has to include all parent groups the user is contained in through
+   *     subgroup membership. Given a set of groups that contains the user directly, callers can use
+   *     {@link
+   *     com.google.gerrit.server.account.GroupIncludeCache#parentGroupsOf(AccountGroup.UUID)} to
+   *     resolve parent groups.
+   */
+  public GroupBackedUser(Set<AccountGroup.UUID> groups) {
+    this.groups = new ListGroupMembership(groups);
+  }
+
+  @Override
+  public GroupMembership getEffectiveGroups() {
+    return groups;
+  }
+
+  @Override
+  public String getLoggableName() {
+    return "GroupBackedUser with memberships: " + groups.getKnownGroups();
+  }
+
+  @Override
+  public Object getCacheKey() {
+    return groups.getKnownGroups();
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index 02e8434..b931457 100644
--- a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -17,11 +17,14 @@
 import static com.google.common.base.Preconditions.checkState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.QueryResult;
 import com.google.gerrit.server.DynamicOptions;
@@ -97,6 +100,8 @@
 
   private OutputStream outputStream = DisabledOutputStream.INSTANCE;
   private PrintWriter out;
+  private ImmutableListMultimap<Change.Id, PluginDefinedInfo> pluginInfosByChange =
+      ImmutableListMultimap.of();
 
   @Inject
   OutputStreamQuery(
@@ -207,6 +212,7 @@
         Map<Project.NameKey, Repository> repos = new HashMap<>();
         Map<Project.NameKey, RevWalk> revWalks = new HashMap<>();
         QueryResult<ChangeData> results = queryProcessor.query(queryBuilder.parse(queryString));
+        pluginInfosByChange = queryProcessor.createPluginDefinedInfos(results.entities());
         try {
           for (ChangeData d : results.entities()) {
             show(buildChangeAttribute(d, repos, revWalks));
@@ -325,6 +331,15 @@
     }
 
     c.plugins = queryProcessor.getAttributesFactory().create(d);
+    List<PluginDefinedInfo> pluginInfos = pluginInfosByChange.get(d.getId());
+    if (!pluginInfos.isEmpty()) {
+      if (c.plugins == null) {
+        c.plugins = pluginInfos;
+      } else {
+        c.plugins = new ArrayList<>(c.plugins);
+        c.plugins.addAll(pluginInfos);
+      }
+    }
     return c;
   }
 
diff --git a/java/com/google/gerrit/server/query/change/SingleGroupUser.java b/java/com/google/gerrit/server/query/change/SingleGroupUser.java
deleted file mode 100644
index 7947b6b..0000000
--- a/java/com/google/gerrit/server/query/change/SingleGroupUser.java
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (C) 2009 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.common.collect.ImmutableSet;
-import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.GroupMembership;
-import com.google.gerrit.server.account.ListGroupMembership;
-import java.util.Set;
-
-public final class SingleGroupUser extends CurrentUser {
-  private final GroupMembership groups;
-
-  public SingleGroupUser(AccountGroup.UUID groupId) {
-    this(ImmutableSet.of(groupId));
-  }
-
-  public SingleGroupUser(Set<AccountGroup.UUID> groups) {
-    this.groups = new ListGroupMembership(groups);
-  }
-
-  @Override
-  public GroupMembership getEffectiveGroups() {
-    return groups;
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
index c80bf57..5979b2a 100644
--- a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
@@ -91,7 +91,7 @@
       throws RestApiException, IOException, PermissionBackendException {
     Map<ProjectWatchKey, Set<NotifyType>> m = new HashMap<>();
     for (ProjectWatchInfo info : input) {
-      if (info.project == null) {
+      if (info.project == null || info.project.trim().isEmpty()) {
         throw new BadRequestException("project name must be specified");
       }
 
diff --git a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
index a5be14f..cb1256c 100644
--- a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
@@ -42,6 +42,7 @@
 public class AddToAttentionSet
     implements RestCollectionModifyView<
         ChangeResource, AttentionSetEntryResource, AttentionSetInput> {
+
   private final BatchUpdate.Factory updateFactory;
   private final AccountResolver accountResolver;
   private final AddToAttentionSetOp.Factory opFactory;
@@ -72,8 +73,9 @@
   public Response<AccountInfo> apply(ChangeResource changeResource, AttentionSetInput input)
       throws Exception {
     AttentionSetUtil.validateInput(input);
+    Account.Id attentionUserId =
+        AttentionSetUtil.resolveAccount(accountResolver, changeResource.getNotes(), input.user);
 
-    Account.Id attentionUserId = accountResolver.resolve(input.user).asUnique().account().id();
     if (serviceUserClassifier.isServiceUser(attentionUserId)) {
       throw new BadRequestException(
           String.format(
diff --git a/java/com/google/gerrit/server/restapi/change/AttentionSet.java b/java/com/google/gerrit/server/restapi/change/AttentionSet.java
index 45d78dc..f72fe64ec 100644
--- a/java/com/google/gerrit/server/restapi/change/AttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/AttentionSet.java
@@ -17,14 +17,15 @@
 import com.google.gerrit.entities.Account;
 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.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.account.AccountResolver;
-import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
 import com.google.gerrit.server.change.AttentionSetEntryResource;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.util.AttentionSetUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -58,12 +59,10 @@
 
   @Override
   public AttentionSetEntryResource parse(ChangeResource changeResource, IdString idString)
-      throws ResourceNotFoundException, AuthException, IOException, ConfigInvalidException {
-    try {
-      Account.Id accountId = accountResolver.resolve(idString.get()).asUnique().account().id();
-      return new AttentionSetEntryResource(changeResource, accountId);
-    } catch (UnresolvableAccountException e) {
-      throw new ResourceNotFoundException(idString, e);
-    }
+      throws ResourceNotFoundException, AuthException, IOException, ConfigInvalidException,
+          BadRequestException {
+    Account.Id accountId =
+        AttentionSetUtil.resolveAccount(accountResolver, changeResource.getNotes(), idString.get());
+    return new AttentionSetEntryResource(changeResource, accountId);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index 4de9b63..cc8ad47 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -20,9 +20,12 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Streams;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.CommentContext;
 import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.entities.FixSuggestion;
 import com.google.gerrit.entities.HumanComment;
@@ -31,15 +34,18 @@
 import com.google.gerrit.extensions.client.Comment.Range;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.ContextLineInfo;
 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.Url;
-import com.google.gerrit.server.CommentContextLoader;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.comment.CommentContextCache;
+import com.google.gerrit.server.comment.CommentContextKey;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 import java.util.TreeMap;
@@ -47,18 +53,19 @@
 public class CommentJson {
 
   private final AccountLoader.Factory accountLoaderFactory;
+  private final CommentContextCache commentContextCache;
+
+  private Project.NameKey project;
+  private Change.Id changeId;
 
   private boolean fillAccounts = true;
   private boolean fillPatchSet;
-  private CommentContextLoader.Factory commentContextLoaderFactory;
-  private CommentContextLoader commentContextLoader;
+  private boolean fillCommentContext;
 
   @Inject
-  CommentJson(
-      AccountLoader.Factory accountLoaderFactory,
-      CommentContextLoader.Factory commentContextLoaderFactory) {
+  CommentJson(AccountLoader.Factory accountLoaderFactory, CommentContextCache commentContextCache) {
     this.accountLoaderFactory = accountLoaderFactory;
-    this.commentContextLoaderFactory = commentContextLoaderFactory;
+    this.commentContextCache = commentContextCache;
   }
 
   CommentJson setFillAccounts(boolean fillAccounts) {
@@ -71,10 +78,18 @@
     return this;
   }
 
-  CommentJson setEnableContext(boolean enableContext, Project.NameKey project) {
-    if (enableContext) {
-      this.commentContextLoader = commentContextLoaderFactory.create(project);
-    }
+  CommentJson setFillCommentContext(boolean fillCommentContext) {
+    this.fillCommentContext = fillCommentContext;
+    return this;
+  }
+
+  CommentJson setProjectKey(Project.NameKey project) {
+    this.project = project;
+    return this;
+  }
+
+  CommentJson setChangeId(Change.Id changeId) {
+    this.changeId = changeId;
     return this;
   }
 
@@ -93,9 +108,6 @@
       if (loader != null) {
         loader.fill();
       }
-      if (commentContextLoader != null) {
-        commentContextLoader.fill();
-      }
       return info;
     }
 
@@ -120,8 +132,10 @@
       if (loader != null) {
         loader.fill();
       }
-      if (commentContextLoader != null) {
-        commentContextLoader.fill();
+
+      if (fillCommentContext) {
+        List<T> allComments = out.values().stream().flatMap(Collection::stream).collect(toList());
+        addCommentContext(allComments);
       }
       return out;
     }
@@ -138,12 +152,41 @@
       if (loader != null) {
         loader.fill();
       }
-      if (commentContextLoader != null) {
-        commentContextLoader.fill();
+
+      if (fillCommentContext) {
+        addCommentContext(out);
       }
+
       return out;
     }
 
+    protected void addCommentContext(List<T> allComments) {
+      List<CommentContextKey> keys =
+          allComments.stream().map(this::createCommentContextKey).collect(toList());
+      ImmutableMap<CommentContextKey, CommentContext> allContext = commentContextCache.getAll(keys);
+      for (T c : allComments) {
+        c.contextLines = toContextLineInfoList(allContext.get(createCommentContextKey(c)));
+      }
+    }
+
+    protected List<ContextLineInfo> toContextLineInfoList(CommentContext commentContext) {
+      List<ContextLineInfo> result = new ArrayList<>();
+      for (Map.Entry<Integer, String> e : commentContext.lines().entrySet()) {
+        result.add(new ContextLineInfo(e.getKey(), e.getValue()));
+      }
+      return result;
+    }
+
+    protected CommentContextKey createCommentContextKey(T r) {
+      return CommentContextKey.builder()
+          .project(project)
+          .changeId(changeId)
+          .id(r.id)
+          .path(r.path)
+          .patchset(r.patchSet)
+          .build();
+    }
+
     protected abstract T toInfo(F comment, AccountLoader loader);
 
     protected void fillCommentInfo(Comment c, CommentInfo r, AccountLoader loader) {
@@ -170,9 +213,6 @@
         r.author = loader.get(c.author.getId());
       }
       r.commitId = c.getCommitId().getName();
-      if (commentContextLoader != null) {
-        r.contextLines = commentContextLoader.getContext(r);
-      }
     }
 
     protected Range toRange(Comment.Range commentRange) {
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
index 8ac2140..af4bf69 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -55,6 +55,7 @@
 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.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.restapi.project.CommitsCollection;
@@ -128,6 +129,13 @@
     psUtil.checkPatchSetNotLocked(rsrc.getNotes());
 
     rsrc.permissions().check(ChangePermission.ADD_PATCH_SET);
+    if (in.author != null) {
+      permissionBackend
+          .currentUser()
+          .project(rsrc.getProject())
+          .ref(rsrc.getChange().getDest().branch())
+          .check(RefPermission.FORGE_AUTHOR);
+    }
 
     ProjectState projectState =
         projectCache.get(rsrc.getProject()).orElseThrow(illegalState(rsrc.getProject()));
@@ -137,6 +145,10 @@
     if (merge == null || Strings.isNullOrEmpty(merge.source)) {
       throw new BadRequestException("merge.source must be non-empty");
     }
+    if (in.author != null
+        && (Strings.isNullOrEmpty(in.author.email) || Strings.isNullOrEmpty(in.author.name))) {
+      throw new BadRequestException("Author must specify name and email");
+    }
     in.baseChange = Strings.nullToEmpty(in.baseChange).trim();
 
     PatchSet ps = psUtil.current(rsrc.getNotes());
@@ -166,7 +178,10 @@
 
       Timestamp now = TimeUtil.nowTs();
       IdentifiedUser me = user.get().asIdentifiedUser();
-      PersonIdent author = me.newCommitterIdent(now, serverTimeZone);
+      PersonIdent author =
+          in.author == null
+              ? me.newCommitterIdent(now, serverTimeZone)
+              : new PersonIdent(in.author.name, in.author.email, now, serverTimeZone);
       CodeReviewCommit newCommit =
           createMergeCommit(
               in,
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
index f79209d..5b44957 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.DeleteChangeMessageInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.Input;
@@ -93,13 +94,17 @@
     }
 
     ChangeMessageInfo updatedMessageInfo =
-        createUpdatedChangeMessageInfo(resource.getChangeId(), resource.getChangeMessageIndex());
+        createUpdatedChangeMessageInfo(
+            resource.getChangeResource().getId(),
+            resource.getChangeResource().getProject(),
+            resource.getChangeMessageIndex());
     return Response.created(updatedMessageInfo);
   }
 
-  private ChangeMessageInfo createUpdatedChangeMessageInfo(Change.Id id, int targetIdx)
-      throws PermissionBackendException {
-    List<ChangeMessage> messages = changeMessagesUtil.byChange(notesFactory.createChecked(id));
+  private ChangeMessageInfo createUpdatedChangeMessageInfo(
+      Change.Id cId, Project.NameKey project, int targetIdx) throws PermissionBackendException {
+    List<ChangeMessage> messages =
+        changeMessagesUtil.byChange(notesFactory.createChecked(project, cId));
     ChangeMessage updatedChangeMessage = messages.get(targetIdx);
     AccountLoader accountLoader = accountLoaderFactory.create(true);
     ChangeMessageInfo info = createChangeMessageInfo(updatedChangeMessage, accountLoader);
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteComment.java b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
index 8580229..044fd77 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
@@ -89,7 +89,9 @@
     }
 
     ChangeNotes updatedNotes =
-        notesFactory.createChecked(rsrc.getRevisionResource().getChange().getId());
+        notesFactory.createChecked(
+            rsrc.getRevisionResource().getProject(),
+            rsrc.getRevisionResource().getChangeResource().getId());
     List<HumanComment> changeComments = commentsUtil.publishedHumanCommentsByChange(updatedNotes);
     Optional<HumanComment> updatedComment =
         changeComments.stream().filter(c -> c.key.equals(rsrc.getComment().key)).findFirst();
diff --git a/java/com/google/gerrit/server/restapi/change/GetChange.java b/java/com/google/gerrit/server/restapi/change/GetChange.java
index c28741b..1ef3c4b 100644
--- a/java/com/google/gerrit/server/restapi/change/GetChange.java
+++ b/java/com/google/gerrit/server/restapi/change/GetChange.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -27,11 +29,13 @@
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
 import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.PluginDefinedAttributesFactories;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
+import java.util.Collection;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Map;
@@ -43,6 +47,7 @@
         DynamicOptions.BeanProvider {
   private final ChangeJson.Factory json;
   private final DynamicSet<ChangeAttributeFactory> attrFactories;
+  private final DynamicSet<ChangePluginDefinedInfoFactory> pdiFactories;
   private final EnumSet<ListChangesOption> options = EnumSet.noneOf(ListChangesOption.class);
   private final Map<String, DynamicBean> dynamicBeans = new HashMap<>();
 
@@ -57,9 +62,13 @@
   }
 
   @Inject
-  GetChange(ChangeJson.Factory json, DynamicSet<ChangeAttributeFactory> attrFactories) {
+  GetChange(
+      ChangeJson.Factory json,
+      DynamicSet<ChangeAttributeFactory> attrFactories,
+      DynamicSet<ChangePluginDefinedInfoFactory> pdiFactories) {
     this.json = json;
     this.attrFactories = attrFactories;
+    this.pdiFactories = pdiFactories;
   }
 
   @Override
@@ -82,11 +91,17 @@
   }
 
   private ChangeJson newChangeJson() {
-    return json.create(options, this::buildPluginInfo);
+    return json.create(options, this::buildPluginInfo, this::createPluginDefinedInfos);
   }
 
   private ImmutableList<PluginDefinedInfo> buildPluginInfo(ChangeData cd) {
     return PluginDefinedAttributesFactories.createAll(
         cd, this, Streams.stream(attrFactories.entries()));
   }
+
+  private ImmutableListMultimap<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+      Collection<ChangeData> cds) {
+    return PluginDefinedAttributesFactories.createAll(
+        cds, this, Streams.stream(pdiFactories.entries()));
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
index e3b433c..fa7c1f5 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
@@ -19,7 +19,6 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.HumanComment;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.ContextLineInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -84,8 +83,7 @@
 
   private ImmutableList<CommentInfo> getAsList(Iterable<HumanComment> comments, ChangeResource rsrc)
       throws PermissionBackendException {
-    ImmutableList<CommentInfo> commentInfos =
-        getCommentFormatter(rsrc.getProject()).formatAsList(comments);
+    ImmutableList<CommentInfo> commentInfos = getCommentFormatter(rsrc).formatAsList(comments);
     List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
     CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages, true);
     return commentInfos;
@@ -93,8 +91,7 @@
 
   private Map<String, List<CommentInfo>> getAsMap(
       Iterable<HumanComment> comments, ChangeResource rsrc) throws PermissionBackendException {
-    Map<String, List<CommentInfo>> commentInfosMap =
-        getCommentFormatter(rsrc.getProject()).format(comments);
+    Map<String, List<CommentInfo>> commentInfosMap = getCommentFormatter(rsrc).format(comments);
     List<CommentInfo> commentInfos =
         commentInfosMap.values().stream().flatMap(List::stream).collect(toList());
     List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
@@ -102,12 +99,14 @@
     return commentInfosMap;
   }
 
-  private CommentJson.HumanCommentFormatter getCommentFormatter(Project.NameKey project) {
+  private CommentJson.HumanCommentFormatter getCommentFormatter(ChangeResource rsrc) {
     return commentJson
         .get()
         .setFillAccounts(true)
         .setFillPatchSet(true)
-        .setEnableContext(includeContext, project)
+        .setFillCommentContext(includeContext)
+        .setProjectKey(rsrc.getProject())
+        .setChangeId(rsrc.getId())
         .newHumanCommentFormatter();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java b/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java
index 9b254f1..e92fe5c 100644
--- a/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java
@@ -18,7 +18,9 @@
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.common.CommentInfo;
+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.RestReadView;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.RevisionResource;
@@ -46,7 +48,10 @@
 
   @Override
   public Response<Map<String, List<CommentInfo>>> apply(RevisionResource revisionResource)
-      throws PermissionBackendException {
+      throws PermissionBackendException, RestApiException {
+    if (!revisionResource.getUser().isIdentifiedUser()) {
+      throw new AuthException("requires authentication; only authenticated users can have drafts");
+    }
     PatchSet targetPatchset = revisionResource.getPatchSet();
 
     List<HumanComment> draftComments =
diff --git a/java/com/google/gerrit/server/restapi/change/Module.java b/java/com/google/gerrit/server/restapi/change/Module.java
index 69e2788..681534c 100644
--- a/java/com/google/gerrit/server/restapi/change/Module.java
+++ b/java/com/google/gerrit/server/restapi/change/Module.java
@@ -47,6 +47,7 @@
 import com.google.gerrit.server.change.SetCherryPickOp;
 import com.google.gerrit.server.change.SetHashtagsOp;
 import com.google.gerrit.server.change.SetPrivateOp;
+import com.google.gerrit.server.change.SetTopicOp;
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.restapi.change.Reviewed.DeleteReviewed;
 import com.google.gerrit.server.restapi.change.Reviewed.PutReviewed;
@@ -218,9 +219,9 @@
     factory(SetAssigneeOp.Factory.class);
     factory(SetCherryPickOp.Factory.class);
     factory(SetHashtagsOp.Factory.class);
+    factory(SetTopicOp.Factory.class);
     factory(SetPrivateOp.Factory.class);
     factory(WorkInProgressOp.Factory.class);
-    factory(SetTopicOp.Factory.class);
     factory(AddToAttentionSetOp.Factory.class);
     factory(RemoveFromAttentionSetOp.Factory.class);
     factory(AttentionSetEmail.Factory.class);
diff --git a/java/com/google/gerrit/server/restapi/change/PutTopic.java b/java/com/google/gerrit/server/restapi/change/PutTopic.java
index 325b80c..3031781 100644
--- a/java/com/google/gerrit/server/restapi/change/PutTopic.java
+++ b/java/com/google/gerrit/server/restapi/change/PutTopic.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.SetTopicOp;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -60,7 +61,7 @@
       sanitizedInput.topic = sanitizedInput.topic.trim();
     }
 
-    SetTopicOp op = topicOpFactory.create(sanitizedInput);
+    SetTopicOp op = topicOpFactory.create(sanitizedInput.topic);
     try (BatchUpdate u =
         updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
       u.addOp(req.getId(), op);
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index 878e714..0fec476 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -165,7 +165,9 @@
     int cnt = queries.size();
     List<QueryResult<ChangeData>> results = queryProcessor.query(qb.parse(queries));
     List<List<ChangeInfo>> res =
-        json.create(options, queryProcessor.getAttributesFactory()).format(results);
+        json.create(
+                options, queryProcessor.getAttributesFactory(), queryProcessor.getInfosFactory())
+            .format(results);
     for (int n = 0; n < cnt; n++) {
       List<ChangeInfo> info = res.get(n);
       if (results.get(n).more() && !info.isEmpty()) {
diff --git a/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
index c4dd04e..7fe463e 100644
--- a/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.AttentionSetUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -69,13 +70,9 @@
     }
     input.user = Strings.nullToEmpty(input.user).trim();
     if (!input.user.isEmpty()) {
-      Account.Id attentionUserId = null;
-      try {
-        attentionUserId = accountResolver.resolve(input.user).asUnique().account().id();
-      } catch (AccountResolver.UnresolvableAccountException ex) {
-        throw new BadRequestException(
-            "The user specified in the input body couldn't be found.", ex);
-      }
+      Account.Id attentionUserId =
+          AttentionSetUtil.resolveAccount(
+              accountResolver, attentionResource.getChangeResource().getNotes(), input.user);
       if (attentionUserId.get() != attentionResource.getAccountId().get()) {
         throw new BadRequestException(
             "The field \"user\" must be empty, or must match the user specified in the URL.");
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
index 65c0cda..a1bd678 100644
--- a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -312,10 +312,14 @@
       throws BadRequestException, IOException, PermissionBackendException,
           UnprocessableEntityException, ConfigInvalidException {
     AttentionSetUtil.validateInput(add);
-    Account.Id attentionUserId =
-        getAccountIdAndValidateUser(changeNotes, add.user, accountsChangedInCommit);
-
-    addToAttentionSet(bu, changeNotes, attentionUserId, add.reason, false);
+    try {
+      Account.Id attentionUserId =
+          getAccountIdAndValidateUser(changeNotes, add.user, accountsChangedInCommit);
+      addToAttentionSet(bu, changeNotes, attentionUserId, add.reason, false);
+    } catch (AccountResolver.UnresolvableAccountException ex) {
+      // This happens only when the account doesn't exist. Silently ignore it. If we threw an error
+      // message here, then it would be possible to probe whether an account exists.
+    }
   }
 
   private void removeFromAttentionSet(
@@ -326,10 +330,14 @@
       throws BadRequestException, IOException, PermissionBackendException,
           UnprocessableEntityException, ConfigInvalidException {
     AttentionSetUtil.validateInput(remove);
-    Account.Id attentionUserId =
-        getAccountIdAndValidateUser(changeNotes, remove.user, accountsChangedInCommit);
-
-    removeFromAttentionSet(bu, changeNotes, attentionUserId, remove.reason, false);
+    try {
+      Account.Id attentionUserId =
+          getAccountIdAndValidateUser(changeNotes, remove.user, accountsChangedInCommit);
+      removeFromAttentionSet(bu, changeNotes, attentionUserId, remove.reason, false);
+    } catch (AccountResolver.UnresolvableAccountException ex) {
+      // This happens only when the account doesn't exist. Silently ignore it. If we threw an error
+      // message here, then it would be possible to probe whether an account exists.
+    }
   }
 
   private Account.Id getAccountId(ChangeNotes changeNotes, String user)
@@ -356,15 +364,21 @@
       ChangeNotes changeNotes, String user, Set<Account.Id> accountsChangedInCommit)
       throws ConfigInvalidException, IOException, PermissionBackendException,
           UnprocessableEntityException, BadRequestException {
-    Account.Id attentionUserId = getAccountId(changeNotes, user);
-    if (accountsChangedInCommit.contains(attentionUserId)) {
-      throw new BadRequestException(
-          String.format(
-              "%s can not be added/removed twice, and can not be added and "
-                  + "removed at the same time",
-              user));
+    try {
+      Account.Id attentionUserId = getAccountId(changeNotes, user);
+      if (accountsChangedInCommit.contains(attentionUserId)) {
+        throw new BadRequestException(
+            String.format(
+                "%s can not be added/removed twice, and can not be added and "
+                    + "removed at the same time",
+                user));
+      }
+      accountsChangedInCommit.add(attentionUserId);
+      return attentionUserId;
+    } catch (AccountResolver.UnresolvableAccountException ex) {
+      // This can only happen if this user can't see the account or the account doesn't exist.
+      // Silently modify the account's attention set anyway, if the account exists.
+      return accountResolver.resolveIgnoreVisibility(user).asUnique().account().id();
     }
-    accountsChangedInCommit.add(attentionUserId);
-    return attentionUserId;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
index ca39a57..cb91faa 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -358,7 +358,11 @@
         commitUtil.createRevertChange(changeNotes, user.get(), revertInput, timestamp);
     results.add(json.noOptions().format(changeNotes.getProjectName(), revertId));
     cherryPickInput.base =
-        changeNotesFactory.createChecked(revertId).getCurrentPatchSet().commitId().getName();
+        changeNotesFactory
+            .createChecked(changeNotes.getProjectName(), revertId)
+            .getCurrentPatchSet()
+            .commitId()
+            .getName();
   }
 
   private CherryPickInput createCherryPickInput(RevertInput revertInput) {
@@ -591,7 +595,7 @@
       // save the commit as base for next cherryPick of that branch
       cherryPickInput.base =
           changeNotesFactory
-              .createChecked(cherryPickResult.changeId())
+              .createChecked(ctx.getProject(), cherryPickResult.changeId())
               .getCurrentPatchSet()
               .commitId()
               .getName();
@@ -612,7 +616,9 @@
     @Override
     public void postUpdate(Context ctx) throws Exception {
       changeReverted.fire(
-          change, changeNotesFactory.createChecked(revertChangeId).getChange(), ctx.getWhen());
+          change,
+          changeNotesFactory.createChecked(ctx.getProject(), revertChangeId).getChange(),
+          ctx.getWhen());
       try {
         RevertedSender emailSender = revertedSenderFactory.create(ctx.getProject(), change.getId());
         emailSender.setFrom(ctx.getAccountId());
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index e77bfe7..790b2db 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -30,8 +30,10 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -66,12 +68,14 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
@@ -373,8 +377,15 @@
 
   public Collection<ChangeData> unmergeableChanges(ChangeSet cs) throws IOException {
     Set<ChangeData> mergeabilityMap = new HashSet<>();
+    Set<ObjectId> outDatedPatchsets = new HashSet<>();
     for (ChangeData change : cs.changes()) {
       mergeabilityMap.add(change);
+      // Add all the patchsets commit ids except the current patchset.
+      outDatedPatchsets.addAll(
+          change.notes().getPatchSets().values().stream()
+              .map(p -> p.commitId())
+              .collect(Collectors.toSet()));
+      outDatedPatchsets.remove(change.currentPatchSet().commitId());
     }
 
     ListMultimap<BranchNameKey, ChangeData> cbb = cs.changesByBranch();
@@ -388,12 +399,17 @@
           allParents.add(parent.getId());
         }
       }
-
       for (ChangeData change : targetBranch) {
+
         RevCommit commit = commits.get(change.getId());
         boolean isMergeCommit = commit.getParentCount() > 1;
         boolean isLastInChain = !allParents.contains(commit.getId());
-
+        if (Arrays.stream(commit.getParents()).anyMatch(c -> outDatedPatchsets.contains(c.getId()))
+            && !isCherryPickSubmit(change)) {
+          // Found a parent that depends on an outdated patchset and the submit strategy is not
+          // cherry-pick.
+          continue;
+        }
         // Recheck mergeability rather than using value stored in the index,
         // which may be stale.
         // TODO(dborowitz): This is ugly; consider providing a way to not read
@@ -419,6 +435,11 @@
     return mergeabilityMap;
   }
 
+  private boolean isCherryPickSubmit(ChangeData changeData) {
+    SubmitTypeRecord submitTypeRecord = changeData.submitTypeRecord();
+    return submitTypeRecord.isOk() && submitTypeRecord.type == SubmitType.CHERRY_PICK;
+  }
+
   private HashMap<Change.Id, RevCommit> findCommits(
       Collection<ChangeData> changes, Project.NameKey project) throws IOException {
     HashMap<Change.Id, RevCommit> commits = new HashMap<>();
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index d08ee50..780c60a 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -238,8 +238,9 @@
     info.mergeabilityComputationBehavior =
         MergeabilityComputationBehavior.fromConfig(config).name();
     info.enableAttentionSet =
-        toBoolean(this.config.getBoolean("change", null, "enableAttentionSet", false));
-    info.enableAssignee = toBoolean(this.config.getBoolean("change", null, "enableAssignee", true));
+        toBoolean(this.config.getBoolean("change", null, "enableAttentionSet", true));
+    info.enableAssignee =
+        toBoolean(this.config.getBoolean("change", null, "enableAssignee", false));
     return info;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/project/CheckAccess.java b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
index 037a953..4ef724a 100644
--- a/java/com/google/gerrit/server/restapi/project/CheckAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.permissions.DefaultPermissionMappings;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -73,60 +74,74 @@
       throw new BadRequestException("input requires 'account'");
     }
 
-    Account.Id match = accountResolver.resolve(input.account).asUnique().account().id();
+    try (TraceContext traceContext = TraceContext.open()) {
+      traceContext.enableAclLogging();
 
-    AccessCheckInfo info = new AccessCheckInfo();
-    try {
-      permissionBackend
-          .absentUser(match)
-          .project(rsrc.getNameKey())
-          .check(ProjectPermission.ACCESS);
-    } catch (AuthException e) {
-      info.message = String.format("user %s cannot see project %s", match, rsrc.getName());
-      info.status = HttpServletResponse.SC_FORBIDDEN;
-      return Response.ok(info);
-    }
+      Account.Id match = accountResolver.resolve(input.account).asUnique().account().id();
 
-    RefPermission refPerm;
-    if (!Strings.isNullOrEmpty(input.permission)) {
-      if (Strings.isNullOrEmpty(input.ref)) {
-        throw new BadRequestException("must set 'ref' when specifying 'permission'");
-      }
-      Optional<RefPermission> rp = DefaultPermissionMappings.refPermission(input.permission);
-      if (!rp.isPresent()) {
-        throw new BadRequestException(
-            String.format("'%s' is not recognized as ref permission", input.permission));
-      }
-
-      refPerm = rp.get();
-    } else {
-      refPerm = RefPermission.READ;
-    }
-
-    if (!Strings.isNullOrEmpty(input.ref)) {
       try {
         permissionBackend
             .absentUser(match)
-            .ref(BranchNameKey.create(rsrc.getNameKey(), input.ref))
-            .check(refPerm);
+            .project(rsrc.getNameKey())
+            .check(ProjectPermission.ACCESS);
       } catch (AuthException e) {
-        info.status = HttpServletResponse.SC_FORBIDDEN;
-        info.message =
-            String.format(
-                "user %s lacks permission %s for %s in project %s",
-                match, input.permission, input.ref, rsrc.getName());
-        return Response.ok(info);
+        return Response.ok(
+            createInfo(
+                traceContext,
+                HttpServletResponse.SC_FORBIDDEN,
+                String.format("user %s cannot see project %s", match, rsrc.getName())));
       }
-    } else {
-      // We say access is okay if there are no refs, but this warrants a warning,
-      // as access denied looks the same as no branches to the user.
-      try (Repository repo = gitRepositoryManager.openRepository(rsrc.getNameKey())) {
-        if (repo.getRefDatabase().getRefsByPrefix(REFS_HEADS).isEmpty()) {
-          info.message = "access is OK, but repository has no branches under refs/heads/";
+
+      RefPermission refPerm;
+      if (!Strings.isNullOrEmpty(input.permission)) {
+        if (Strings.isNullOrEmpty(input.ref)) {
+          throw new BadRequestException("must set 'ref' when specifying 'permission'");
+        }
+        Optional<RefPermission> rp = DefaultPermissionMappings.refPermission(input.permission);
+        if (!rp.isPresent()) {
+          throw new BadRequestException(
+              String.format("'%s' is not recognized as ref permission", input.permission));
+        }
+
+        refPerm = rp.get();
+      } else {
+        refPerm = RefPermission.READ;
+      }
+
+      String message = null;
+      if (!Strings.isNullOrEmpty(input.ref)) {
+        try {
+          permissionBackend
+              .absentUser(match)
+              .ref(BranchNameKey.create(rsrc.getNameKey(), input.ref))
+              .check(refPerm);
+        } catch (AuthException e) {
+          return Response.ok(
+              createInfo(
+                  traceContext,
+                  HttpServletResponse.SC_FORBIDDEN,
+                  String.format(
+                      "user %s lacks permission %s for %s in project %s",
+                      match, input.permission, input.ref, rsrc.getName())));
+        }
+      } else {
+        // We say access is okay if there are no refs, but this warrants a warning,
+        // as access denied looks the same as no branches to the user.
+        try (Repository repo = gitRepositoryManager.openRepository(rsrc.getNameKey())) {
+          if (repo.getRefDatabase().getRefsByPrefix(REFS_HEADS).isEmpty()) {
+            message = "access is OK, but repository has no branches under refs/heads/";
+          }
         }
       }
+      return Response.ok(createInfo(traceContext, HttpServletResponse.SC_OK, message));
     }
-    info.status = HttpServletResponse.SC_OK;
-    return Response.ok(info);
+  }
+
+  private AccessCheckInfo createInfo(TraceContext traceContext, int statusCode, String message) {
+    AccessCheckInfo info = new AccessCheckInfo();
+    info.status = statusCode;
+    info.message = message;
+    info.debugLogs = traceContext.getAclLogRecords();
+    return info;
   }
 }
diff --git a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
index afa9d1a..de9374e 100644
--- a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
+++ b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.GroupUuid;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -91,7 +92,7 @@
   @Override
   public void create() throws IOException, ConfigInvalidException {
     GroupReference admins = createGroupReference("Administrators");
-    GroupReference serviceUsers = createGroupReference("Service Users");
+    GroupReference serviceUsers = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
 
     AllProjectsInput allProjectsInput =
         AllProjectsInput.builder()
diff --git a/java/com/google/gerrit/server/schema/Schema_184.java b/java/com/google/gerrit/server/schema/Schema_184.java
index d0ca3d0..c14ae8a 100644
--- a/java/com/google/gerrit/server/schema/Schema_184.java
+++ b/java/com/google/gerrit/server/schema/Schema_184.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.git.RefUpdateUtil;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -44,7 +45,7 @@
   @Override
   public void upgrade(Arguments args, UpdateUI ui) throws Exception {
     try (Repository allUsersRepo = args.repoManager.openRepository(args.allUsers)) {
-      AccountGroup.NameKey newName = AccountGroup.nameKey("Service Users");
+      AccountGroup.NameKey newName = AccountGroup.nameKey(ServiceUserClassifier.SERVICE_USERS);
       Optional<GroupReference> nonInteractiveUsers =
           GroupNameNotes.loadAllGroups(allUsersRepo).stream()
               .filter(g -> g.getName().equals("Non-Interactive Users"))
diff --git a/java/com/google/gerrit/server/submit/ConfiguredSubscriptionGraphFactory.java b/java/com/google/gerrit/server/submit/ConfiguredSubscriptionGraphFactory.java
index 00e2443..3f3b544 100644
--- a/java/com/google/gerrit/server/submit/ConfiguredSubscriptionGraphFactory.java
+++ b/java/com/google/gerrit/server/submit/ConfiguredSubscriptionGraphFactory.java
@@ -46,9 +46,8 @@
       throws SubmoduleConflictException {
     if (cfg.getBoolean("submodule", "enableSuperProjectSubscriptions", true)) {
       return subscriptionGraphFactory.compute(updatedBranches, orm);
-    } else {
-      logger.atFine().log("Updating superprojects disabled");
-      return SubscriptionGraph.createEmptyGraph(ImmutableSet.copyOf(updatedBranches));
     }
+    logger.atFine().log("Updating superprojects disabled");
+    return SubscriptionGraph.createEmptyGraph(ImmutableSet.copyOf(updatedBranches));
   }
 }
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 58b0c8e..01c7b75 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -232,7 +232,7 @@
   private final SubmitStrategyFactory submitStrategyFactory;
   private final SubscriptionGraph.Factory subscriptionGraphFactory;
   private final SubmoduleCommits.Factory submoduleCommitsFactory;
-  private final SubmissionListener superprojectUpdateSubmissionListener;
+  private final ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners;
   private final Provider<MergeOpRepoManager> ormProvider;
   private final NotifyResolver notifyResolver;
   private final RetryHelper retryHelper;
@@ -264,7 +264,8 @@
       SubmitStrategyFactory submitStrategyFactory,
       SubmoduleCommits.Factory submoduleCommitsFactory,
       SubscriptionGraph.Factory subscriptionGraphFactory,
-      @SuperprojectUpdateOnSubmission SubmissionListener superprojectUpdateSubmissionListener,
+      @SuperprojectUpdateOnSubmission
+          ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners,
       Provider<MergeOpRepoManager> ormProvider,
       NotifyResolver notifyResolver,
       TopicMetrics topicMetrics,
@@ -279,7 +280,7 @@
     this.submitStrategyFactory = submitStrategyFactory;
     this.submoduleCommitsFactory = submoduleCommitsFactory;
     this.subscriptionGraphFactory = subscriptionGraphFactory;
-    this.superprojectUpdateSubmissionListener = superprojectUpdateSubmissionListener;
+    this.superprojectUpdateSubmissionListeners = superprojectUpdateSubmissionListeners;
     this.ormProvider = ormProvider;
     this.notifyResolver = notifyResolver;
     this.retryHelper = retryHelper;
@@ -352,7 +353,7 @@
     }
     if (record.requirements != null) {
       record.requirements.stream()
-          .map(SubmitRequirement::fallbackText)
+          .map(MergeOp::describeSubmitRequirement)
           .forEach(blockingConditions::add);
     }
     return Joiner.on("; ").join(blockingConditions);
@@ -388,6 +389,10 @@
     return Joiner.on("; ").join(labelResults);
   }
 
+  private static String describeSubmitRequirement(SubmitRequirement submitRequirement) {
+    return String.format("Submit requirement not fulfilled: %s", submitRequirement.fallbackText());
+  }
+
   private void checkSubmitRulesAndState(ChangeSet cs, boolean allowMerged)
       throws ResourceConflictException {
     checkArgument(
@@ -498,7 +503,7 @@
         }
 
         SubmissionExecutor submissionExecutor =
-            new SubmissionExecutor(dryrun, superprojectUpdateSubmissionListener);
+            new SubmissionExecutor(dryrun, superprojectUpdateSubmissionListeners);
         RetryTracker retryTracker = new RetryTracker();
         retryHelper
             .changeUpdate(
@@ -864,7 +869,8 @@
 
       MergeValidators mergeValidators = mergeValidatorsFactory.create();
       try {
-        mergeValidators.validatePreMerge(or.repo, commit, or.project, destBranch, ps.id(), caller);
+        mergeValidators.validatePreMerge(
+            or.repo, or.rw, commit, or.project, destBranch, ps.id(), caller);
       } catch (MergeValidationException mve) {
         commitStatus.problem(changeId, mve.getMessage());
         continue;
diff --git a/java/com/google/gerrit/server/submit/MergeSuperSet.java b/java/com/google/gerrit/server/submit/MergeSuperSet.java
index 93c78a8..67f2907 100644
--- a/java/com/google/gerrit/server/submit/MergeSuperSet.java
+++ b/java/com/google/gerrit/server/submit/MergeSuperSet.java
@@ -18,19 +18,16 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Strings;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.logging.TraceContext;
-import com.google.gerrit.server.notedb.ChangeNotes;
 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.plugincontext.PluginContext;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -55,8 +52,6 @@
  * included.
  */
 public class MergeSuperSet {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private final ChangeData.Factory changeDataFactory;
   private final Provider<InternalChangeQuery> queryProvider;
   private final Provider<MergeOpRepoManager> repoManagerProvider;
@@ -64,7 +59,6 @@
   private final PermissionBackend permissionBackend;
   private final Config cfg;
   private final ProjectCache projectCache;
-  private final ChangeNotes.Factory notesFactory;
 
   private MergeOpRepoManager orm;
   private boolean closeOrm;
@@ -77,8 +71,7 @@
       Provider<MergeOpRepoManager> repoManagerProvider,
       DynamicItem<MergeSuperSetComputation> mergeSuperSetComputation,
       PermissionBackend permissionBackend,
-      ProjectCache projectCache,
-      ChangeNotes.Factory notesFactory) {
+      ProjectCache projectCache) {
     this.cfg = cfg;
     this.changeDataFactory = changeDataFactory;
     this.queryProvider = queryProvider;
@@ -86,7 +79,6 @@
     this.mergeSuperSetComputation = mergeSuperSetComputation;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
-    this.notesFactory = notesFactory;
   }
 
   public static boolean wholeTopicEnabled(Config config) {
@@ -212,24 +204,8 @@
     if (!projectCache.get(cd.project()).map(ProjectState::statePermitsRead).orElse(false)) {
       return false;
     }
-
-    ChangeNotes notes;
     try {
-      notes = cd.notes();
-    } catch (NoSuchChangeException e) {
-      // The change was found in the index but is missing in NoteDb.
-      // This can happen in systems with multiple primary nodes when the replication of the index
-      // documents is faster than the replication of the Git data.
-      // Instead of failing, create the change notes from the index data so that the read permission
-      // check can be performed successfully.
-      logger.atWarning().log(
-          "Got change %d of project %s from index, but couldn't find it in NoteDb",
-          cd.getId().get(), cd.project().get());
-      notes = notesFactory.createFromIndexedChange(cd.change());
-    }
-
-    try {
-      permissionBackend.user(user).change(notes).check(ChangePermission.READ);
+      permissionBackend.user(user).change(cd).check(ChangePermission.READ);
       return true;
     } catch (AuthException e) {
       return false;
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index 3b77dd9..3430047 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -461,9 +461,12 @@
       // If we naively execute postUpdate even if the change is already merged when updateChange
       // being, then we are subject to a race where postUpdate steps are run twice if two submit
       // processes run at the same time.
-      logger.atFine().log("Skipping post-update steps for change %s", getId());
+      logger.atFine().log(
+          "Skipping post-update steps for change %s; submitter is %s", getId(), submitter);
       return;
     }
+    logger.atFine().log(
+        "Begin post-update steps for change %s; submitter is %s", getId(), submitter);
     postUpdateImpl(ctx);
 
     if (command != null) {
@@ -483,6 +486,9 @@
       }
     }
 
+    logger.atFine().log(
+        "Begin sending emails for submitting change %s; submitter is %s", getId(), submitter);
+
     // Assume the change must have been merged at this point, otherwise we would
     // have failed fast in one of the other steps.
     try {
diff --git a/java/com/google/gerrit/server/update/SubmissionExecutor.java b/java/com/google/gerrit/server/update/SubmissionExecutor.java
index 5a3a789..39eda58 100644
--- a/java/com/google/gerrit/server/update/SubmissionExecutor.java
+++ b/java/com/google/gerrit/server/update/SubmissionExecutor.java
@@ -17,7 +17,6 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.submit.MergeOpRepoManager;
-import java.util.Arrays;
 import java.util.Collection;
 import java.util.Optional;
 import java.util.stream.Collectors;
@@ -28,14 +27,9 @@
   private final boolean dryrun;
   private ImmutableList<BatchUpdateListener> additionalListeners = ImmutableList.of();
 
-  public SubmissionExecutor(
-      boolean dryrun, SubmissionListener listener, SubmissionListener... otherListeners) {
+  public SubmissionExecutor(boolean dryrun, ImmutableList<SubmissionListener> submissionListeners) {
     this.dryrun = dryrun;
-    this.submissionListeners =
-        ImmutableList.<SubmissionListener>builder()
-            .add(listener)
-            .addAll(Arrays.asList(otherListeners))
-            .build();
+    this.submissionListeners = submissionListeners;
     if (dryrun) {
       submissionListeners.forEach(SubmissionListener::setDryrun);
     }
diff --git a/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java b/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
index dffdff0..4c65c80 100644
--- a/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
+++ b/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.server.submit.MergeOpRepoManager;
 import com.google.gerrit.server.submit.SubmoduleOp;
 import com.google.inject.AbstractModule;
+import com.google.inject.Provides;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
@@ -39,11 +40,11 @@
   private boolean dryrun;
 
   public static class Module extends AbstractModule {
-    @Override
-    protected void configure() {
-      bind(SubmissionListener.class)
-          .annotatedWith(SuperprojectUpdateOnSubmission.class)
-          .to(SuperprojectUpdateSubmissionListener.class);
+    @Provides
+    @SuperprojectUpdateOnSubmission
+    ImmutableList<SubmissionListener> provideSubmissionListeners(
+        SuperprojectUpdateSubmissionListener listener) {
+      return ImmutableList.of(listener);
     }
   }
 
diff --git a/java/com/google/gerrit/server/util/AttentionSetUtil.java b/java/com/google/gerrit/server/util/AttentionSetUtil.java
index 62cad3f..26c862d 100644
--- a/java/com/google/gerrit/server/util/AttentionSetUtil.java
+++ b/java/com/google/gerrit/server/util/AttentionSetUtil.java
@@ -16,11 +16,16 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import java.io.IOException;
 import java.util.Collection;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /** Common helpers for dealing with attention set data structures. */
 public class AttentionSetUtil {
@@ -49,5 +54,45 @@
     }
   }
 
+  /**
+   * Returns the {@code Account.Id} of {@code user} if the user is active on the change, and exists.
+   * If the user doesn't exist or is not active on the change, the same exception is thrown to
+   * disallow probing for account existence based on exception type.
+   */
+  public static Account.Id resolveAccount(
+      AccountResolver accountResolver, ChangeNotes changeNotes, String user)
+      throws ConfigInvalidException, IOException, BadRequestException {
+    // We will throw this exception if the account doesn't exist, or if the account is not active.
+    // This is purposely the same exception so that users can't probe for account existence based on
+    // the thrown exception.
+    BadRequestException possibleExceptionForNotFoundOrInactiveAccount =
+        new BadRequestException(
+            String.format(
+                "%s doesn't exist or is not active on the change as an owner, uploader, "
+                    + "reviewer, or cc so they can't be added to the attention set",
+                user));
+    Account.Id attentionUserId;
+    try {
+      attentionUserId = accountResolver.resolveIgnoreVisibility(user).asUnique().account().id();
+    } catch (AccountResolver.UnresolvableAccountException ex) {
+      possibleExceptionForNotFoundOrInactiveAccount.initCause(ex);
+      throw possibleExceptionForNotFoundOrInactiveAccount;
+    }
+    if (!isActiveOnTheChange(changeNotes, attentionUserId)) {
+      throw possibleExceptionForNotFoundOrInactiveAccount;
+    }
+    return attentionUserId;
+  }
+
+  /**
+   * Returns whether {@code attentionUserId} is active on a change. Activity is defined as being a
+   * part of the reviewers, an uploader, or an owner of a change.
+   */
+  private static boolean isActiveOnTheChange(ChangeNotes changeNotes, Account.Id attentionUserId) {
+    return changeNotes.getChange().getOwner().equals(attentionUserId)
+        || changeNotes.getCurrentPatchSet().uploader().equals(attentionUserId)
+        || changeNotes.getReviewers().all().stream().anyMatch(id -> id.equals(attentionUserId));
+  }
+
   private AttentionSetUtil() {}
 }
diff --git a/java/com/google/gerrit/server/util/PluginLogFile.java b/java/com/google/gerrit/server/util/PluginLogFile.java
index de8b3aa..8235623 100644
--- a/java/com/google/gerrit/server/util/PluginLogFile.java
+++ b/java/com/google/gerrit/server/util/PluginLogFile.java
@@ -40,8 +40,13 @@
   public void start() {
     AsyncAppender asyncAppender = systemLog.createAsyncAppender(logName, layout, true, true);
     Logger logger = LogManager.getLogger(logName);
-    logger.removeAppender(logName);
-    logger.addAppender(asyncAppender);
+    if (logger.getAppender(logName) == null) {
+      synchronized (this) {
+        if (logger.getAppender(logName) == null) {
+          logger.addAppender(asyncAppender);
+        }
+      }
+    }
     logger.setAdditivity(false);
   }
 
diff --git a/java/com/google/gerrit/sshd/AbstractGitCommand.java b/java/com/google/gerrit/sshd/AbstractGitCommand.java
index 8bf6cd5..b3753fd 100644
--- a/java/com/google/gerrit/sshd/AbstractGitCommand.java
+++ b/java/com/google/gerrit/sshd/AbstractGitCommand.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.AccessPath;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -52,6 +53,7 @@
 
   @Override
   public void start(ChannelSession channel, Environment env) {
+    enableGracefulStop();
     String gitProtocol = env.getEnv().get(GIT_PROTOCOL);
     if (gitProtocol != null) {
       extraParameters = gitProtocol.split(":");
@@ -63,8 +65,8 @@
       startThread(
           new ProjectCommandRunnable() {
             @Override
-            public void executeParseCommand() throws Exception {
-              parseCommandLine();
+            public void executeParseCommand(DynamicOptions pluginOptions) throws Exception {
+              parseCommandLine(pluginOptions);
             }
 
             @Override
diff --git a/java/com/google/gerrit/sshd/BaseCommand.java b/java/com/google/gerrit/sshd/BaseCommand.java
index ab1f062..0dbae0a 100644
--- a/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/java/com/google/gerrit/sshd/BaseCommand.java
@@ -101,9 +101,9 @@
   @PluginName
   private String pluginName;
 
-  @Inject private Injector injector;
+  @Inject protected Injector injector;
 
-  @Inject private DynamicMap<DynamicOptions.DynamicBean> dynamicBeans = null;
+  @Inject protected DynamicMap<DynamicOptions.DynamicBean> dynamicBeans = null;
 
   /** The task, as scheduled on a worker thread. */
   private final AtomicReference<Future<?>> task;
@@ -211,12 +211,13 @@
    *
    * <p>This method must be explicitly invoked to cause a parse.
    *
+   * @param pluginOptions which helps to define and parse options provided from plugins
    * @throws UnloggedFailure if the command line arguments were invalid.
    * @see Option
    * @see Argument
    */
-  protected void parseCommandLine() throws UnloggedFailure {
-    parseCommandLine(this);
+  protected void parseCommandLine(DynamicOptions pluginOptions) throws UnloggedFailure {
+    parseCommandLine(this, pluginOptions);
   }
 
   /**
@@ -226,13 +227,14 @@
    *
    * @param options object whose fields declare Option and Argument annotations to describe the
    *     parameters of the command. Usually {@code this}.
+   * @param pluginOptions which helps to define and parse options provided from plugins
    * @throws UnloggedFailure if the command line arguments were invalid.
    * @see Option
    * @see Argument
    */
-  protected void parseCommandLine(Object options) throws UnloggedFailure {
+  protected void parseCommandLine(Object options, DynamicOptions pluginOptions)
+      throws UnloggedFailure {
     final CmdLineParser clp = newCmdLineParser(options);
-    DynamicOptions pluginOptions = new DynamicOptions(options, injector, dynamicBeans);
     pluginOptions.parseDynamicBeans(clp);
     pluginOptions.setDynamicBeans();
     pluginOptions.onBeanParseStart();
@@ -403,6 +405,10 @@
     }
   }
 
+  protected void enableGracefulStop() {
+    context.getSession().setGracefulStop(true);
+  }
+
   protected String getTaskDescription() {
     String[] ta = getTrimmedArguments();
     if (ta != null) {
@@ -460,13 +466,17 @@
           context.started = TimeUtil.nowMs();
           thisThread.setName("SSH " + taskName);
 
-          if (thunk instanceof ProjectCommandRunnable) {
-            ((ProjectCommandRunnable) thunk).executeParseCommand();
-            projectName = ((ProjectCommandRunnable) thunk).getProjectName();
-          }
-
           try {
-            thunk.run();
+            if (thunk instanceof ProjectCommandRunnable) {
+              try (DynamicOptions pluginOptions =
+                  new DynamicOptions(BaseCommand.this, injector, dynamicBeans)) {
+                ((ProjectCommandRunnable) thunk).executeParseCommand(pluginOptions);
+                projectName = ((ProjectCommandRunnable) thunk).getProjectName();
+                thunk.run();
+              }
+            } else {
+              thunk.run();
+            }
           } catch (NoSuchProjectException e) {
             throw new UnloggedFailure(1, e.getMessage());
           } catch (NoSuchChangeException e) {
@@ -529,7 +539,7 @@
   public interface ProjectCommandRunnable extends CommandRunnable {
     // execute parser command before running, in order to be able to retrieve
     // project name
-    void executeParseCommand() throws Exception;
+    void executeParseCommand(DynamicOptions pluginOptions) throws Exception;
 
     Project.NameKey getProjectName();
   }
diff --git a/java/com/google/gerrit/sshd/ChangeArgumentParser.java b/java/com/google/gerrit/sshd/ChangeArgumentParser.java
index 4a1489739..92019ad 100644
--- a/java/com/google/gerrit/sshd/ChangeArgumentParser.java
+++ b/java/com/google/gerrit/sshd/ChangeArgumentParser.java
@@ -116,7 +116,7 @@
   }
 
   private List<ChangeNotes> changeFromNotesFactory(String id) throws UnloggedFailure {
-    return changeNotesFactory.create(parseId(id));
+    return changeNotesFactory.createUsingIndexLookup(parseId(id));
   }
 
   private List<Change.Id> parseId(String id) throws UnloggedFailure {
diff --git a/java/com/google/gerrit/sshd/DispatchCommand.java b/java/com/google/gerrit/sshd/DispatchCommand.java
index 7db65bd..a45cd31 100644
--- a/java/com/google/gerrit/sshd/DispatchCommand.java
+++ b/java/com/google/gerrit/sshd/DispatchCommand.java
@@ -20,6 +20,7 @@
 import com.google.common.util.concurrent.Atomics;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.args4j.SubcommandHandler;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -71,8 +72,9 @@
 
   @Override
   public void start(ChannelSession channel, Environment env) throws IOException {
-    try {
-      parseCommandLine();
+    try (DynamicOptions pluginOptions =
+        new DynamicOptions(DispatchCommand.this, injector, dynamicBeans)) {
+      parseCommandLine(pluginOptions);
       if (Strings.isNullOrEmpty(commandName)) {
         StringWriter msg = new StringWriter();
         msg.write(usage());
diff --git a/java/com/google/gerrit/sshd/SshCommand.java b/java/com/google/gerrit/sshd/SshCommand.java
index e60ba6d..3ef7061 100644
--- a/java/com/google/gerrit/sshd/SshCommand.java
+++ b/java/com/google/gerrit/sshd/SshCommand.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.AccessPath;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.RequestInfo;
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -49,19 +50,22 @@
   public void start(ChannelSession channel, Environment env) throws IOException {
     startThread(
         () -> {
-          parseCommandLine();
-          stdout = toPrintWriter(out);
-          stderr = toPrintWriter(err);
-          try (TraceContext traceContext = enableTracing();
-              PerformanceLogContext performanceLogContext =
-                  new PerformanceLogContext(config, performanceLoggers)) {
-            RequestInfo requestInfo =
-                RequestInfo.builder(RequestInfo.RequestType.SSH, user, traceContext).build();
-            requestListeners.runEach(l -> l.onRequest(requestInfo));
-            SshCommand.this.run();
-          } finally {
-            stdout.flush();
-            stderr.flush();
+          try (DynamicOptions pluginOptions =
+              new DynamicOptions(SshCommand.this, injector, dynamicBeans)) {
+            parseCommandLine(pluginOptions);
+            stdout = toPrintWriter(out);
+            stderr = toPrintWriter(err);
+            try (TraceContext traceContext = enableTracing();
+                PerformanceLogContext performanceLogContext =
+                    new PerformanceLogContext(config, performanceLoggers)) {
+              RequestInfo requestInfo =
+                  RequestInfo.builder(RequestInfo.RequestType.SSH, user, traceContext).build();
+              requestListeners.runEach(l -> l.onRequest(requestInfo));
+              SshCommand.this.run();
+            } finally {
+              stdout.flush();
+              stderr.flush();
+            }
           }
         },
         AccessPath.SSH_COMMAND);
diff --git a/java/com/google/gerrit/sshd/SshDaemon.java b/java/com/google/gerrit/sshd/SshDaemon.java
index c43bf91..c14ebd8 100644
--- a/java/com/google/gerrit/sshd/SshDaemon.java
+++ b/java/com/google/gerrit/sshd/SshDaemon.java
@@ -90,6 +90,7 @@
 import org.apache.sshd.common.random.Random;
 import org.apache.sshd.common.random.SingletonRandomFactory;
 import org.apache.sshd.common.session.Session;
+import org.apache.sshd.common.session.helpers.AbstractSession;
 import org.apache.sshd.common.session.helpers.DefaultUnknownChannelReferenceHandler;
 import org.apache.sshd.common.util.buffer.Buffer;
 import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
@@ -369,14 +370,24 @@
     Collection<IoSession> ioSessions = daemonAcceptor.getManagedSessions().values();
     CountDownLatch allSessionsClosed = new CountDownLatch(ioSessions.size());
     for (IoSession io : ioSessions) {
-      logger.atFine().log("Waiting for session %s to stop.", io.getId());
-      io.addCloseFutureListener(
-          new SshFutureListener<CloseFuture>() {
-            @Override
-            public void operationComplete(CloseFuture future) {
-              allSessionsClosed.countDown();
-            }
-          });
+      AbstractSession serverSession = AbstractSession.getSession(io, true);
+      SshSession sshSession =
+          serverSession != null ? serverSession.getAttribute(SshSession.KEY) : null;
+      if (sshSession != null && sshSession.requiresGracefulStop()) {
+        logger.atFine().log("Waiting for session %s to stop.", io.getId());
+        io.addCloseFutureListener(
+            new SshFutureListener<CloseFuture>() {
+              @Override
+              public void operationComplete(CloseFuture future) {
+                logger.atFine().log("Session %s was stopped.", io.getId());
+                allSessionsClosed.countDown();
+              }
+            });
+      } else {
+        logger.atFine().log("Stopping session %s immediately.", io.getId());
+        io.close(true);
+        allSessionsClosed.countDown();
+      }
     }
     try {
       if (!allSessionsClosed.await(gracefulStopTimeout, TimeUnit.SECONDS)) {
diff --git a/java/com/google/gerrit/sshd/SshSession.java b/java/com/google/gerrit/sshd/SshSession.java
index d6ecc73..b39eaed 100644
--- a/java/com/google/gerrit/sshd/SshSession.java
+++ b/java/com/google/gerrit/sshd/SshSession.java
@@ -35,6 +35,8 @@
   private volatile String authError;
   private volatile String peerAgent;
 
+  private volatile boolean gracefulStop = false;
+
   SshSession(int sessionId, SocketAddress peer) {
     this.sessionId = sessionId;
     this.remoteAddress = peer;
@@ -58,6 +60,14 @@
     return sessionId;
   }
 
+  public boolean requiresGracefulStop() {
+    return gracefulStop;
+  }
+
+  public void setGracefulStop(boolean gracefulStop) {
+    this.gracefulStop = gracefulStop;
+  }
+
   /** Identity of the authenticated user account on the socket. */
   public CurrentUser getUser() {
     return identity;
diff --git a/java/com/google/gerrit/sshd/SuExec.java b/java/com/google/gerrit/sshd/SuExec.java
index ea163d5..bf785bb 100644
--- a/java/com/google/gerrit/sshd/SuExec.java
+++ b/java/com/google/gerrit/sshd/SuExec.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PeerDaemonUser;
 import com.google.gerrit.server.config.AuthConfig;
@@ -92,9 +93,9 @@
 
   @Override
   public void start(ChannelSession channel, Environment env) throws IOException {
-    try {
+    try (DynamicOptions pluginOptions = new DynamicOptions(SuExec.this, injector, dynamicBeans)) {
       checkCanRunAs();
-      parseCommandLine();
+      parseCommandLine(pluginOptions);
 
       final Context ctx = callingContext.subContext(newSession(), join(args));
       final Context old = sshScope.set(ctx);
diff --git a/java/com/google/gerrit/sshd/commands/AproposCommand.java b/java/com/google/gerrit/sshd/commands/AproposCommand.java
index d3db70d..e7a88a1 100644
--- a/java/com/google/gerrit/sshd/commands/AproposCommand.java
+++ b/java/com/google/gerrit/sshd/commands/AproposCommand.java
@@ -39,6 +39,7 @@
 
   @Override
   public void run() throws Exception {
+    enableGracefulStop();
     try {
       List<QueryDocumentationExecutor.DocResult> res = searcher.doQuery(q);
       for (DocResult docResult : res) {
diff --git a/java/com/google/gerrit/sshd/commands/BanCommitCommand.java b/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
index ee6f635..134fb03 100644
--- a/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
+++ b/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
@@ -63,6 +63,7 @@
 
   @Override
   protected void run() throws Failure {
+    enableGracefulStop();
     try {
       BanCommitInput input =
           BanCommitInput.fromCommits(Lists.transform(commitsToBan, ObjectId::getName));
diff --git a/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java b/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java
index d70c153..ad8e20d 100644
--- a/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java
+++ b/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java
@@ -59,6 +59,7 @@
 
   @Override
   protected final void run() throws UnloggedFailure {
+    enableGracefulStop();
     try {
       RevisionResource revision =
           revisions.parse(
diff --git a/java/com/google/gerrit/sshd/commands/CloseConnection.java b/java/com/google/gerrit/sshd/commands/CloseConnection.java
index 093f647..e0b87f8 100644
--- a/java/com/google/gerrit/sshd/commands/CloseConnection.java
+++ b/java/com/google/gerrit/sshd/commands/CloseConnection.java
@@ -57,6 +57,7 @@
 
   @Override
   protected void run() throws Failure {
+    enableGracefulStop();
     SshUtil.forEachSshSession(
         sshDaemon,
         (k, sshSession, abstractSession, ioSession) -> {
diff --git a/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java b/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
index 004a0ba..4da55e2 100644
--- a/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
@@ -72,6 +72,7 @@
   @Override
   protected void run()
       throws IOException, ConfigInvalidException, UnloggedFailure, PermissionBackendException {
+    enableGracefulStop();
     AccountInput input = new AccountInput();
     input.username = username;
     input.email = email;
diff --git a/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java b/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java
index aad96a1..a837ecd 100644
--- a/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java
@@ -44,6 +44,7 @@
 
   @Override
   protected void run() throws UnloggedFailure {
+    enableGracefulStop();
     try {
       BranchInput in = new BranchInput();
       in.revision = revision;
diff --git a/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java b/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
index 17f80c0..5fd2297 100644
--- a/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
@@ -102,6 +102,7 @@
   @Override
   protected void run()
       throws Failure, IOException, ConfigInvalidException, PermissionBackendException {
+    enableGracefulStop();
     try {
       GroupResource rsrc = createGroup();
 
diff --git a/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java b/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
index fca7427..f2ab4e8 100644
--- a/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
@@ -166,6 +166,7 @@
 
   @Override
   protected void run() throws Failure {
+    enableGracefulStop();
     try {
       if (!suggestParent) {
         if (projectName == null) {
diff --git a/java/com/google/gerrit/sshd/commands/FlushCaches.java b/java/com/google/gerrit/sshd/commands/FlushCaches.java
index 2afc009..fe2a897 100644
--- a/java/com/google/gerrit/sshd/commands/FlushCaches.java
+++ b/java/com/google/gerrit/sshd/commands/FlushCaches.java
@@ -55,6 +55,7 @@
 
   @Override
   protected void run() throws Failure {
+    enableGracefulStop();
     try {
       if (list) {
         if (all || !caches.isEmpty()) {
diff --git a/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java b/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
index 2073087..28a7804 100644
--- a/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
+++ b/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
@@ -62,6 +62,7 @@
 
   @Override
   public void run() throws Exception {
+    enableGracefulStop();
     verifyCommandLine();
     runGC();
   }
diff --git a/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java b/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java
index 0804d08..30dc5c4 100644
--- a/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java
+++ b/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java
@@ -34,6 +34,7 @@
 
   @Override
   protected void run() throws UnloggedFailure {
+    enableGracefulStop();
     try {
       if (versionManager.isKnownIndex(name)) {
         if (versionManager.activateLatestIndex(name)) {
diff --git a/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java b/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
index fb62b48..1fb0e13 100644
--- a/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
+++ b/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
@@ -52,6 +52,7 @@
 
   @Override
   protected void run() throws UnloggedFailure {
+    enableGracefulStop();
     boolean ok = true;
     for (ChangeResource rsrc : changes.values()) {
       try {
diff --git a/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java b/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java
index 56b00a5..168dc19 100644
--- a/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java
+++ b/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java
@@ -43,6 +43,7 @@
 
   @Override
   protected void run() throws UnloggedFailure, Failure, Exception {
+    enableGracefulStop();
     if (projects.isEmpty()) {
       throw die("needs at least one project as command arguments");
     }
diff --git a/java/com/google/gerrit/sshd/commands/IndexStartCommand.java b/java/com/google/gerrit/sshd/commands/IndexStartCommand.java
index f3d349c..5433b17 100644
--- a/java/com/google/gerrit/sshd/commands/IndexStartCommand.java
+++ b/java/com/google/gerrit/sshd/commands/IndexStartCommand.java
@@ -38,6 +38,7 @@
 
   @Override
   protected void run() throws UnloggedFailure {
+    enableGracefulStop();
     try {
       if (versionManager.isKnownIndex(name)) {
         if (versionManager.startReindexer(name, force)) {
diff --git a/java/com/google/gerrit/sshd/commands/KillCommand.java b/java/com/google/gerrit/sshd/commands/KillCommand.java
index df74f86..a633a8a 100644
--- a/java/com/google/gerrit/sshd/commands/KillCommand.java
+++ b/java/com/google/gerrit/sshd/commands/KillCommand.java
@@ -47,6 +47,7 @@
 
   @Override
   protected void run() {
+    enableGracefulStop();
     ConfigResource cfgRsrc = new ConfigResource();
     for (String id : taskIds) {
       try {
diff --git a/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java b/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
index bdf5412..7bf42eb 100644
--- a/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
@@ -52,6 +52,7 @@
 
   @Override
   public void run() throws Exception {
+    enableGracefulStop();
     if (listGroups.getUser() != null && !listGroups.getProjects().isEmpty()) {
       throw die("--user and --project options are not compatible.");
     }
diff --git a/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java b/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
index c8b8fa1..1a7be32 100644
--- a/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
@@ -40,6 +40,7 @@
   @SuppressWarnings("unchecked")
   @Override
   protected void run() {
+    enableGracefulStop();
     Map<String, String> logs = new TreeMap<>();
     for (Enumeration<Logger> logger = LogManager.getCurrentLoggers(); logger.hasMoreElements(); ) {
       Logger log = logger.nextElement();
diff --git a/java/com/google/gerrit/sshd/commands/ListMembersCommand.java b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
index dc1bc6e..52d0468 100644
--- a/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
@@ -20,6 +20,7 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.GroupControl;
@@ -45,12 +46,13 @@
 
   @Override
   public void run() throws Exception {
+    enableGracefulStop();
     impl.display(stdout);
   }
 
   @Override
-  protected void parseCommandLine() throws UnloggedFailure {
-    parseCommandLine(impl);
+  protected void parseCommandLine(DynamicOptions pluginOptions) throws UnloggedFailure {
+    parseCommandLine(impl, pluginOptions);
   }
 
   private static class ListMembersCommandImpl extends ListMembers {
diff --git a/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java b/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
index 9f2ffa9..e711d57 100644
--- a/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
@@ -32,6 +32,7 @@
 
   @Override
   public void run() throws Exception {
+    enableGracefulStop();
     if (!impl.getFormat().isJson()) {
       List<String> showBranch = impl.getShowBranch();
       if (impl.isShowTree() && (showBranch != null) && !showBranch.isEmpty()) {
diff --git a/java/com/google/gerrit/sshd/commands/LsUserRefs.java b/java/com/google/gerrit/sshd/commands/LsUserRefs.java
index 80aee01..6eb045b 100644
--- a/java/com/google/gerrit/sshd/commands/LsUserRefs.java
+++ b/java/com/google/gerrit/sshd/commands/LsUserRefs.java
@@ -74,6 +74,7 @@
 
   @Override
   protected void run() throws Failure {
+    enableGracefulStop();
     Account.Id userAccountId;
     try {
       userAccountId = accountResolver.resolve(userName).asUnique().account().id();
diff --git a/java/com/google/gerrit/sshd/commands/PluginAdminSshCommand.java b/java/com/google/gerrit/sshd/commands/PluginAdminSshCommand.java
index 7e32615..086081c 100644
--- a/java/com/google/gerrit/sshd/commands/PluginAdminSshCommand.java
+++ b/java/com/google/gerrit/sshd/commands/PluginAdminSshCommand.java
@@ -28,6 +28,7 @@
 
   @Override
   protected final void run() throws UnloggedFailure {
+    enableGracefulStop();
     if (!loader.isRemoteAdminEnabled()) {
       throw die("remote plugin administration is disabled");
     }
diff --git a/java/com/google/gerrit/sshd/commands/PluginLsCommand.java b/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
index 3a952f0..504b239 100644
--- a/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
+++ b/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
@@ -41,6 +41,7 @@
 
   @Override
   public void run() throws Exception {
+    enableGracefulStop();
     Map<String, PluginInfo> output = list.apply(TopLevelResource.INSTANCE).value();
 
     if (format.isJson()) {
diff --git a/java/com/google/gerrit/sshd/commands/Query.java b/java/com/google/gerrit/sshd/commands/Query.java
index 78485d3..da19153 100644
--- a/java/com/google/gerrit/sshd/commands/Query.java
+++ b/java/com/google/gerrit/sshd/commands/Query.java
@@ -106,6 +106,7 @@
 
   @Override
   protected void run() throws Exception {
+    enableGracefulStop();
     processor.query(join(query, " "));
   }
 
@@ -115,9 +116,9 @@
   }
 
   @Override
-  protected void parseCommandLine() throws UnloggedFailure {
+  protected void parseCommandLine(DynamicOptions pluginOptions) throws UnloggedFailure {
     processor.setOutput(out, OutputFormat.TEXT);
-    super.parseCommandLine();
+    super.parseCommandLine(pluginOptions);
     if (processor.getIncludeFiles()
         && !(processor.getIncludePatchSets() || processor.getIncludeCurrentPatchSet())) {
       throw die("--files option needs --patch-sets or --current-patch-set");
diff --git a/java/com/google/gerrit/sshd/commands/ReloadConfig.java b/java/com/google/gerrit/sshd/commands/ReloadConfig.java
index cbe3c57..eeb48bb 100644
--- a/java/com/google/gerrit/sshd/commands/ReloadConfig.java
+++ b/java/com/google/gerrit/sshd/commands/ReloadConfig.java
@@ -38,6 +38,7 @@
 
   @Override
   protected void run() throws Failure {
+    enableGracefulStop();
     Multimap<UpdateResult, ConfigUpdateEntry> updates = gerritServerConfigReloader.reloadConfig();
     if (updates.isEmpty()) {
       stdout.println("No config entries updated!");
diff --git a/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java b/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java
index 166ad68..976e7bd 100644
--- a/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java
+++ b/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java
@@ -46,6 +46,7 @@
 
   @Override
   protected void run() throws Failure {
+    enableGracefulStop();
     try {
       GroupResource rsrc = groups.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(groupName));
       NameInput input = new NameInput();
diff --git a/java/com/google/gerrit/sshd/commands/ReviewCommand.java b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
index 78a7381..4c84bd3 100644
--- a/java/com/google/gerrit/sshd/commands/ReviewCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -167,6 +168,7 @@
 
   @Override
   protected void run() throws UnloggedFailure {
+    enableGracefulStop();
     if (abandonChange) {
       if (restoreChange) {
         throw die("abandon and restore actions are mutually exclusive");
@@ -319,7 +321,7 @@
   }
 
   @Override
-  protected void parseCommandLine() throws UnloggedFailure {
+  protected void parseCommandLine(DynamicOptions pluginOptions) throws UnloggedFailure {
     optionMap = new LinkedHashMap<>();
     customLabels = new HashMap<>();
 
@@ -340,7 +342,7 @@
       optionMap.put(newApproveOption(type, usage.toString()), new LabelSetter(type));
     }
 
-    super.parseCommandLine();
+    super.parseCommandLine(pluginOptions);
   }
 
   private static String asOptionName(LabelType type) {
diff --git a/java/com/google/gerrit/sshd/commands/SetAccountCommand.java b/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
index df1e3ed..43a1670 100644
--- a/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
@@ -154,6 +154,7 @@
 
   @Override
   public void run() throws Exception {
+    enableGracefulStop();
     user = genericUserFactory.create(id);
 
     validate();
diff --git a/java/com/google/gerrit/sshd/commands/SetHeadCommand.java b/java/com/google/gerrit/sshd/commands/SetHeadCommand.java
index fd7ef75..b6d283e 100644
--- a/java/com/google/gerrit/sshd/commands/SetHeadCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetHeadCommand.java
@@ -43,6 +43,7 @@
 
   @Override
   protected void run() throws Exception {
+    enableGracefulStop();
     HeadInput input = new HeadInput();
     input.ref = newHead;
     try {
diff --git a/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java b/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
index cfdd735..3faf598 100644
--- a/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
@@ -61,6 +61,7 @@
   @SuppressWarnings("unchecked")
   @Override
   protected void run() throws MalformedURLException {
+    enableGracefulStop();
     if (level == LevelOption.RESET) {
       reset();
     } else {
diff --git a/java/com/google/gerrit/sshd/commands/SetMembersCommand.java b/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
index 2511df4..db8e42a 100644
--- a/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
@@ -102,6 +102,7 @@
 
   @Override
   protected void run() throws UnloggedFailure, Failure, Exception {
+    enableGracefulStop();
     try {
       for (AccountGroup.UUID groupUuid : groups) {
         GroupResource resource =
diff --git a/java/com/google/gerrit/sshd/commands/SetParentCommand.java b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
index 406949e..d23f7fa 100644
--- a/java/com/google/gerrit/sshd/commands/SetParentCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
@@ -90,6 +90,7 @@
 
   @Override
   protected void run() throws Failure {
+    enableGracefulStop();
     if (oldParent == null && children.isEmpty()) {
       throw die(
           "child projects have to be specified as "
diff --git a/java/com/google/gerrit/sshd/commands/SetProjectCommand.java b/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
index 8c9fc9f..9866c4e 100644
--- a/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
@@ -132,6 +132,7 @@
 
   @Override
   protected void run() throws Failure {
+    enableGracefulStop();
     ConfigInput configInput = new ConfigInput();
     configInput.requireChangeId = requireChangeID;
     configInput.submitType = submitType;
diff --git a/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java b/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
index 5bc5537..95627e1 100644
--- a/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
@@ -95,6 +95,7 @@
 
   @Override
   protected void run() throws UnloggedFailure {
+    enableGracefulStop();
     boolean ok = true;
     for (ChangeResource rsrc : changes.values()) {
       try {
diff --git a/java/com/google/gerrit/sshd/commands/SetTopicCommand.java b/java/com/google/gerrit/sshd/commands/SetTopicCommand.java
index 70700f1..35cb3ba 100644
--- a/java/com/google/gerrit/sshd/commands/SetTopicCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetTopicCommand.java
@@ -16,12 +16,11 @@
 
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.api.changes.TopicInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.SetTopicOp;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.restapi.change.SetTopicOp;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.sshd.ChangeArgumentParser;
@@ -74,18 +73,17 @@
 
   @Override
   public void run() throws Exception {
-    TopicInput input = new TopicInput();
     if (topic != null) {
-      input.topic = topic.trim();
+      topic = topic.trim();
     }
 
-    if (input.topic != null && input.topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) {
+    if (topic != null && topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) {
       throw new BadRequestException(
           String.format("topic length exceeds the limit (%s)", ChangeUtil.TOPIC_MAX_LENGTH));
     }
 
     for (ChangeResource r : changes.values()) {
-      SetTopicOp op = topicOpFactory.create(input);
+      SetTopicOp op = topicOpFactory.create(topic);
       try (BatchUpdate u =
           updateFactory.create(r.getChange().getProject(), user, TimeUtil.nowTs())) {
         u.addOp(r.getId(), op);
diff --git a/java/com/google/gerrit/sshd/commands/ShowCaches.java b/java/com/google/gerrit/sshd/commands/ShowCaches.java
index 1d756de..ba84179 100644
--- a/java/com/google/gerrit/sshd/commands/ShowCaches.java
+++ b/java/com/google/gerrit/sshd/commands/ShowCaches.java
@@ -112,6 +112,7 @@
 
   @Override
   protected void run() throws Failure {
+    enableGracefulStop();
     nw = columns - 50;
     Date now = new Date();
     stdout.format(
diff --git a/java/com/google/gerrit/sshd/commands/ShowConnections.java b/java/com/google/gerrit/sshd/commands/ShowConnections.java
index decf5d5..d271364 100644
--- a/java/com/google/gerrit/sshd/commands/ShowConnections.java
+++ b/java/com/google/gerrit/sshd/commands/ShowConnections.java
@@ -86,6 +86,7 @@
 
   @Override
   protected void run() throws Failure {
+    enableGracefulStop();
     final IoAcceptor acceptor = daemon.getIoAcceptor();
     if (acceptor == null) {
       throw new Failure(1, "fatal: sshd no longer running");
diff --git a/java/com/google/gerrit/sshd/commands/ShowQueue.java b/java/com/google/gerrit/sshd/commands/ShowQueue.java
index 2ec9e2d..779f2df 100644
--- a/java/com/google/gerrit/sshd/commands/ShowQueue.java
+++ b/java/com/google/gerrit/sshd/commands/ShowQueue.java
@@ -85,6 +85,7 @@
 
   @Override
   protected void run() throws Failure {
+    enableGracefulStop();
     maxCommandWidth = wide ? Integer.MAX_VALUE : columns - 8 - 12 - 12 - 4 - 4;
     stdout.print(
         String.format(
diff --git a/java/com/google/gerrit/sshd/commands/StreamEvents.java b/java/com/google/gerrit/sshd/commands/StreamEvents.java
index 45540a0..188cc83 100644
--- a/java/com/google/gerrit/sshd/commands/StreamEvents.java
+++ b/java/com/google/gerrit/sshd/commands/StreamEvents.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.events.EventGson;
@@ -107,59 +108,63 @@
 
   @Override
   public void start(ChannelSession channel, Environment env) throws IOException {
-    try {
-      parseCommandLine();
-    } catch (UnloggedFailure e) {
-      String msg = e.getMessage();
-      if (!msg.endsWith("\n")) {
-        msg += "\n";
+    try (DynamicOptions pluginOptions =
+        new DynamicOptions(StreamEvents.this, injector, dynamicBeans)) {
+      try {
+        parseCommandLine(pluginOptions);
+      } catch (UnloggedFailure e) {
+        String msg = e.getMessage();
+        if (!msg.endsWith("\n")) {
+          msg += "\n";
+        }
+        err.write(msg.getBytes(UTF_8));
+        err.flush();
+        onExit(1);
+        return;
       }
-      err.write(msg.getBytes(UTF_8));
-      err.flush();
-      onExit(1);
-      return;
-    }
 
-    PrintWriter stdout = toPrintWriter(out);
-    CancelableRunnable writer =
-        new CancelableRunnable() {
-          @Override
-          public void run() {
-            writeEvents(this, stdout);
-          }
-
-          @Override
-          public void cancel() {
-            onExit(0);
-          }
-
-          @Override
-          public String toString() {
-            StringBuilder b = new StringBuilder();
-            b.append("Stream Events");
-            if (currentUser.getUserName().isPresent()) {
-              b.append(" (").append(currentUser.getUserName().get()).append(")");
+      PrintWriter stdout = toPrintWriter(out);
+      CancelableRunnable writer =
+          new CancelableRunnable() {
+            @Override
+            public void run() {
+              writeEvents(this, stdout);
             }
-            return b.toString();
-          }
-        };
 
-    eventListenerRegistration =
-        eventListeners.add(
-            "gerrit",
-            new UserScopedEventListener() {
-              @Override
-              public void onEvent(Event event) {
-                if (subscribedToEvents.isEmpty() || subscribedToEvents.contains(event.getType())) {
-                  offer(writer, event);
+            @Override
+            public void cancel() {
+              onExit(0);
+            }
+
+            @Override
+            public String toString() {
+              StringBuilder b = new StringBuilder();
+              b.append("Stream Events");
+              if (currentUser.getUserName().isPresent()) {
+                b.append(" (").append(currentUser.getUserName().get()).append(")");
+              }
+              return b.toString();
+            }
+          };
+
+      eventListenerRegistration =
+          eventListeners.add(
+              "gerrit",
+              new UserScopedEventListener() {
+                @Override
+                public void onEvent(Event event) {
+                  if (subscribedToEvents.isEmpty()
+                      || subscribedToEvents.contains(event.getType())) {
+                    offer(writer, event);
+                  }
                 }
-              }
 
-              @Override
-              public CurrentUser getUser() {
-                return currentUser;
-              }
-            });
+                @Override
+                public CurrentUser getUser() {
+                  return currentUser;
+                }
+              });
+    }
   }
 
   private void removeEventListenerRegistration() {
diff --git a/java/com/google/gerrit/sshd/commands/VersionCommand.java b/java/com/google/gerrit/sshd/commands/VersionCommand.java
index 8fac979..f8771fb 100644
--- a/java/com/google/gerrit/sshd/commands/VersionCommand.java
+++ b/java/com/google/gerrit/sshd/commands/VersionCommand.java
@@ -25,6 +25,7 @@
 
   @Override
   protected void run() throws Failure {
+    enableGracefulStop();
     String v = Version.getVersion();
     if (v == null) {
       throw new Failure(1, "fatal: version unavailable");
diff --git a/java/com/google/gerrit/testing/TestCommentHelper.java b/java/com/google/gerrit/testing/TestCommentHelper.java
index da8f871..5865a3c 100644
--- a/java/com/google/gerrit/testing/TestCommentHelper.java
+++ b/java/com/google/gerrit/testing/TestCommentHelper.java
@@ -65,7 +65,7 @@
   }
 
   public Collection<CommentInfo> getPublishedComments(String changeId) throws Exception {
-    return gApi.changes().id(changeId).comments().values().stream()
+    return gApi.changes().id(changeId).commentsRequest().get().values().stream()
         .flatMap(Collection::stream)
         .collect(toList());
   }
diff --git a/java/com/google/gerrit/util/ssl/BlindSSLSocketFactory.java b/java/com/google/gerrit/util/ssl/BlindSSLSocketFactory.java
index 6dc1006..88845ef 100644
--- a/java/com/google/gerrit/util/ssl/BlindSSLSocketFactory.java
+++ b/java/com/google/gerrit/util/ssl/BlindSSLSocketFactory.java
@@ -20,7 +20,6 @@
 import java.net.UnknownHostException;
 import java.security.GeneralSecurityException;
 import java.security.SecureRandom;
-import java.security.cert.X509Certificate;
 import javax.net.SocketFactory;
 import javax.net.ssl.SSLContext;
 import javax.net.ssl.SSLSocketFactory;
@@ -32,19 +31,7 @@
   private static final BlindSSLSocketFactory INSTANCE;
 
   static {
-    final X509TrustManager dummyTrustManager =
-        new X509TrustManager() {
-          @Override
-          public X509Certificate[] getAcceptedIssuers() {
-            return null;
-          }
-
-          @Override
-          public void checkClientTrusted(X509Certificate[] chain, String authType) {}
-
-          @Override
-          public void checkServerTrusted(X509Certificate[] chain, String authType) {}
-        };
+    final X509TrustManager dummyTrustManager = new BlindTrustManager();
 
     try {
       final SSLContext context = SSLContext.getInstance("SSL");
diff --git a/java/com/google/gerrit/util/ssl/BlindTrustManager.java b/java/com/google/gerrit/util/ssl/BlindTrustManager.java
new file mode 100644
index 0000000..2db091a
--- /dev/null
+++ b/java/com/google/gerrit/util/ssl/BlindTrustManager.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2020 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.util.ssl;
+
+import java.security.cert.X509Certificate;
+import javax.net.ssl.X509TrustManager;
+
+/** TrustManager implementation that accepts all certificates without validation. */
+public class BlindTrustManager implements X509TrustManager {
+
+  @Override
+  public X509Certificate[] getAcceptedIssuers() {
+    return null;
+  }
+
+  @Override
+  public void checkClientTrusted(X509Certificate[] chain, String authType) {}
+
+  @Override
+  public void checkServerTrusted(X509Certificate[] chain, String authType) {}
+}
diff --git a/java/org/apache/commons/net/smtp/AuthSMTPClient.java b/java/org/apache/commons/net/smtp/AuthSMTPClient.java
index 85e4dbf..0f8c1f4 100644
--- a/java/org/apache/commons/net/smtp/AuthSMTPClient.java
+++ b/java/org/apache/commons/net/smtp/AuthSMTPClient.java
@@ -17,68 +17,66 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.io.BaseEncoding;
-import com.google.gerrit.util.ssl.BlindSSLSocketFactory;
-import java.io.BufferedReader;
-import java.io.BufferedWriter;
+import com.google.gerrit.util.ssl.BlindTrustManager;
 import java.io.IOException;
-import java.io.InputStreamReader;
-import java.io.OutputStreamWriter;
 import java.io.UnsupportedEncodingException;
-import java.net.SocketException;
 import java.security.InvalidKeyException;
 import java.security.NoSuchAlgorithmException;
 import java.util.Arrays;
 import java.util.List;
 import javax.crypto.Mac;
 import javax.crypto.spec.SecretKeySpec;
-import javax.net.ssl.SSLParameters;
-import javax.net.ssl.SSLSocket;
-import javax.net.ssl.SSLSocketFactory;
 
-public class AuthSMTPClient extends SMTPClient {
+/**
+ * SMTP Client with authentication support and optional SSL processing and verification. {@link
+ * org.apache.commons.net.smtp.SMTPSClient} is used for the SSL handshake and hostname verification.
+ *
+ * <p>If shouldHandshakeOnConnect mode is selected, SSL/TLS negotiation starts right after the
+ * connection has been established. Otherwise SSL/TLS negotiation will only occur if {@link
+ * AuthSMTPClient#execTLS} is explicitly called and the server accepts the command.
+ *
+ * <p>Examples:
+ *
+ * <ul>
+ *   <li>For SSL connection:
+ *       <pre>
+ *       AuthSMTPClient c = new AuthSMTPClient(true, sslVerify);
+ *       c.connect("127.0.0.1", 465);
+ *     </pre>
+ *   <li>For TLS connection:
+ *       <pre>
+ *       AuthSMTPClient c = new AuthSMTPClient(false, sslVerify);
+ *       c.connect("127.0.0.1", 25);
+ *       if (c.execTLS()) { /rest of the commands here/ }
+ *     </pre>
+ *   <li>If SSL encryption is not required:
+ *       <pre>
+ *       AuthSMTPClient c = new AuthSMTPClient(false, false);
+ *       c.connect("127.0.0.1", port);
+ *     </pre>
+ */
+public class AuthSMTPClient extends SMTPSClient {
+
   private String authTypes;
 
-  public AuthSMTPClient(String charset) {
-    super(charset);
-  }
-
-  public void enableSSL(boolean verify) {
-    _socketFactory_ = sslFactory(verify);
-  }
-
-  public boolean startTLS(String hostname, int port, boolean verify)
-      throws SocketException, IOException {
-    if (sendCommand("STARTTLS") != 220) {
-      return false;
+  /**
+   * Constructs AuthSMTPClient.
+   *
+   * @param shouldHandshakeOnConnect the SSL processing mode, {@code true} if SSL negotiation should
+   *     start right after connect, {@code false} if it will be started by the user explicitly or
+   *     SSL negotiation is not required.
+   * @param sslVerificationEnabled {@code true} if the SMTP server's SSL certificate and hostname
+   *     should be verified, {@code false} otherwise.
+   */
+  public AuthSMTPClient(boolean shouldHandshakeOnConnect, boolean sslVerificationEnabled) {
+    // If SSL Encryption is required, SMTPSClient is used for the handshake.
+    // Otherwise, use  SMTPSClient in 'explicit' mode without calling execTLS().
+    // See SMTPSClient._connectAction_ in commons-net-3.6.
+    super("TLS", shouldHandshakeOnConnect, UTF_8.name());
+    this.setEndpointCheckingEnabled(sslVerificationEnabled);
+    if (!sslVerificationEnabled) {
+      this.setTrustManager(new BlindTrustManager());
     }
-
-    _socket_ = sslFactory(verify).createSocket(_socket_, hostname, port, true);
-
-    if (verify) {
-      SSLParameters sslParams = new SSLParameters();
-      sslParams.setEndpointIdentificationAlgorithm("HTTPS");
-      ((SSLSocket) _socket_).setSSLParameters(sslParams);
-    }
-
-    // XXX: Can't call _connectAction_() because SMTP server doesn't
-    // give banner information again after STARTTLS, thus SMTP._connectAction_()
-    // will wait on __getReply() forever, see source code of commons-net-2.2.
-    //
-    // The lines below are copied from SocketClient._connectAction_() and
-    // SMTP._connectAction_() in commons-net-2.2.
-    _socket_.setSoTimeout(_timeout_);
-    _input_ = _socket_.getInputStream();
-    _output_ = _socket_.getOutputStream();
-    _reader = new BufferedReader(new InputStreamReader(_input_, UTF_8));
-    _writer = new BufferedWriter(new OutputStreamWriter(_output_, UTF_8));
-    return true;
-  }
-
-  private static SSLSocketFactory sslFactory(boolean verify) {
-    if (verify) {
-      return (SSLSocketFactory) SSLSocketFactory.getDefault();
-    }
-    return (SSLSocketFactory) BlindSSLSocketFactory.getDefault();
   }
 
   @Override
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index d4affb7..f59fba0 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -47,7 +47,6 @@
 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 com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
@@ -92,7 +91,6 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Address;
-import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelType;
@@ -121,7 +119,6 @@
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.api.groups.GroupApi;
-import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
@@ -142,14 +139,10 @@
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.LabelInfo;
-import com.google.gerrit.extensions.common.MergeInput;
-import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
-import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 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.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -183,7 +176,6 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.name.Named;
-import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
@@ -1069,6 +1061,11 @@
       com.google.gerrit.acceptance.TestAccount deleteAs)
       throws Exception {
     try {
+      projectOperations
+          .project(projectName)
+          .forUpdate()
+          .add(allow(Permission.VIEW_PRIVATE_CHANGES).ref("refs/*").group(ANONYMOUS_USERS))
+          .update();
       requestScopeOperations.setApiUser(owner.id());
       ChangeInput in = new ChangeInput();
       in.project = projectName.get();
@@ -1507,19 +1504,19 @@
     assertThat(commit.author.email).isEqualTo(user.email());
     assertThat(commit.committer.email).isEqualTo(user.email());
 
-    // check that the author/committer was added as reviewer
-    Collection<AccountInfo> reviewers = change.reviewers.get(REVIEWER);
+    // check that the author/committer was added as cc
+    Collection<AccountInfo> reviewers = change.reviewers.get(CC);
     assertThat(reviewers).isNotNull();
     assertThat(reviewers).hasSize(1);
     assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.id().get());
-    assertThat(change.reviewers.get(CC)).isNull();
+    assertThat(change.reviewers.get(REVIEWER)).isNull();
 
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
     assertThat(m.from().name()).isEqualTo("Administrator (Code Review)");
     assertThat(m.rcpt()).containsExactly(user.getNameEmail());
-    assertThat(m.body()).contains("I'd like you to do a code review");
+    assertThat(m.body()).contains("has uploaded this change for review");
     assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
     assertMailReplyTo(m, admin.email());
   }
@@ -3189,407 +3186,6 @@
   }
 
   @Test
-  public void createMergePatchSet() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("dev");
-
-    // create a change for master
-    String changeId = createChange().getChangeId();
-
-    testRepo.reset(initialHead);
-    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
-    currentMaster.assertOkStatus();
-    String parent = currentMaster.getCommit().getName();
-
-    // push a commit into dev branch
-    testRepo.reset(initialHead);
-    PushOneCommit.Result changeA =
-        pushFactory
-            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
-            .to("refs/heads/dev");
-    changeA.assertOkStatus();
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "dev";
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    String subject = "update change by merge ps2";
-    in.subject = subject;
-
-    TestWorkInProgressStateChangedListener wipStateChangedListener =
-        new TestWorkInProgressStateChangedListener();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(wipStateChangedListener)) {
-      ChangeInfo changeInfo = gApi.changes().id(changeId).createMergePatchSet(in);
-      assertThat(changeInfo.subject).isEqualTo(in.subject);
-      assertThat(changeInfo.containsGitConflicts).isNull();
-      assertThat(changeInfo.workInProgress).isNull();
-    }
-    assertThat(wipStateChangedListener.invoked).isFalse();
-
-    // To get the revisions, we must retrieve the change with more change options.
-    ChangeInfo changeInfo =
-        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
-    assertThat(changeInfo.revisions).hasSize(2);
-    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
-        .isEqualTo(parent);
-
-    // Verify the message that has been posted on the change.
-    List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
-    assertThat(messages).hasSize(2);
-    assertThat(Iterables.getLast(messages).message).isEqualTo("Uploaded patch set 2.");
-
-    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.message)
-        .contains(subject);
-  }
-
-  @Test
-  public void createMergePatchSet_SubjectCarriesOverByDefault() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("dev");
-
-    // create a change for master
-    PushOneCommit.Result result = createChange();
-    String changeId = result.getChangeId();
-    String subject = result.getChange().change().getSubject();
-
-    // push a commit into dev branch
-    testRepo.reset(initialHead);
-    PushOneCommit.Result pushResult =
-        pushFactory.create(user.newIdent(), testRepo).to("refs/heads/dev");
-    pushResult.assertOkStatus();
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "dev";
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    in.subject = null;
-
-    // Ensure subject carries over
-    gApi.changes().id(changeId).createMergePatchSet(in);
-    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
-    assertThat(changeInfo.subject).isEqualTo(subject);
-  }
-
-  @Test
-  public void createMergePatchSet_Conflict() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("dev");
-
-    // create a change for master
-    String changeId = createChange().getChangeId();
-
-    String fileName = "shared.txt";
-    testRepo.reset(initialHead);
-    PushOneCommit.Result currentMaster =
-        pushFactory
-            .create(admin.newIdent(), testRepo, "change 1", fileName, "content 1")
-            .to("refs/heads/master");
-    currentMaster.assertOkStatus();
-
-    // push a commit into dev branch
-    testRepo.reset(initialHead);
-    PushOneCommit.Result changeA =
-        pushFactory
-            .create(user.newIdent(), testRepo, "change 2", fileName, "content 2")
-            .to("refs/heads/dev");
-    changeA.assertOkStatus();
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "dev";
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    in.subject = "update change by merge ps2";
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(changeId).createMergePatchSet(in));
-    assertThat(thrown).hasMessageThat().isEqualTo("merge conflict(s):\n" + fileName);
-  }
-
-  @Test
-  public void createMergePatchSet_ConflictAllowed() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("dev");
-
-    // create a change for master
-    String changeId = createChange().getChangeId();
-
-    String fileName = "shared.txt";
-    String sourceSubject = "source change";
-    String sourceContent = "source content";
-    String targetSubject = "target change";
-    String targetContent = "target content";
-    testRepo.reset(initialHead);
-    PushOneCommit.Result currentMaster =
-        pushFactory
-            .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
-            .to("refs/heads/master");
-    currentMaster.assertOkStatus();
-    String parent = currentMaster.getCommit().getName();
-
-    // push a commit into dev branch
-    testRepo.reset(initialHead);
-    PushOneCommit.Result changeA =
-        pushFactory
-            .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
-            .to("refs/heads/dev");
-    changeA.assertOkStatus();
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "dev";
-    mergeInput.allowConflicts = true;
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    in.subject = "update change by merge ps2";
-
-    TestWorkInProgressStateChangedListener wipStateChangedListener =
-        new TestWorkInProgressStateChangedListener();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(wipStateChangedListener)) {
-      ChangeInfo changeInfo = gApi.changes().id(changeId).createMergePatchSet(in);
-      assertThat(changeInfo.subject).isEqualTo(in.subject);
-      assertThat(changeInfo.containsGitConflicts).isTrue();
-      assertThat(changeInfo.workInProgress).isTrue();
-    }
-    assertThat(wipStateChangedListener.invoked).isTrue();
-    assertThat(wipStateChangedListener.wip).isTrue();
-
-    // To get the revisions, we must retrieve the change with more change options.
-    ChangeInfo changeInfo =
-        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
-    assertThat(changeInfo.revisions).hasSize(2);
-    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
-        .isEqualTo(parent);
-
-    // Verify that the file content in the created patch set is correct.
-    // We expect that it has conflict markers to indicate the conflict.
-    BinaryResult bin = gApi.changes().id(changeId).current().file(fileName).content();
-    ByteArrayOutputStream os = new ByteArrayOutputStream();
-    bin.writeTo(os);
-    String fileContent = new String(os.toByteArray(), UTF_8);
-    String sourceSha1 = abbreviateName(changeA.getCommit(), 6);
-    String targetSha1 = abbreviateName(currentMaster.getCommit(), 6);
-    assertThat(fileContent)
-        .isEqualTo(
-            "<<<<<<< TARGET BRANCH ("
-                + targetSha1
-                + " "
-                + targetSubject
-                + ")\n"
-                + targetContent
-                + "\n"
-                + "=======\n"
-                + sourceContent
-                + "\n"
-                + ">>>>>>> SOURCE BRANCH ("
-                + sourceSha1
-                + " "
-                + sourceSubject
-                + ")\n");
-
-    // Verify the message that has been posted on the change.
-    List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
-    assertThat(messages).hasSize(2);
-    assertThat(Iterables.getLast(messages).message)
-        .isEqualTo(
-            "Uploaded patch set 2.\n\n"
-                + "The following files contain Git conflicts:\n"
-                + "* "
-                + fileName
-                + "\n");
-  }
-
-  @Test
-  public void createMergePatchSet_ConflictAllowedNotSupportedByMergeStrategy() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("dev");
-
-    // create a change for master
-    String changeId = createChange().getChangeId();
-
-    String fileName = "shared.txt";
-    String sourceSubject = "source change";
-    String sourceContent = "source content";
-    String targetSubject = "target change";
-    String targetContent = "target content";
-    testRepo.reset(initialHead);
-    PushOneCommit.Result currentMaster =
-        pushFactory
-            .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
-            .to("refs/heads/master");
-    currentMaster.assertOkStatus();
-
-    // push a commit into dev branch
-    testRepo.reset(initialHead);
-    PushOneCommit.Result changeA =
-        pushFactory
-            .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
-            .to("refs/heads/dev");
-    changeA.assertOkStatus();
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "dev";
-    mergeInput.allowConflicts = true;
-    mergeInput.strategy = "simple-two-way-in-core";
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    in.subject = "update change by merge ps2";
-
-    BadRequestException ex =
-        assertThrows(
-            BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
-    assertThat(ex)
-        .hasMessageThat()
-        .isEqualTo(
-            "merge with conflicts is not supported with merge strategy: " + mergeInput.strategy);
-  }
-
-  @Test
-  public void createMergePatchSetInheritParent() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("dev");
-
-    // create a change for master
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    String parent = r.getCommit().getParent(0).getName();
-
-    // advance master branch
-    testRepo.reset(initialHead);
-    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
-    currentMaster.assertOkStatus();
-
-    // push a commit into dev branch
-    testRepo.reset(initialHead);
-    PushOneCommit.Result changeA =
-        pushFactory
-            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
-            .to("refs/heads/dev");
-    changeA.assertOkStatus();
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "dev";
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    in.subject = "update change by merge ps2 inherit parent of ps1";
-    in.inheritParent = true;
-    gApi.changes().id(changeId).createMergePatchSet(in);
-    ChangeInfo changeInfo =
-        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
-
-    assertThat(changeInfo.revisions).hasSize(2);
-    assertThat(changeInfo.subject).isEqualTo(in.subject);
-    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
-        .isEqualTo(parent);
-    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
-        .isNotEqualTo(currentMaster.getCommit().getName());
-  }
-
-  @Test
-  public void createMergePatchSetCannotBaseOnInvisibleChange() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("foo");
-    createBranch("bar");
-
-    // Create a merged commit on 'foo' branch.
-    merge(createChange("refs/for/foo"));
-
-    // Create the base change on 'bar' branch.
-    testRepo.reset(initialHead);
-    String baseChange = createChange("refs/for/bar").getChangeId();
-    gApi.changes().id(baseChange).setPrivate(true, "set private");
-
-    // Create the destination change on 'master' branch.
-    requestScopeOperations.setApiUser(user.id());
-    testRepo.reset(initialHead);
-    String changeId = createChange().getChangeId();
-
-    UnprocessableEntityException thrown =
-        assertThrows(
-            UnprocessableEntityException.class,
-            () ->
-                gApi.changes()
-                    .id(changeId)
-                    .createMergePatchSet(createMergePatchSetInput(baseChange)));
-    assertThat(thrown).hasMessageThat().contains("Read not permitted for " + baseChange);
-  }
-
-  @Test
-  public void createMergePatchSetBaseOnChange() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("foo");
-    createBranch("bar");
-
-    // Create a merged commit on 'foo' branch.
-    merge(createChange("refs/for/foo"));
-
-    // Create the base change on 'bar' branch.
-    testRepo.reset(initialHead);
-    PushOneCommit.Result result = createChange("refs/for/bar");
-    String baseChange = result.getChangeId();
-    String expectedParent = result.getCommit().getName();
-
-    // Create the destination change on 'master' branch.
-    testRepo.reset(initialHead);
-    String changeId = createChange().getChangeId();
-
-    gApi.changes().id(changeId).createMergePatchSet(createMergePatchSetInput(baseChange));
-
-    ChangeInfo changeInfo =
-        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
-    assertThat(changeInfo.revisions).hasSize(2);
-    assertThat(changeInfo.subject).isEqualTo("create ps2");
-    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
-        .isEqualTo(expectedParent);
-  }
-
-  @Test
-  public void createMergePatchSetWithUnupportedMergeStrategy() throws Exception {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    createBranch("dev");
-
-    // create a change for master
-    String changeId = createChange().getChangeId();
-
-    String fileName = "shared.txt";
-    String sourceSubject = "source change";
-    String sourceContent = "source content";
-    String targetSubject = "target change";
-    String targetContent = "target content";
-    testRepo.reset(initialHead);
-    PushOneCommit.Result currentMaster =
-        pushFactory
-            .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
-            .to("refs/heads/master");
-    currentMaster.assertOkStatus();
-
-    // push a commit into dev branch
-    testRepo.reset(initialHead);
-    PushOneCommit.Result changeA =
-        pushFactory
-            .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
-            .to("refs/heads/dev");
-    changeA.assertOkStatus();
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "dev";
-    mergeInput.strategy = "unsupported-strategy";
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    in.subject = "update change by merge ps2";
-
-    BadRequestException ex =
-        assertThrows(
-            BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
-    assertThat(ex).hasMessageThat().isEqualTo("invalid merge strategy: " + mergeInput.strategy);
-  }
-
-  private MergePatchSetInput createMergePatchSetInput(String baseChange) {
-    MergeInput mergeInput = new MergeInput();
-    mergeInput.source = "foo";
-    MergePatchSetInput in = new MergePatchSetInput();
-    in.merge = mergeInput;
-    in.subject = "create ps2";
-    in.inheritParent = false;
-    in.baseChange = baseChange;
-    return in;
-  }
-
-  @Test
   public void checkLabelsForUnsubmittedChange() throws Exception {
     PushOneCommit.Result r = createChange();
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
@@ -4178,7 +3774,7 @@
         .startsWith(subject);
 
     List<CommentInfo> comments =
-        Iterables.getOnlyElement(gApi.changes().id(id).comments().values());
+        Iterables.getOnlyElement(gApi.changes().id(id).commentsRequest().get().values());
     assertThat(Iterables.getOnlyElement(comments).message).isEqualTo(ci.message);
   }
 
@@ -4709,10 +4305,6 @@
     return pushTo("refs/for/master%wip");
   }
 
-  private BranchApi createBranch(String branch) throws Exception {
-    return createBranch(BranchNameKey.create(project, branch));
-  }
-
   private ThrowableSubject assertThatQueryException(String query) throws Exception {
     try {
       query(query);
@@ -4726,17 +4318,4 @@
   private interface AddReviewerCaller {
     void call(String changeId, String reviewer) throws RestApiException;
   }
-
-  private static class TestWorkInProgressStateChangedListener
-      implements WorkInProgressStateChangedListener {
-    boolean invoked;
-    Boolean wip;
-
-    @Override
-    public void onWorkInProgressStateChanged(Event event) {
-      this.invoked = true;
-      this.wip =
-          event.getChange().workInProgress != null ? event.getChange().workInProgress : false;
-    }
-  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
new file mode 100644
index 0000000..aee7f6f
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
@@ -0,0 +1,641 @@
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+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.MergePatchSetInput;
+import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
+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.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.inject.Inject;
+import java.io.ByteArrayOutputStream;
+import java.util.List;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+public class CreateMergePatchSetIT extends AbstractDaemonTest {
+
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
+
+  @Before
+  public void setUp() {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+  }
+
+  @Test
+  public void createMergePatchSet() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+    String parent = currentMaster.getCommit().getName();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    String subject = "update change by merge ps2";
+    in.subject = subject;
+
+    TestWorkInProgressStateChangedListener wipStateChangedListener =
+        new TestWorkInProgressStateChangedListener();
+    try (ExtensionRegistry.Registration registration =
+        extensionRegistry.newRegistration().add(wipStateChangedListener)) {
+      ChangeInfo changeInfo = gApi.changes().id(changeId).createMergePatchSet(in);
+      assertThat(changeInfo.subject).isEqualTo(in.subject);
+      assertThat(changeInfo.containsGitConflicts).isNull();
+      assertThat(changeInfo.workInProgress).isNull();
+    }
+    assertThat(wipStateChangedListener.invoked).isFalse();
+
+    // To get the revisions, we must retrieve the change with more change options.
+    ChangeInfo changeInfo =
+        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+    assertThat(changeInfo.revisions).hasSize(2);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(parent);
+
+    // Verify the message that has been posted on the change.
+    List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
+    assertThat(messages).hasSize(2);
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Uploaded patch set 2.");
+
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.message)
+        .contains(subject);
+  }
+
+  @Test
+  public void createMergePatchSet_SubjectCarriesOverByDefault() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String subject = result.getChange().change().getSubject();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result pushResult =
+        pushFactory.create(user.newIdent(), testRepo).to("refs/heads/dev");
+    pushResult.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = null;
+
+    // Ensure subject carries over
+    gApi.changes().id(changeId).createMergePatchSet(in);
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    assertThat(changeInfo.subject).isEqualTo(subject);
+  }
+
+  @Test
+  public void createMergePatchSet_Conflict() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    String fileName = "shared.txt";
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "change 1", fileName, "content 1")
+            .to("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change 2", fileName, "content 2")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2";
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).createMergePatchSet(in));
+    assertThat(thrown).hasMessageThat().isEqualTo("merge conflict(s):\n" + fileName);
+  }
+
+  @Test
+  public void createMergePatchSet_ConflictAllowed() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    String fileName = "shared.txt";
+    String sourceSubject = "source change";
+    String sourceContent = "source content";
+    String targetSubject = "target change";
+    String targetContent = "target content";
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster =
+        pushFactory
+            .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
+            .to("refs/heads/master");
+    currentMaster.assertOkStatus();
+    String parent = currentMaster.getCommit().getName();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    mergeInput.allowConflicts = true;
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2";
+
+    TestWorkInProgressStateChangedListener wipStateChangedListener =
+        new TestWorkInProgressStateChangedListener();
+    try (ExtensionRegistry.Registration registration =
+        extensionRegistry.newRegistration().add(wipStateChangedListener)) {
+      ChangeInfo changeInfo = gApi.changes().id(changeId).createMergePatchSet(in);
+      assertThat(changeInfo.subject).isEqualTo(in.subject);
+      assertThat(changeInfo.containsGitConflicts).isTrue();
+      assertThat(changeInfo.workInProgress).isTrue();
+    }
+    assertThat(wipStateChangedListener.invoked).isTrue();
+    assertThat(wipStateChangedListener.wip).isTrue();
+
+    // To get the revisions, we must retrieve the change with more change options.
+    ChangeInfo changeInfo =
+        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+    assertThat(changeInfo.revisions).hasSize(2);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(parent);
+
+    // Verify that the file content in the created patch set is correct.
+    // We expect that it has conflict markers to indicate the conflict.
+    BinaryResult bin = gApi.changes().id(changeId).current().file(fileName).content();
+    ByteArrayOutputStream os = new ByteArrayOutputStream();
+    bin.writeTo(os);
+    String fileContent = new String(os.toByteArray(), UTF_8);
+    String sourceSha1 = abbreviateName(changeA.getCommit(), 6);
+    String targetSha1 = abbreviateName(currentMaster.getCommit(), 6);
+    assertThat(fileContent)
+        .isEqualTo(
+            "<<<<<<< TARGET BRANCH ("
+                + targetSha1
+                + " "
+                + targetSubject
+                + ")\n"
+                + targetContent
+                + "\n"
+                + "=======\n"
+                + sourceContent
+                + "\n"
+                + ">>>>>>> SOURCE BRANCH ("
+                + sourceSha1
+                + " "
+                + sourceSubject
+                + ")\n");
+
+    // Verify the message that has been posted on the change.
+    List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
+    assertThat(messages).hasSize(2);
+    assertThat(Iterables.getLast(messages).message)
+        .isEqualTo(
+            "Uploaded patch set 2.\n\n"
+                + "The following files contain Git conflicts:\n"
+                + "* "
+                + fileName
+                + "\n");
+  }
+
+  @Test
+  public void createMergePatchSet_ConflictAllowedNotSupportedByMergeStrategy() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    String fileName = "shared.txt";
+    String sourceSubject = "source change";
+    String sourceContent = "source content";
+    String targetSubject = "target change";
+    String targetContent = "target content";
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster =
+        pushFactory
+            .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
+            .to("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    mergeInput.allowConflicts = true;
+    mergeInput.strategy = "simple-two-way-in-core";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2";
+
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+    assertThat(ex)
+        .hasMessageThat()
+        .isEqualTo(
+            "merge with conflicts is not supported with merge strategy: " + mergeInput.strategy);
+  }
+
+  @Test
+  public void createMergePatchSetInheritParent() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String parent = r.getCommit().getParent(0).getName();
+
+    // advance master branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2 inherit parent of ps1";
+    in.inheritParent = true;
+    gApi.changes().id(changeId).createMergePatchSet(in);
+    ChangeInfo changeInfo =
+        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+
+    assertThat(changeInfo.revisions).hasSize(2);
+    assertThat(changeInfo.subject).isEqualTo(in.subject);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(parent);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isNotEqualTo(currentMaster.getCommit().getName());
+  }
+
+  @Test
+  public void createMergePatchSetCannotBaseOnInvisibleChange() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "foo"));
+    createBranch(BranchNameKey.create(project, "bar"));
+
+    // Create a merged commit on 'foo' branch.
+    merge(createChange("refs/for/foo"));
+
+    // Create the base change on 'bar' branch.
+    testRepo.reset(initialHead);
+    String baseChange = createChange("refs/for/bar").getChangeId();
+    gApi.changes().id(baseChange).setPrivate(true, "set private");
+
+    // Create the destination change on 'master' branch.
+    requestScopeOperations.setApiUser(user.id());
+    testRepo.reset(initialHead);
+    String changeId = createChange().getChangeId();
+
+    UnprocessableEntityException thrown =
+        assertThrows(
+            UnprocessableEntityException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .createMergePatchSet(createMergePatchSetInput(baseChange)));
+    assertThat(thrown).hasMessageThat().contains("Read not permitted for " + baseChange);
+  }
+
+  @Test
+  public void createMergePatchSetBaseOnChange() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "foo"));
+    createBranch(BranchNameKey.create(project, "bar"));
+
+    // Create a merged commit on 'foo' branch.
+    merge(createChange("refs/for/foo"));
+
+    // Create the base change on 'bar' branch.
+    testRepo.reset(initialHead);
+    PushOneCommit.Result result = createChange("refs/for/bar");
+    String baseChange = result.getChangeId();
+    String expectedParent = result.getCommit().getName();
+
+    // Create the destination change on 'master' branch.
+    testRepo.reset(initialHead);
+    String changeId = createChange().getChangeId();
+
+    gApi.changes().id(changeId).createMergePatchSet(createMergePatchSetInput(baseChange));
+
+    ChangeInfo changeInfo =
+        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+    assertThat(changeInfo.revisions).hasSize(2);
+    assertThat(changeInfo.subject).isEqualTo("create ps2");
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(expectedParent);
+  }
+
+  @Test
+  public void createMergePatchSetWithUnupportedMergeStrategy() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    String fileName = "shared.txt";
+    String sourceSubject = "source change";
+    String sourceContent = "source content";
+    String targetSubject = "target change";
+    String targetContent = "target content";
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster =
+        pushFactory
+            .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
+            .to("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    mergeInput.strategy = "unsupported-strategy";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2";
+
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+    assertThat(ex).hasMessageThat().isEqualTo("invalid merge strategy: " + mergeInput.strategy);
+  }
+
+  @Test
+  public void createMergePatchSetWithOtherAuthor() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+    String parent = currentMaster.getCommit().getName();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    String subject = "update change by merge ps2";
+    in.subject = subject;
+    in.author = new AccountInput();
+    in.author.name = "Other Author";
+    in.author.email = "otherauthor@example.com";
+    gApi.changes().id(changeId).createMergePatchSet(in);
+
+    // To get the revisions, we must retrieve the change with more change options.
+    ChangeInfo changeInfo =
+        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+    assertThat(changeInfo.revisions).hasSize(2);
+    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(parent);
+
+    // Verify the message that has been posted on the change.
+    List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
+    assertThat(messages).hasSize(2);
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Uploaded patch set 2.");
+
+    CommitInfo commitInfo = changeInfo.revisions.get(changeInfo.currentRevision).commit;
+    assertThat(commitInfo.message).contains(subject);
+    assertThat(commitInfo.author.name).isEqualTo("Other Author");
+    assertThat(commitInfo.author.email).isEqualTo("otherauthor@example.com");
+  }
+
+  @Test
+  public void createMergePatchSetWithSpecificAuthorButNoForgeAuthorPermission() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    String subject = "update change by merge ps2";
+    in.subject = subject;
+    in.author = new AccountInput();
+    in.author.name = "Foo";
+    in.author.email = "foo@example.com";
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(
+            TestProjectUpdate.permissionKey(Permission.FORGE_AUTHOR)
+                .ref("refs/*")
+                .group(REGISTERED_USERS))
+        .add(block(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    AuthException ex =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+    assertThat(ex).hasMessageThat().isEqualTo("not permitted: forge author on refs/heads/master");
+  }
+
+  @Test
+  public void createMergePatchSetWithMissingNameFailsWithBadRequestException() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    String subject = "update change by merge ps2";
+    in.subject = subject;
+    in.author = new AccountInput();
+    in.author.name = "Foo";
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+    assertThat(ex).hasMessageThat().isEqualTo("Author must specify name and email");
+  }
+
+  @Test
+  public void createMergePatchSetWithMissingEmailFailsWithBadRequestException() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    String changeId = createChange().getChangeId();
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    String subject = "update change by merge ps2";
+    in.subject = subject;
+    in.author = new AccountInput();
+    in.author.email = "Foo";
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+    assertThat(ex).hasMessageThat().isEqualTo("Author must specify name and email");
+  }
+
+  private MergePatchSetInput createMergePatchSetInput(String baseChange) {
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "foo";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "create ps2";
+    in.inheritParent = false;
+    in.baseChange = baseChange;
+    return in;
+  }
+
+  private static class TestWorkInProgressStateChangedListener
+      implements WorkInProgressStateChangedListener {
+    boolean invoked;
+    Boolean wip;
+
+    @Override
+    public void onWorkInProgressStateChanged(Event event) {
+      this.invoked = true;
+      this.wip =
+          event.getChange().workInProgress != null ? event.getChange().workInProgress : false;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
index d5089ff..31198d5 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.inject.AbstractModule;
+import java.util.Arrays;
 import org.junit.Test;
 
 @NoHttpd
@@ -50,6 +51,18 @@
   }
 
   @Test
+  public void querySingleChangeWithBulkAttribute() throws Exception {
+    getSingleChangeWithPluginDefinedBulkAttribute(
+        id -> pluginInfosFromChangeInfos(gApi.changes().query(id.toString()).get()));
+  }
+
+  @Test
+  public void getSingleChangeWithBulkAttribute() throws Exception {
+    getSingleChangeWithPluginDefinedBulkAttribute(
+        id -> pluginInfosFromChangeInfos(Arrays.asList(gApi.changes().id(id.toString()).get())));
+  }
+
+  @Test
   public void queryChangeWithOption() throws Exception {
     getChangeWithOption(
         id -> pluginInfoFromSingletonList(gApi.changes().query(id.toString()).get()),
@@ -65,6 +78,53 @@
         (id, opts) -> pluginInfoFromChangeInfo(gApi.changes().id(id.get()).get(opts)));
   }
 
+  @Test
+  public void queryChangeWithOptionBulkAttribute() throws Exception {
+    getChangeWithPluginDefinedBulkAttributeOption(
+        id -> pluginInfosFromChangeInfos(gApi.changes().query(id.toString()).get()),
+        (id, opts) ->
+            pluginInfosFromChangeInfos(
+                gApi.changes().query(id.toString()).withPluginOptions(opts).get()));
+  }
+
+  @Test
+  public void getChangeWithOptionBulkAttribute() throws Exception {
+    getChangeWithPluginDefinedBulkAttributeOption(
+        id -> pluginInfosFromChangeInfos(Arrays.asList(gApi.changes().id(id.get()).get())),
+        (id, opts) ->
+            pluginInfosFromChangeInfos(Arrays.asList(gApi.changes().id(id.get()).get(opts))));
+  }
+
+  @Test
+  public void queryMultipleChangesWithPluginDefinedAttribute() throws Exception {
+    getMultipleChangesWithPluginDefinedBulkAttribute(
+        () -> pluginInfosFromChangeInfos(gApi.changes().query("status:open").get()));
+  }
+
+  @Test
+  public void queryChangesByCommitMessageWithPluginDefinedBulkAttribute() throws Exception {
+    getChangesByCommitMessageWithPluginDefinedBulkAttribute(
+        () -> pluginInfosFromChangeInfos(gApi.changes().query("status:open").get()));
+  }
+
+  @Test
+  public void getMultipleChangesWithPluginDefinedAndChangeAttributes() throws Exception {
+    getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
+        () -> pluginInfosFromChangeInfos(gApi.changes().query("status:open").get()));
+  }
+
+  @Test
+  public void getMultipleChangesWithPluginDefinedAttributeInSingleCall() throws Exception {
+    getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
+        () -> pluginInfosFromChangeInfos(gApi.changes().query("status:open").get()));
+  }
+
+  @Test
+  public void getChangeWithPluginDefinedException() throws Exception {
+    getChangeWithPluginDefinedBulkAttributeWithException(
+        id -> pluginInfosFromChangeInfos(Arrays.asList(gApi.changes().id(id.get()).get())));
+  }
+
   static class SimpleAttributeWithExplicitExportModule extends AbstractModule {
     @Override
     public void configure() {
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index cd4b24d..e4bb73a 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -85,6 +85,7 @@
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupIncludeCache;
 import com.google.gerrit.server.account.GroupsSnapshotReader;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.auth.ldap.FakeLdapGroupBackend;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.PeriodicGroupIndexer;
@@ -112,6 +113,7 @@
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.stream.Stream;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -703,6 +705,36 @@
   }
 
   @Test
+  public void renamingGroupChangesProjectConfigs() throws Exception {
+    String name = name("Name1");
+    GroupInfo group = gApi.groups().create(name).get();
+
+    // Use group in a permission
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(AccountGroup.uuid(group.id)))
+        .update();
+    Optional<String> beforeRename =
+        projectCache.get(project).get().getLocalGroups().stream()
+            .filter(g -> g.getUUID().get().equals(group.id))
+            .map(GroupReference::getName)
+            .findAny();
+    // Groups created with ProjectOperations always have their UUID as local name
+    assertThat(beforeRename).hasValue(group.id);
+
+    String newName = name("Name2");
+    gApi.groups().id(name).name(newName);
+
+    Optional<String> afterRename =
+        projectCache.get(project).get().getLocalGroups().stream()
+            .filter(g -> g.getUUID().get().equals(group.id))
+            .map(GroupReference::getName)
+            .findAny();
+    assertThat(afterRename).hasValue(newName);
+  }
+
+  @Test
   public void groupDescription() throws Exception {
     String name = name("group");
     gApi.groups().create(name);
@@ -916,7 +948,9 @@
   @Test
   public void defaultGroupsCreated() throws Exception {
     Iterable<String> names = gApi.groups().list().getAsMap().keySet();
-    assertThat(names).containsAtLeast("Administrators", "Service Users").inOrder();
+    assertThat(names)
+        .containsAtLeast("Administrators", ServiceUserClassifier.SERVICE_USERS)
+        .inOrder();
   }
 
   @Test
@@ -1160,6 +1194,11 @@
 
   @Test
   public void pushToDeletedGroupBranchIsRejectedForAllUsersRepo() throws Exception {
+    // refs/deleted-groups is only visible with ACCESS_DATABASE
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
     String groupRef =
         RefNames.refsDeletedGroups(AccountGroup.uuid(gApi.groups().create(name("foo")).get().id));
     createBranch(allUsers, groupRef);
@@ -1352,6 +1391,11 @@
 
   @Test
   public void cannotDeleteDeletedGroupBranch() throws Exception {
+    // refs/deleted-groups is only visible with ACCESS_DATABASE
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
     String groupRef = RefNames.refsDeletedGroups(AccountGroup.uuid(name("foo")));
     createBranch(allUsers, groupRef);
     testCannotDeleteGroupBranch(RefNames.REFS_DELETED_GROUPS + "*", groupRef);
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index f1d537f..59493be 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -162,28 +162,37 @@
     String project;
     String permission;
     int want;
+    List<String> expectedDebugLogs;
 
-    static TestCase project(String mail, String project, int want) {
+    static TestCase project(String mail, String project, int want, List<String> expectedDebugLogs) {
       TestCase t = new TestCase();
       t.input = new AccessCheckInput();
       t.input.account = mail;
       t.project = project;
       t.want = want;
+      t.expectedDebugLogs = expectedDebugLogs;
       return t;
     }
 
-    static TestCase projectRef(String mail, String project, String ref, int want) {
+    static TestCase projectRef(
+        String mail, String project, String ref, int want, List<String> expectedDebugLogs) {
       TestCase t = new TestCase();
       t.input = new AccessCheckInput();
       t.input.account = mail;
       t.input.ref = ref;
       t.project = project;
       t.want = want;
+      t.expectedDebugLogs = expectedDebugLogs;
       return t;
     }
 
     static TestCase projectRefPerm(
-        String mail, String project, String ref, String permission, int want) {
+        String mail,
+        String project,
+        String ref,
+        String permission,
+        int want,
+        List<String> expectedDebugLogs) {
       TestCase t = new TestCase();
       t.input = new AccessCheckInput();
       t.input.account = mail;
@@ -191,6 +200,7 @@
       t.input.permission = permission;
       t.project = project;
       t.want = want;
+      t.expectedDebugLogs = expectedDebugLogs;
       return t;
     }
   }
@@ -217,27 +227,98 @@
                 normalProject.get(),
                 "refs/heads/master",
                 Permission.VIEW_PRIVATE_CHANGES,
-                403),
-            TestCase.project(user.email(), normalProject.get(), 200),
-            TestCase.project(user.email(), secretProject.get(), 403),
+                403,
+                ImmutableList.of(
+                    "'user' can perform 'read' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/*'",
+                    "'user' cannot perform 'viewPrivateChanges' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/master'")),
+            TestCase.project(
+                user.email(),
+                normalProject.get(),
+                200,
+                ImmutableList.of(
+                    "'user' can perform 'read' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/*'")),
+            TestCase.project(
+                user.email(),
+                secretProject.get(),
+                403,
+                ImmutableList.of(
+                    "'user' cannot perform 'read' with force=false on project '"
+                        + secretProject.get()
+                        + "' for ref 'refs/*' because this permission is blocked")),
             TestCase.projectRef(
-                user.email(), secretRefProject.get(), "refs/heads/secret/master", 403),
+                user.email(),
+                secretRefProject.get(),
+                "refs/heads/secret/master",
+                403,
+                ImmutableList.of(
+                    "'user' can perform 'read' with force=false on project '"
+                        + secretRefProject.get()
+                        + "' for ref 'refs/heads/*'",
+                    "'user' cannot perform 'read' with force=false on project '"
+                        + secretRefProject.get()
+                        + "' for ref 'refs/heads/secret/master' because this permission is blocked")),
             TestCase.projectRef(
-                privilegedUser.email(), secretRefProject.get(), "refs/heads/secret/master", 200),
-            TestCase.projectRef(privilegedUser.email(), normalProject.get(), null, 200),
-            TestCase.projectRef(privilegedUser.email(), secretProject.get(), null, 200),
+                privilegedUser.email(),
+                secretRefProject.get(),
+                "refs/heads/secret/master",
+                200,
+                ImmutableList.of(
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + secretRefProject.get()
+                        + "' for ref 'refs/heads/*'",
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + secretRefProject.get()
+                        + "' for ref 'refs/heads/secret/master'")),
+            TestCase.projectRef(
+                privilegedUser.email(),
+                normalProject.get(),
+                null,
+                200,
+                ImmutableList.of(
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/*'")),
+            TestCase.projectRef(
+                privilegedUser.email(),
+                secretProject.get(),
+                null,
+                200,
+                ImmutableList.of(
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + secretProject.get()
+                        + "' for ref 'refs/*'")),
             TestCase.projectRefPerm(
                 privilegedUser.email(),
                 normalProject.get(),
                 "refs/heads/master",
                 Permission.VIEW_PRIVATE_CHANGES,
-                200),
+                200,
+                ImmutableList.of(
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/*'",
+                    "'privilegedUser' can perform 'viewPrivateChanges' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/master'")),
             TestCase.projectRefPerm(
                 privilegedUser.email(),
                 normalProject.get(),
                 "refs/heads/master",
                 Permission.FORGE_SERVER,
-                200));
+                200,
+                ImmutableList.of(
+                    "'privilegedUser' can perform 'read' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/*'",
+                    "'privilegedUser' can perform 'forgeServerAsCommitter' with force=false on project '"
+                        + normalProject.get()
+                        + "' for ref 'refs/heads/master'")));
 
     for (TestCase tc : inputs) {
       String in = newGson().toJson(tc.input);
@@ -273,6 +354,14 @@
         default:
           assertWithMessage(String.format("unknown code %d", want)).fail();
       }
+
+      if (!info.debugLogs.equals(tc.expectedDebugLogs)) {
+        assertWithMessage(
+                String.format(
+                    "check.access(%s, %s) = %s, want %s",
+                    tc.project, in, info.debugLogs, tc.expectedDebugLogs))
+            .fail();
+      }
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
index d3d8457..7197425 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
@@ -20,6 +20,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.extensions.common.testing.CommentInfoSubject.assertThat;
 import static com.google.gerrit.extensions.common.testing.CommentInfoSubject.assertThatList;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.MapSubject.assertThatMap;
 
 import com.google.common.collect.ImmutableList;
@@ -37,6 +38,7 @@
 import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
@@ -496,6 +498,25 @@
   }
 
   @Test
+  public void anonymousUsersGetAuthExceptionForPortedDrafts() throws Exception {
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchsetId = changeOps.change(changeId).currentPatchset().get().patchsetId();
+
+    requestScopeOps.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.changes()
+                    .id(patchsetId.changeId().get())
+                    .revision(patchsetId.get())
+                    .portedDrafts());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("requires authentication; only authenticated users can have drafts");
+  }
+
+  @Test
   public void portedDraftCommentHasNoAuthor() throws Exception {
     // Set up change and patchsets.
     Account.Id authorId = accountOps.newAccount().create();
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index 82215b6..39366bd 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -118,6 +118,12 @@
     assertDiffForNewFile(result, COMMIT_MSG, result.getCommit().getFullMessage());
   }
 
+  @Ignore
+  @Test
+  public void diffWithRootCommit() throws Exception {
+    // TODO(ghareeb): Implement this test
+  }
+
   @Test
   public void patchsetLevelFileDiffIsEmpty() throws Exception {
     PushOneCommit.Result result = createChange();
@@ -446,6 +452,57 @@
   }
 
   @Test
+  public void diffWithThreeParentsMergeCommitChange() throws Exception {
+    // Create a merge commit of 3 files: foo, bar, baz. The merge commit is pointing to 3 different
+    // parents: the merge commit contains foo of parent1, bar of parent2 and baz of parent3.
+    PushOneCommit.Result r =
+        createNParentsMergeCommitChange("refs/for/master", ImmutableList.of("foo", "bar", "baz"));
+
+    DiffInfo diff;
+
+    // parent 1
+    Map<String, FileInfo> changedFiles = gApi.changes().id(r.getChangeId()).current().files(1);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, MERGE_LIST, "bar", "baz");
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "foo").withParent(1).get();
+    assertThat(diff.diffHeader).isNull();
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "bar").withParent(1).get();
+    assertThat(diff.diffHeader).hasSize(4);
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "baz").withParent(1).get();
+    assertThat(diff.diffHeader).hasSize(4);
+
+    // parent 2
+    changedFiles = gApi.changes().id(r.getChangeId()).current().files(2);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, MERGE_LIST, "foo", "baz");
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "foo").withParent(2).get();
+    assertThat(diff.diffHeader).hasSize(4);
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "bar").withParent(2).get();
+    assertThat(diff.diffHeader).isNull();
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "baz").withParent(2).get();
+    assertThat(diff.diffHeader).hasSize(4);
+
+    // parent 3
+    changedFiles = gApi.changes().id(r.getChangeId()).current().files(3);
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, MERGE_LIST, "foo", "bar");
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "foo").withParent(3).get();
+    assertThat(diff.diffHeader).hasSize(4);
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "bar").withParent(3).get();
+    assertThat(diff.diffHeader).hasSize(4);
+    diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "baz").withParent(3).get();
+    assertThat(diff.diffHeader).isNull();
+  }
+
+  @Test
+  public void diffWithThreeParentsMergeCommitAgainstAutoMergeIsNotSupported() throws Exception {
+    PushOneCommit.Result r =
+        createNParentsMergeCommitChange("refs/for/master", ImmutableList.of("foo", "bar", "baz"));
+
+    // Diff against auto-merge returns COMMIT_MSG and MERGE_LIST only
+    // todo(ghareeb): We could throw an exception in this case for better handling at the client.
+    Map<String, FileInfo> changedFiles = gApi.changes().id(r.getChangeId()).current().files();
+    assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, MERGE_LIST);
+  }
+
+  @Test
   public void diffBetweenPatchSetsOfMergeCommitCanBeRetrievedForCommitMessageAndMergeList()
       throws Exception {
     PushOneCommit.Result result = createMergeCommitChange("refs/for/master", "my_file.txt");
@@ -874,6 +931,37 @@
   }
 
   @Test
+  public void intralineEditsAreIdentified() throws Exception {
+    // TODO(ghareeb): This test asserts the wrong behavior due to the following issue
+    // bugs.chromium.org/p/gerrit/issues/detail?id=13563
+    // Please remove this comment and assert the correct behavior when the bug is fixed.
+
+    assume().that(intraline).isTrue();
+
+    String orig = "[-9999,9999]";
+    String replace = "[-999,999]";
+
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.concat(orig));
+    String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
+    addModifiedPatchSet(changeId, FILE_NAME, fileContent -> fileContent.replace(orig, replace));
+
+    // TODO(ghareeb): remove this comment when the issue is fixed.
+    // The returned diff incorrectly contains:
+    // replace [-9999{,99}99] with [-999{,}999].
+    // If this replace edit is done, the resulting string incorrectly becomes [-9999,99].
+
+    DiffInfo diffInfo =
+        getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
+
+    List<List<Integer>> editsA = diffInfo.content.get(1).editA;
+    List<List<Integer>> editsB = diffInfo.content.get(1).editB;
+    String reconstructed = transformStringUsingEditList(orig, replace, editsA, editsB);
+
+    // TODO(ghareeb): assert equals when the issue is fixed.
+    assertThat(reconstructed).isNotEqualTo(replace);
+  }
+
+  @Test
   public void intralineEditsForModifiedLastLineArePreservedWhenNewlineIsAlsoAddedAtEnd()
       throws Exception {
     assume().that(intraline).isTrue();
@@ -2839,4 +2927,29 @@
         .diffRequest()
         .withIntraline(intraline);
   }
+
+  /**
+   * This method transforms the {@code orig} input String using the list of replace edits {@code
+   * editsA}, {@code editsB} and the resulting {@code replace} String. This method currently assumes
+   * that all input edits are replace edits, and that the edits are sorted according to their
+   * indices.
+   *
+   * @return The transformed String after applying the list of replace edits to the original String.
+   */
+  private String transformStringUsingEditList(
+      String orig, String replace, List<List<Integer>> editsA, List<List<Integer>> editsB) {
+    assertThat(editsA).hasSize(editsB.size());
+    StringBuilder process = new StringBuilder(orig);
+    // The edits are processed right to left to avoid recomputation of indices when characters
+    // are removed.
+    for (int i = editsA.size() - 1; i >= 0; i--) {
+      List<Integer> leftEdit = editsA.get(i);
+      List<Integer> rightEdit = editsB.get(i);
+      process.replace(
+          leftEdit.get(0),
+          leftEdit.get(0) + leftEdit.get(1),
+          replace.substring(rightEdit.get(0), rightEdit.get(0) + rightEdit.get(1)));
+    }
+    return process.toString();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 2f9530c..839b051 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -872,7 +872,7 @@
     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.
+    // will be added as cc of the newly created change.
     requestScopeOperations.setApiUser(user.id());
     CherryPickInput input = new CherryPickInput();
     input.message = "it goes to a new branch";
@@ -882,7 +882,7 @@
     input.notify = NotifyHandling.ALL;
     sender.clear();
     gApi.changes().id(changeId).current().cherryPick(input);
-    assertNotifyTo(admin);
+    assertNotifyCc(admin);
 
     // Disable the notification. 'admin' as a reviewer should not be notified any more.
     input.destination = "branch-2";
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index e994d03..763e7b1 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -87,12 +87,14 @@
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 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;
+import com.google.gerrit.extensions.events.TopicEditedListener;
 import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
@@ -487,26 +489,34 @@
 
   @Test
   public void pushForMasterWithTopic() throws Exception {
-    String topic = "my/topic";
-    // specify topic as option
-    PushOneCommit.Result r = pushTo("refs/for/master%topic=" + topic);
-    r.assertOkStatus();
-    r.assertChange(Change.Status.NEW, topic);
+    TopicValidator topicValidator = new TopicValidator();
+    try (Registration registration = extensionRegistry.newRegistration().add(topicValidator)) {
+      String topic = "my/topic";
+      // specify topic as option
+      PushOneCommit.Result r = pushTo("refs/for/master%topic=" + topic);
+      r.assertOkStatus();
+      r.assertChange(Change.Status.NEW, topic);
+      assertThat(topicValidator.count()).isEqualTo(1);
+    }
   }
 
   @Test
   public void pushForMasterWithTopicOption() throws Exception {
-    String topicOption = "topic=myTopic";
-    List<String> pushOptions = new ArrayList<>();
-    pushOptions.add(topicOption);
+    TopicValidator topicValidator = new TopicValidator();
+    try (Registration registration = extensionRegistry.newRegistration().add(topicValidator)) {
+      String topicOption = "topic=myTopic";
+      List<String> pushOptions = new ArrayList<>();
+      pushOptions.add(topicOption);
 
-    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
-    push.setPushOptions(pushOptions);
-    PushOneCommit.Result r = push.to("refs/for/master");
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      push.setPushOptions(pushOptions);
+      PushOneCommit.Result r = push.to("refs/for/master");
 
-    r.assertOkStatus();
-    r.assertChange(Change.Status.NEW, "myTopic");
-    r.assertPushOptions(pushOptions);
+      r.assertOkStatus();
+      r.assertChange(Change.Status.NEW, "myTopic");
+      r.assertPushOptions(pushOptions);
+      assertThat(topicValidator.count()).isEqualTo(1);
+    }
   }
 
   @Test
@@ -1114,7 +1124,7 @@
 
     String changeId = GitUtil.getChangeId(testRepo, c).get();
     assertThat(getOwnerEmail(changeId)).isEqualTo(admin.email());
-    assertThat(getReviewerEmails(changeId, ReviewerState.REVIEWER))
+    assertThat(getReviewerEmails(changeId, ReviewerState.CC))
         .containsExactly(user.email(), user2.email());
 
     assertThat(sender.getMessages()).hasSize(1);
@@ -1139,7 +1149,7 @@
     pushHead(testRepo, "refs/for/master");
 
     assertThat(getOwnerEmail(r.getChangeId())).isEqualTo(admin.email());
-    assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.REVIEWER))
+    assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.CC))
         .containsExactly(user.email(), user2.email());
 
     assertThat(sender.getMessages()).hasSize(1);
@@ -1171,27 +1181,20 @@
     // Push this commit as "Administrator" (requires Forge Committer Identity)
     pushHead(testRepo, "refs/for/master%l=Code-Review+1", false);
 
-    // Expected Code-Review votes:
-    // 1. 0 from User (committer):
-    //    When the committer is forged, the committer is automatically added as
-    //    reviewer, hence we expect a dummy 0 vote for the committer.
-    // 2. +1 from Administrator (uploader):
-    //    On push Code-Review+1 was specified, hence we expect a +1 vote from
-    //    the uploader.
+    // Expected Code-Review vote:
+    // +1 from Administrator (uploader):
+    // On push Code-Review+1 was specified, hence we expect a +1 vote from the uploader. When the
+    // committer is forged, the committer is automatically added as cc, but that doesn't add votes
+    // (as opposted to being added as reviewer that adds a dummy +0 vote). We ensure there are no
+    // votes from the committer.
     ChangeInfo ci =
         get(GitUtil.getChangeId(testRepo, c).get(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
     LabelInfo cr = ci.labels.get("Code-Review");
-    assertThat(cr.all).hasSize(2);
-    int indexAdmin = admin.fullName().equals(cr.all.get(0).name) ? 0 : 1;
-    int indexUser = indexAdmin == 0 ? 1 : 0;
-    assertThat(cr.all.get(indexAdmin).name).isEqualTo(admin.fullName());
-    assertThat(cr.all.get(indexAdmin).value.intValue()).isEqualTo(1);
-    assertThat(cr.all.get(indexUser).name).isEqualTo(user.fullName());
-    assertThat(cr.all.get(indexUser).value.intValue()).isEqualTo(0);
+    ApprovalInfo approvalInfo = Iterables.getOnlyElement(cr.all);
+    assertThat(approvalInfo.name).isEqualTo(admin.fullName());
+    assertThat(approvalInfo.value.intValue()).isEqualTo(1);
     assertThat(Iterables.getLast(ci.messages).message)
         .isEqualTo("Uploaded patch set 1: Code-Review+1.");
-    // Check that the user who pushed the change was added as a reviewer since they added a vote
-    assertThatUserIsOnlyReviewer(ci, admin);
   }
 
   @Test
@@ -1563,7 +1566,7 @@
     if (!url.endsWith("/")) {
       url += "/";
     }
-    RevCommit c = createCommit(testRepo, "test commit\n\nLink: " + url + "id/" + changeId);
+    createCommit(testRepo, "test commit\n\nLink: " + url + "id/" + changeId);
     pushForReviewOk(testRepo);
 
     List<ChangeMessageInfo> messages = getMessages(changeId);
@@ -1573,7 +1576,7 @@
   @Test
   public void pushWithWrongHostLinkFooter() throws Exception {
     String changeId = "I0123456789abcdef0123456789abcdef01234567";
-    RevCommit c = createCommit(testRepo, "test commit\n\nLink: https://wronghost/id/" + changeId);
+    createCommit(testRepo, "test commit\n\nLink: https://wronghost/id/" + changeId);
     pushForReviewRejected(testRepo, "missing Change-Id in message footer");
   }
 
@@ -2372,6 +2375,19 @@
     }
   }
 
+  private static class TopicValidator implements TopicEditedListener {
+    private final AtomicInteger count = new AtomicInteger();
+
+    @Override
+    public void onTopicEdited(Event event) {
+      count.incrementAndGet();
+    }
+
+    public int count() {
+      return count.get();
+    }
+  }
+
   @Test
   public void skipValidation() throws Exception {
     String master = "refs/heads/master";
@@ -2731,7 +2747,7 @@
   }
 
   private Collection<CommentInfo> getPublishedComments(String changeId) throws Exception {
-    return gApi.changes().id(changeId).comments().values().stream()
+    return gApi.changes().id(changeId).commentsRequest().get().values().stream()
         .flatMap(Collection::stream)
         .collect(toList());
   }
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index 1a2ae7c..78be4ab 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -364,7 +364,7 @@
         .update();
 
     String project2 = name("project2");
-    gApi.projects().create(project2);
+    projectOperations.newProject().name(project2).create();
 
     ObjectId oldId = forceFetch("refs/meta/config");
 
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 0930815..c6e610f 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -48,6 +48,7 @@
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.receive.ReceiveCommitsAdvertiseRefsHookChain;
 import com.google.gerrit.server.git.receive.testing.TestRefAdvertiser;
@@ -118,7 +119,7 @@
   @Before
   public void setUp() throws Exception {
     admins = adminGroupUuid();
-    nonInteractiveUsers = groupUuid("Service Users");
+    nonInteractiveUsers = groupUuid(ServiceUserClassifier.SERVICE_USERS);
     setUpPermissions();
     setUpChanges();
   }
diff --git a/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java b/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
index 23d7658..093711f 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.schema.NoteDbSchemaVersion;
 import com.google.gerrit.server.schema.Schema_184;
 import com.google.gerrit.testing.TestUpdateUI;
@@ -26,7 +27,8 @@
 import org.junit.Test;
 
 public class Schema_184IT extends AbstractDaemonTest {
-  private static final AccountGroup.NameKey SERVICE_USERS = AccountGroup.nameKey("Service Users");
+  private static final AccountGroup.NameKey SERVICE_USERS =
+      AccountGroup.nameKey(ServiceUserClassifier.SERVICE_USERS);
   private static final AccountGroup.NameKey NON_INTERACTIVE_USERS =
       AccountGroup.nameKey("Non-Interactive Users");
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index f5d9e3a..cb34bdb 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -54,9 +54,9 @@
 import com.google.inject.Inject;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Set;
 import org.apache.http.message.BasicHeader;
 import org.junit.Rule;
 import org.junit.Test;
@@ -337,7 +337,7 @@
     assertThat(LoggingContext.getInstance().getTags().isEmpty()).isTrue();
     assertForceLogging(false);
     try (TraceContext traceContext = TraceContext.open().forceLogging().addTag("foo", "bar")) {
-      SortedMap<String, SortedSet<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
+      Map<String, ? extends Set<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
       assertThat(tagMap.keySet()).containsExactly("foo");
       assertThat(tagMap.get("foo")).containsExactly("bar");
       assertForceLogging(true);
@@ -348,7 +348,7 @@
               () -> {
                 // Verify that the tags and force logging flag have been propagated to the new
                 // thread.
-                SortedMap<String, SortedSet<Object>> threadTagMap =
+                Map<String, ? extends Set<Object>> threadTagMap =
                     LoggingContext.getInstance().getTags().asMap();
                 expect.that(threadTagMap.keySet()).containsExactly("foo");
                 expect.that(threadTagMap.get("foo")).containsExactly("bar");
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
index a5cf3e1..b999abd 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.common.AccountDetailInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.inject.Inject;
 import org.junit.Test;
 
@@ -45,7 +46,11 @@
   public void getDetailForServiceUser() throws Exception {
     Account.Id serviceUser = accountOperations.newAccount().create();
     groupOperations
-        .group(groupCache.get(AccountGroup.nameKey("Service Users")).get().getGroupUUID())
+        .group(
+            groupCache
+                .get(AccountGroup.nameKey(ServiceUserClassifier.SERVICE_USERS))
+                .get()
+                .getGroupUUID())
         .forUpdate()
         .addMember(serviceUser)
         .update();
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
index 2e2f5d9..1e61d0a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.inject.Inject;
 import org.junit.Test;
 
@@ -70,7 +71,8 @@
   @Test
   public void getServiceUserAccount() throws Exception {
     TestAccount serviceUser =
-        accountCreator.create("robot1", "robot1@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot1", "robot1@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     assertThat(serviceUser.tags()).containsExactly("SERVICE_USER");
     testGetAccount(serviceUser.id().toString(), serviceUser);
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index a11328f..5c596dc 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -506,7 +506,8 @@
     assertThat(m.author._accountId).isEqualTo(user.id().get());
 
     CommentInfo c =
-        Iterables.getOnlyElement(gApi.changes().id(r.getChangeId()).comments().get(di.path));
+        Iterables.getOnlyElement(
+            gApi.changes().id(r.getChangeId()).commentsRequest().get().get(di.path));
     assertThat(c.author._accountId).isEqualTo(user.id().get());
     assertThat(c.message).isEqualTo(di.message);
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
index 2c9107c..b70cab8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
@@ -249,4 +249,30 @@
   public void postWithoutBody() throws Exception {
     adminRestSession.post("/accounts/" + admin.username() + "/watched.projects").assertOK();
   }
+
+  @Test
+  public void nullProjectThrowsBadRequestException() {
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = null;
+    projectsToWatch.add(pwi);
+    Throwable t =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.accounts().self().setWatchedProjects(projectsToWatch));
+    assertThat(t.getMessage()).isEqualTo("project name must be specified");
+  }
+
+  @Test
+  public void emptyProjectThrowsBadRequestException() {
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = "  ";
+    projectsToWatch.add(pwi);
+    Throwable t =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.accounts().self().setWatchedProjects(projectsToWatch));
+    assertThat(t.getMessage()).isEqualTo("project name must be specified");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/auth/AuthenticationCheckIT.java b/javatests/com/google/gerrit/acceptance/rest/auth/AuthenticationCheckIT.java
index 9298b43..00b1c55 100644
--- a/javatests/com/google/gerrit/acceptance/rest/auth/AuthenticationCheckIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/auth/AuthenticationCheckIT.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.acceptance.rest.auth;
 
-import static com.google.common.truth.Truth.assertThat;
-
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.RestSession;
@@ -34,6 +32,5 @@
     RestSession anonymous = new RestSession(server, null);
     RestResponse r = anonymous.get("/auth-check");
     r.assertForbidden();
-    assertThat(r.getHeader("Content-Length")).isEqualTo("0");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index faef5aa..085d23d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -1439,6 +1439,12 @@
     assertWithMessage("enabled bit on submit action").that(desc.isEnabled()).isTrue();
   }
 
+  protected void assertSubmitDisabled(String changeId) throws Throwable {
+    RevisionResource rsrc = parseCurrentRevisionResource(changeId);
+    UiAction.Description desc = submitHandler.getDescription(rsrc);
+    assertWithMessage("enabled bit on submit action").that(desc.isEnabled()).isFalse();
+  }
+
   protected void assertChangeMergedEvents(String... expected) throws Throwable {
     eventRecorder.assertChangeMergedEvents(project.get(), "refs/heads/master", expected);
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
index f77552d..9c496fa 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.inject.Inject;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -117,4 +118,49 @@
     assertThat(head.getParent(0)).isEqualTo(change1.getCommit());
     assertThat(head.getParent(1)).isEqualTo(change2.getCommit());
   }
+
+  @Test
+  public void dependencyOnOutdatedPatchSetPreventsMerge() throws Throwable {
+    // Create a change
+    PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
+    PushOneCommit.Result changeResult = change.to("refs/for/master");
+    PatchSet.Id patchSetId = changeResult.getPatchSetId();
+
+    // Create a successor change.
+    PushOneCommit change2 =
+        pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
+    PushOneCommit.Result change2Result = change2.to("refs/for/master");
+
+    // Create new patch set for first change.
+    testRepo.reset(changeResult.getCommit().name());
+    amendChange(changeResult.getChangeId());
+
+    // Approve both changes
+    approve(changeResult.getChangeId());
+    approve(change2Result.getChangeId());
+
+    // submit button is disabled.
+    assertSubmitDisabled(change2Result.getChangeId());
+
+    submitWithConflict(
+        change2Result.getChangeId(),
+        "Failed to submit 2 changes due to the following problems:\n"
+            + "Change "
+            + change2Result.getChange().getId()
+            + ": Depends on change that was not submitted."
+            + " Commit "
+            + change2Result.getCommit().name()
+            + " depends on commit "
+            + changeResult.getCommit().name()
+            + ", which is outdated patch set "
+            + patchSetId.get()
+            + " of change "
+            + changeResult.getChange().getId()
+            + ". The latest patch set is "
+            + changeResult.getPatchSetId().get()
+            + ".");
+
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
index 955dd7a..5eb19df 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.ChangeStatus;
@@ -384,4 +385,49 @@
     gApi.changes().id(change2.getChangeId()).current().rebase();
     submit(change2.getChangeId());
   }
+
+  @Test
+  public void dependencyOnOutdatedPatchSetPreventsRebase() throws Throwable {
+    // Create a change
+    PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
+    PushOneCommit.Result changeResult = change.to("refs/for/master");
+    PatchSet.Id patchSetId = changeResult.getPatchSetId();
+
+    // Create a successor change.
+    PushOneCommit change2 =
+        pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
+    PushOneCommit.Result change2Result = change2.to("refs/for/master");
+
+    // Create new patch set for first change.
+    testRepo.reset(changeResult.getCommit().name());
+    amendChange(changeResult.getChangeId());
+
+    // Approve both changes
+    approve(changeResult.getChangeId());
+    approve(change2Result.getChangeId());
+
+    // submit button is disabled.
+    assertSubmitDisabled(change2Result.getChangeId());
+
+    submitWithConflict(
+        change2Result.getChangeId(),
+        "Failed to submit 2 changes due to the following problems:\n"
+            + "Change "
+            + change2Result.getChange().getId()
+            + ": Depends on change that was not submitted."
+            + " Commit "
+            + change2Result.getCommit().name()
+            + " depends on commit "
+            + changeResult.getCommit().name()
+            + ", which is outdated patch set "
+            + patchSetId.get()
+            + " of change "
+            + changeResult.getChange().getId()
+            + ". The latest patch set is "
+            + changeResult.getPatchSetId().get()
+            + ".");
+
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index 976be96..9e944a2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -28,12 +28,14 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.entities.AttentionSetUpdate.Operation;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
@@ -41,9 +43,12 @@
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -58,6 +63,7 @@
 import java.util.List;
 import java.util.concurrent.TimeUnit;
 import java.util.function.LongSupplier;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import org.junit.Before;
 import org.junit.Test;
@@ -110,48 +116,48 @@
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(user.id());
     int accountId =
-        change(r).addToAttentionSet(new AttentionSetInput(user.email(), "first"))._accountId;
-    assertThat(accountId).isEqualTo(user.id().get());
+        change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "first"))._accountId;
+    assertThat(accountId).isEqualTo(admin.id().get());
     AttentionSetUpdate expectedAttentionSetUpdate =
         AttentionSetUpdate.createFromRead(
-            fakeClock.now(), user.id(), AttentionSetUpdate.Operation.ADD, "first");
+            fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.ADD, "first");
     assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
 
     // Second add is ignored.
     accountId =
-        change(r).addToAttentionSet(new AttentionSetInput(user.email(), "second"))._accountId;
-    assertThat(accountId).isEqualTo(user.id().get());
+        change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "second"))._accountId;
+    assertThat(accountId).isEqualTo(admin.id().get());
     assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
 
     // Only one email since the second add was ignored.
     String emailBody = Iterables.getOnlyElement(email.getMessages()).body();
     assertThat(emailBody)
         .contains(
-            user.fullName()
-                + " added themselves to the attention set of this change.\n The reason is: first.");
+            String.format(
+                "%s requires the attention of %s to this change.\n The reason is: first.",
+                user.fullName(), admin.fullName()));
   }
 
   @Test
   public void addMultipleUsers() throws Exception {
     PushOneCommit.Result r = createChange();
     Instant timestamp1 = fakeClock.now();
-    int accountId1 =
-        change(r).addToAttentionSet(new AttentionSetInput(user.email(), "user"))._accountId;
-    assertThat(accountId1).isEqualTo(user.id().get());
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
     fakeClock.advance(Duration.ofSeconds(42));
     Instant timestamp2 = fakeClock.now();
     int accountId2 =
         change(r)
-            .addToAttentionSet(new AttentionSetInput(admin.id().toString(), "admin"))
+            .addToAttentionSet(new AttentionSetInput(admin.id().toString(), "manual update"))
             ._accountId;
     assertThat(accountId2).isEqualTo(admin.id().get());
 
     AttentionSetUpdate expectedAttentionSetUpdate1 =
         AttentionSetUpdate.createFromRead(
-            timestamp1, user.id(), AttentionSetUpdate.Operation.ADD, "user");
+            timestamp1, user.id(), AttentionSetUpdate.Operation.ADD, "Reviewer was added");
     AttentionSetUpdate expectedAttentionSetUpdate2 =
         AttentionSetUpdate.createFromRead(
-            timestamp2, admin.id(), AttentionSetUpdate.Operation.ADD, "admin");
+            timestamp2, admin.id(), AttentionSetUpdate.Operation.ADD, "manual update");
     assertThat(r.getChange().attentionSet())
         .containsExactly(expectedAttentionSetUpdate1, expectedAttentionSetUpdate2);
   }
@@ -159,7 +165,9 @@
   @Test
   public void removeUser() throws Exception {
     PushOneCommit.Result r = createChange();
-    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "added"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
+    sender.clear();
     requestScopeOperations.setApiUser(user.id());
 
     fakeClock.advance(Duration.ofSeconds(42));
@@ -186,6 +194,9 @@
   @Test
   public void removeUserWithInvalidUserInput() throws Exception {
     PushOneCommit.Result r = createChange();
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
+
     BadRequestException exception =
         assertThrows(
             BadRequestException.class,
@@ -194,7 +205,9 @@
                     .attention(user.id().toString())
                     .remove(new AttentionSetInput("invalid user", "reason")));
     assertThat(exception.getMessage())
-        .isEqualTo("The user specified in the input body couldn't be found.");
+        .isEqualTo(
+            "invalid user doesn't exist or is not active on the change as an owner, "
+                + "uploader, reviewer, or cc so they can't be added to the attention set");
 
     exception =
         assertThrows(
@@ -209,16 +222,10 @@
   }
 
   @Test
-  public void removeUnrelatedUser() throws Exception {
-    PushOneCommit.Result r = createChange();
-    change(r).attention(user.id().toString()).remove(new AttentionSetInput("foo"));
-    assertThat(r.getChange().attentionSet()).isEmpty();
-  }
-
-  @Test
   public void abandonRemovesUsers() throws Exception {
     PushOneCommit.Result r = createChange();
-    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "user"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
     change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "admin"));
 
     change(r).abandon();
@@ -239,7 +246,8 @@
   @Test
   public void workInProgressRemovesUsers() throws Exception {
     PushOneCommit.Result r = createChange();
-    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
 
     change(r).setWorkInProgress();
 
@@ -253,13 +261,10 @@
   public void submitRemovesUsersForAllSubmittedChanges() throws Exception {
     PushOneCommit.Result r1 = createChange("refs/heads/master", "file1", "content");
 
-    change(r1)
-        .current()
-        .review(ReviewInput.approve().addUserToAttentionSet(user.email(), "reason"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r1).current().review(ReviewInput.approve().reviewer(user.email()));
     PushOneCommit.Result r2 = createChange("refs/heads/master", "file2", "content");
-    change(r2)
-        .current()
-        .review(ReviewInput.approve().addUserToAttentionSet(user.email(), "reason"));
+    change(r2).current().review(ReviewInput.approve().reviewer(user.email()));
 
     change(r2).current().submit();
 
@@ -281,21 +286,26 @@
 
   @Test
   public void robotSubmitsRemovesUsers() throws Exception {
-    PushOneCommit.Result r1 = createChange("refs/heads/master", "file1", "content");
+    PushOneCommit.Result r = createChange("refs/heads/master", "file1", "content");
 
-    change(r1)
-        .current()
-        .review(ReviewInput.approve().addUserToAttentionSet(user.email(), "reason"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
 
     TestAccount robot =
         accountCreator.create(
-            "robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users", "Administrators");
+            "robot2",
+            "robot2@example.com",
+            "Ro Bot",
+            "Ro",
+            ServiceUserClassifier.SERVICE_USERS,
+            "Administrators");
     requestScopeOperations.setApiUser(robot.id());
-    change(r1).current().submit();
+    change(r).current().review(ReviewInput.approve());
+    change(r).current().submit();
 
     // Attention set updates that relate to the admin (the person who replied) are filtered out.
     AttentionSetUpdate attentionSet =
-        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r1, user));
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
 
     assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
     assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
@@ -457,7 +467,12 @@
 
     TestAccount robot =
         accountCreator.create(
-            "robot1", "robot1@example.com", "Ro Bot", "Ro", "Service Users", "Administrators");
+            "robot1",
+            "robot1@example.com",
+            "Ro Bot",
+            "Ro",
+            ServiceUserClassifier.SERVICE_USERS,
+            "Administrators");
     requestScopeOperations.setApiUser(robot.id());
     change(r).setReadyForReview();
     AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
@@ -518,7 +533,9 @@
   @Test
   public void reviewersAreNotAddedForNoReasonBecauseOfAnUpdate() throws Exception {
     PushOneCommit.Result r = createChange();
-    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "user"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
+
     change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
 
     HashtagsInput hashtagsInput = new HashtagsInput();
@@ -552,7 +569,8 @@
   @Test
   public void reviewRemovesManuallyRemovedUserFromAttentionSet() throws Exception {
     PushOneCommit.Result r = createChange();
-    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
     requestScopeOperations.setApiUser(user.id());
 
     ReviewInput reviewInput =
@@ -572,7 +590,8 @@
   @Test
   public void reviewWithManualAdditionToAttentionSetFailsWithoutReason() throws Exception {
     PushOneCommit.Result r = createChange();
-    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
 
     ReviewInput reviewInput = ReviewInput.create().addUserToAttentionSet(user.email(), "");
 
@@ -596,7 +615,8 @@
   @Test
   public void reviewAddReviewerWhileRemovingFromAttentionSetJustRemovesUser() throws Exception {
     PushOneCommit.Result r = createChange();
-    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "addition"));
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
 
     ReviewInput reviewInput =
         ReviewInput.create()
@@ -757,7 +777,15 @@
 
     requestScopeOperations.setApiUser(user.id());
 
-    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+    // add the user to the attention set.
+    change(r)
+        .current()
+        .review(
+            ReviewInput.create()
+                .reviewer(user.email(), ReviewerState.CC, true)
+                .addUserToAttentionSet(user.email(), "reason"));
+
+    // add the user as reviewer but still be removed on reply.
     ReviewInput reviewInput = ReviewInput.create().reviewer(user.email());
     change(r).current().review(reviewInput);
 
@@ -1129,6 +1157,9 @@
   @Test
   public void repliesWhileAddingAsReviewerStillRemovesUser() throws Exception {
     PushOneCommit.Result r = createChange();
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
+
     change(r).addToAttentionSet(new AttentionSetInput(user.email(), "remove"));
 
     requestScopeOperations.setApiUser(user.id());
@@ -1219,8 +1250,11 @@
   @Test
   public void robotsNotAddedToAttentionSet() throws Exception {
     TestAccount robot =
-        accountCreator.create("robot1", "robot1@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot1", "robot1@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     PushOneCommit.Result r = createChange();
+    // Make the robot active on the change.
+    change(r).addReviewer(robot.email());
 
     // Throw an error when adding a robot explicitly.
     BadRequestException exception =
@@ -1239,7 +1273,8 @@
   @Test
   public void robotAddingAReviewerChangeAttentionSet() throws Exception {
     TestAccount robot =
-        accountCreator.create("robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(robot.id());
     change(r).addReviewer(user.id().toString());
@@ -1255,7 +1290,8 @@
   @Test
   public void robotReviewDoesNotChangeAttentionSet() throws Exception {
     TestAccount robot =
-        accountCreator.create("robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(robot.id());
     change(r).current().review(ReviewInput.recommend());
@@ -1266,7 +1302,8 @@
   @Test
   public void robotReviewWithNegativeLabelAddsOwner() throws Exception {
     TestAccount robot =
-        accountCreator.create("robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(robot.id());
     change(r).current().review(ReviewInput.dislike());
@@ -1281,7 +1318,8 @@
   @Test
   public void robotCommentDoesNotAddOwnerOnClosedChanges() throws Exception {
     TestAccount robot =
-        accountCreator.create("robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).abandon();
 
@@ -1298,7 +1336,8 @@
   @Test
   public void robotCanChangeAttentionSetExplicitly() throws Exception {
     TestAccount robot =
-        accountCreator.create("robot2", "robot2@example.com", "Ro Bot", "Ro", "Service Users");
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(robot.id());
     change(r).current().review(new ReviewInput().addUserToAttentionSet(admin.email(), "reason"));
@@ -1314,13 +1353,15 @@
   public void addUsersToAttentionSetInPrivateChanges() throws Exception {
     PushOneCommit.Result r = createChange();
     change(r).setPrivate(true);
-    change(r).current().review(new ReviewInput().addUserToAttentionSet(user.email(), "reason"));
+
+    // implictly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
 
     AttentionSetUpdate attentionSet =
         Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
     assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
     assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
-    assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer was added");
   }
 
   @Test
@@ -1360,6 +1401,307 @@
     sender.clear();
   }
 
+  @Test
+  @GerritConfig(name = "change.enableAttentionSet", value = "true")
+  public void attentionSetEmailHeader() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestAccount user2 = accountCreator.user2();
+
+    // The pattern ensures the header mentions the attention set requirements in any order.
+    Pattern attentionSetHeaderPattern =
+        Pattern.compile(
+            String.format(
+                "Attention is currently required from: (%s|%s), (%s|%s).",
+                user2.fullName(), user.fullName(), user.fullName(), user2.fullName()));
+    // Add user and user2 to the attention set.
+    change(r)
+        .current()
+        .review(
+            ReviewInput.create().reviewer(user.email()).reviewer(accountCreator.user2().email()));
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .containsMatch(attentionSetHeaderPattern);
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
+        .containsMatch(attentionSetHeaderPattern);
+    sender.clear();
+
+    // Irrelevant reply, User and User2 are still in the attention set.
+    change(r).current().review(ReviewInput.approve());
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .containsMatch(attentionSetHeaderPattern);
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
+        .containsMatch(attentionSetHeaderPattern);
+    sender.clear();
+
+    // Abandon the change which removes user from attention set; there is an email but without the
+    // attention footer.
+    change(r).abandon();
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .doesNotContain("Attention is currently required");
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
+        .doesNotContain("Attention is currently required");
+    sender.clear();
+  }
+
+  @Test
+  @GerritConfig(name = "change.enableAttentionSet", value = "false")
+  public void noReferenceToAttentionSetInEmailsWhenDisabled() throws Exception {
+    PushOneCommit.Result r = createChange();
+    // Add user and to the attention set.
+    change(r).addReviewer(user.id().toString());
+
+    // Attention set is not referenced.
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .doesNotContain("Attention is currently required");
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
+        .doesNotContain("Attention is currently required");
+    sender.clear();
+  }
+
+  @Test
+  public void attentionSetWithEmailFilter() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add preference for the user such that they only receive an email on changes that require
+    // their attention.
+    requestScopeOperations.setApiUser(user.id());
+    GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
+    prefs.emailStrategy = EmailStrategy.ATTENTION_SET_ONLY;
+    gApi.accounts().self().setPreferences(prefs);
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Add user to attention set. They receive an email since they are in the attention set.
+    change(r).addReviewer(user.id().toString());
+    assertThat(sender.getMessages()).isNotEmpty();
+    sender.clear();
+
+    // Irrelevant reply, User is still in the attention set, thus got another email.
+    change(r).current().review(ReviewInput.approve());
+    assertThat(sender.getMessages()).isNotEmpty();
+    sender.clear();
+
+    // Abandon the change which removes user from attention set; the user doesn't receive an email
+    // since they are not in the attention set.
+    change(r).abandon();
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void attentionSetWithEmailFilterFiltersNewPatchsets() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add preference for the user such that they only receive an email on changes that require
+    // their attention.
+    requestScopeOperations.setApiUser(user.id());
+    GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
+    prefs.emailStrategy = EmailStrategy.ATTENTION_SET_ONLY;
+    gApi.accounts().self().setPreferences(prefs);
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Add user to reviewers but not to the attention set
+    change(r)
+        .current()
+        .review(
+            ReviewInput.create()
+                .reviewer(user.email())
+                .removeUserFromAttentionSet(user.email(), "reason"));
+    sender.clear();
+
+    // amending a change doesn't send an email when user is not in the attention set.
+    amendChange(r.getChangeId());
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void attentionSetWithEmailFilterStillReceivesSubmitEmail() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add preference for the user such that they only receive an email on changes that require
+    // their attention.
+    requestScopeOperations.setApiUser(user.id());
+    GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
+    prefs.emailStrategy = EmailStrategy.ATTENTION_SET_ONLY;
+    gApi.accounts().self().setPreferences(prefs);
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Add user to reviewers but not to the attention set
+    change(r)
+        .current()
+        .review(
+            ReviewInput.approve()
+                .reviewer(user.email())
+                .removeUserFromAttentionSet(user.email(), "reason"));
+    sender.clear();
+
+    // submitting the change sends an email even when user is not in the attention set.
+    change(r).current().submit();
+    assertThat(sender.getMessages()).isNotEmpty();
+  }
+
+  @Test
+  public void attentionSetWithEmailFilterImpactingOnlyChangeEmails() throws Exception {
+    // Add preference for the user such that they only receive an email on changes that require
+    // their attention.
+    requestScopeOperations.setApiUser(user.id());
+    GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
+    prefs.emailStrategy = EmailStrategy.ATTENTION_SET_ONLY;
+    gApi.accounts().self().setPreferences(prefs);
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Ensure emails that don't relate to changes are still sent.
+    gApi.accounts().id(user.id().get()).generateHttpPassword();
+    assertThat(sender.getMessages()).isNotEmpty();
+  }
+
+  @Test
+  public void cannotAddIrrelevantUserToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason")));
+    assertThat(exception.getMessage())
+        .isEqualTo(
+            String.format(
+                "%s doesn't exist or is not active on the change as an owner, uploader, reviewer, "
+                    + "or cc so they can't be added to the attention set",
+                user.email()));
+  }
+
+  @Test
+  public void cannotAddNonExistingUserToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> change(r).addToAttentionSet(new AttentionSetInput("INVALID USER", "reason")));
+    assertThat(exception.getMessage())
+        .isEqualTo(
+            "INVALID USER doesn't exist or is not active on the change as an owner,"
+                + " uploader, reviewer, or cc so they can't be added to the attention set");
+  }
+
+  @Test
+  public void cannotRemoveIrrelevantUserToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> change(r).attention(user.email()).remove(new AttentionSetInput("reason")));
+    assertThat(exception.getMessage())
+        .isEqualTo(
+            String.format(
+                "%s doesn't exist or is not active on the change as an owner, uploader, reviewer, "
+                    + "or cc so they can't be added to the attention set",
+                user.email()));
+  }
+
+  @Test
+  public void cannotRemoveIrrelevantUserToAttentionSetWithUserInInput() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                change(r)
+                    .attention(user.email())
+                    .remove(new AttentionSetInput(user.email(), "reason")));
+    assertThat(exception.getMessage())
+        .isEqualTo(
+            String.format(
+                "%s doesn't exist or is not active on the change as an owner, uploader, reviewer, "
+                    + "or cc so they can't be added to the attention set",
+                user.email()));
+  }
+
+  @Test
+  public void cannotRemoveNonExistingUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> change(r).attention("INVALID USER").remove(new AttentionSetInput("reason")));
+    assertThat(exception.getMessage())
+        .isEqualTo(
+            "INVALID USER doesn't exist or is not active on the change as an owner,"
+                + " uploader, reviewer, or cc so they can't be added to the attention set");
+  }
+
+  @Test
+  public void irrelevantUsersAddedToAttentionSetAreIgnoredOnReply() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    change(r).current().review(ReviewInput.create().addUserToAttentionSet(user.email(), "reason"));
+    assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+  }
+
+  @Test
+  public void newReviewerCanBeAddedToTheAttentionSetManually() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r)
+        .current()
+        .review(
+            ReviewInput.create()
+                .reviewer(user.email())
+                .addUserToAttentionSet(user.email(), "reason")
+                .blockAutomaticAttentionSetRules());
+    assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user)).operation())
+        .isEqualTo(Operation.ADD);
+  }
+
+  @Test
+  public void newReviewerCanBeAddedToTheAttentionSetAutomatically() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).current().review(ReviewInput.create().reviewer(user.email()));
+    assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user)).operation())
+        .isEqualTo(Operation.ADD);
+  }
+
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
+  public void onReplyCanAddInvisibleUsersToAttentionSetOnVisibleChanges() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+
+    // admin is invisible to the user, but they can still add them to the attention set since they
+    // see the change.
+    change(r).current().review(ReviewInput.create().addUserToAttentionSet(admin.email(), "reason"));
+    assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin)).operation())
+        .isEqualTo(Operation.ADD);
+  }
+
+  @Test
+  public void onReplyNonExistingUsersAreSilentlyIgnored() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    change(r)
+        .current()
+        .review(ReviewInput.create().addUserToAttentionSet("INVALID USER", "reason"));
+    assertThat(getAttentionSetUpdates(r.getChange().getId())).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
+  public void canModifyAttentionSetForInvisibleUsersOnVisibleChanges() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+
+    // admin is invisible to the user, but they can still add them to the attention set since they
+    // see the change.
+    change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "reason"));
+    assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin)).operation())
+        .isEqualTo(Operation.ADD);
+
+    // admin is invisible to the user, but they can still remove them to the attention set since
+    // they see the change.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin)).operation())
+        .isEqualTo(Operation.REMOVE);
+  }
+
   private List<AttentionSetUpdate> getAttentionSetUpdatesForUser(
       PushOneCommit.Result r, TestAccount account) {
     return getAttentionSetUpdates(r.getChange().getId()).stream()
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/LifecycleListenersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/LifecycleListenersIT.java
new file mode 100644
index 0000000..59914bc
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/LifecycleListenersIT.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2020 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 org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.gerrit.acceptance.AbstractLifecycleListenersTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.inject.Inject;
+import org.junit.Before;
+import org.junit.Test;
+
+public class LifecycleListenersIT extends AbstractLifecycleListenersTest {
+  @Inject private InvocationCheck invocationCheck;
+
+  @Before
+  public void before() {
+    invocationCheck.setStartInvoked(false);
+    invocationCheck.setStopInvoked(false);
+  }
+
+  @Test
+  public void lifecycleListenerSuccessfulInvocation() throws Exception {
+    try (AutoCloseable ignored = installPlugin("my-plugin", SimpleModule.class)) {
+      RestResponse response = adminRestSession.get("/changes/?--my-plugin--opt&q=status:open");
+      response.assertOK();
+      assertTrue(invocationCheck.isStartInvoked());
+      assertTrue(invocationCheck.isStopInvoked());
+    }
+  }
+
+  @Test
+  public void lifecycleListenerUnsuccessfulInvocation() throws Exception {
+    try (AutoCloseable ignored = installPlugin("my-plugin", SimpleModule.class)) {
+      RestResponse response = adminRestSession.get("/projects/");
+      response.assertOK();
+      assertFalse(invocationCheck.isStartInvoked());
+      assertFalse(invocationCheck.isStopInvoked());
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
index 7649316..17bf37e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
@@ -22,9 +22,11 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gson.Gson;
 import com.google.gson.reflect.TypeToken;
+import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
 import org.junit.Test;
@@ -68,6 +70,24 @@
   }
 
   @Test
+  public void querySingleChangeWithBulkAttribute() throws Exception {
+    getSingleChangeWithPluginDefinedBulkAttribute(
+        id -> pluginInfosFromChangeInfos(adminRestSession.get(changeQueryUrl(id))));
+  }
+
+  @Test
+  public void pluginDefinedGetChangeWithSimpleAttribute() throws Exception {
+    getSingleChangeWithPluginDefinedBulkAttribute(
+        id -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeUrl(id))));
+  }
+
+  @Test
+  public void pluginDefinedGetChangeDetailWithSimpleAttribute() throws Exception {
+    getSingleChangeWithPluginDefinedBulkAttribute(
+        id -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeDetailUrl(id))));
+  }
+
+  @Test
   public void queryChangeWithOption() throws Exception {
     getChangeWithOption(
         id -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id))),
@@ -88,6 +108,57 @@
         (id, opts) -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id, opts))));
   }
 
+  @Test
+  public void pluginDefinedQueryChangeWithOption() throws Exception {
+    getChangeWithPluginDefinedBulkAttributeOption(
+        id -> pluginInfosFromChangeInfos(adminRestSession.get(changeQueryUrl(id))),
+        (id, opts) -> pluginInfosFromChangeInfos(adminRestSession.get(changeQueryUrl(id, opts))));
+  }
+
+  @Test
+  public void pluginDefinedGetChangeWithOption() throws Exception {
+    getChangeWithPluginDefinedBulkAttributeOption(
+        id -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeUrl(id))),
+        (id, opts) -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeUrl(id, opts))));
+  }
+
+  @Test
+  public void pluginDefinedGetChangeDetailWithOption() throws Exception {
+    getChangeWithPluginDefinedBulkAttributeOption(
+        id -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeDetailUrl(id))),
+        (id, opts) -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeDetailUrl(id, opts))));
+  }
+
+  @Test
+  public void queryMultipleChangesWithPluginDefinedAttribute() throws Exception {
+    getMultipleChangesWithPluginDefinedBulkAttribute(
+        () -> pluginInfosFromChangeInfos(adminRestSession.get("/changes/?q=status:open")));
+  }
+
+  @Test
+  public void queryChangesByCommitMessageWithPluginDefinedBulkAttribute() throws Exception {
+    getChangesByCommitMessageWithPluginDefinedBulkAttribute(
+        () -> pluginInfosFromChangeInfos(adminRestSession.get("/changes/?q=status:open")));
+  }
+
+  @Test
+  public void getMultipleChangesWithPluginDefinedAndChangeAttributes() throws Exception {
+    getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
+        () -> pluginInfosFromChangeInfos(adminRestSession.get("/changes/?q=status:open")));
+  }
+
+  @Test
+  public void getMultipleChangesWithPluginDefinedAttributeInSingleCall() throws Exception {
+    getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
+        () -> pluginInfosFromChangeInfos(adminRestSession.get("/changes/?q=status:open")));
+  }
+
+  @Test
+  public void getChangeWithPluginDefinedException() throws Exception {
+    getChangeWithPluginDefinedBulkAttributeWithException(
+        id -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeUrl(id))));
+  }
+
   private String changeQueryUrl(Change.Id id) {
     return changeQueryUrl(id, ImmutableListMultimap.of());
   }
@@ -133,7 +204,8 @@
   }
 
   @Nullable
-  private static List<MyInfo> pluginInfoFromSingletonList(RestResponse res) throws Exception {
+  private static List<PluginDefinedInfo> pluginInfoFromSingletonList(RestResponse res)
+      throws Exception {
     res.assertOK();
     List<Map<String, Object>> changeInfos =
         GSON.fromJson(res.getReader(), new TypeToken<List<Map<String, Object>>>() {}.getType());
@@ -142,10 +214,28 @@
   }
 
   @Nullable
-  private List<MyInfo> pluginInfoFromChangeInfo(RestResponse res) throws Exception {
+  private List<PluginDefinedInfo> pluginInfoFromChangeInfo(RestResponse res) throws Exception {
     res.assertOK();
     Map<String, Object> changeInfo =
         GSON.fromJson(res.getReader(), new TypeToken<Map<String, Object>>() {}.getType());
     return decodeRawPluginsList(GSON, changeInfo.get("plugins"));
   }
+
+  @Nullable
+  private Map<Change.Id, List<PluginDefinedInfo>> pluginInfoMapFromChangeInfo(RestResponse res)
+      throws Exception {
+    res.assertOK();
+    Map<String, Object> changeInfo =
+        GSON.fromJson(res.getReader(), new TypeToken<Map<String, Object>>() {}.getType());
+    return getPluginInfosFromChangeInfos(GSON, Arrays.asList(changeInfo));
+  }
+
+  @Nullable
+  private Map<Change.Id, List<PluginDefinedInfo>> pluginInfosFromChangeInfos(RestResponse res)
+      throws Exception {
+    res.assertOK();
+    List<Map<String, Object>> changeInfos =
+        GSON.fromJson(res.getReader(), new TypeToken<List<Map<String, Object>>>() {}.getType());
+    return getPluginInfosFromChangeInfos(GSON, changeInfos);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index 6f519f1..5bcf995 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.InheritableBoolean;
@@ -373,4 +374,27 @@
         change2.getChangeId(),
         headAfterFirstSubmit.name());
   }
+
+  @Test
+  public void dependencyOnOutdatedPatchSetNotPreventingCherryPick() throws Throwable {
+    // Create a change
+    PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
+    PushOneCommit.Result changeResult = change.to("refs/for/master");
+    PatchSet.Id patchSetId = changeResult.getPatchSetId();
+
+    // Create a successor change.
+    PushOneCommit change2 =
+        pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
+    PushOneCommit.Result change2Result = change2.to("refs/for/master");
+
+    // Create new patch set for first change.
+    testRepo.reset(changeResult.getCommit().name());
+    amendChange(changeResult.getChangeId());
+
+    // Approve both changes
+    approve(changeResult.getChangeId());
+    approve(change2Result.getChangeId());
+
+    assertSubmittable(change2Result.getChangeId());
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index 1912697..66eb48c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
@@ -170,4 +171,49 @@
     assertRefUpdatedEvents(initialHead, headAfterSubmit);
     assertChangeMergedEvents(id1, headAfterSubmit.name());
   }
+
+  @Test
+  public void dependencyOnOutdatedPatchSetPreventsFastForward() throws Throwable {
+    // Create a change
+    PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
+    PushOneCommit.Result changeResult = change.to("refs/for/master");
+    PatchSet.Id patchSetId = changeResult.getPatchSetId();
+
+    // Create a successor change.
+    PushOneCommit change2 =
+        pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
+    PushOneCommit.Result change2Result = change2.to("refs/for/master");
+
+    // Create new patch set for first change.
+    testRepo.reset(changeResult.getCommit().name());
+    amendChange(changeResult.getChangeId());
+
+    // Approve both changes
+    approve(changeResult.getChangeId());
+    approve(change2Result.getChangeId());
+
+    // submit button is disabled.
+    assertSubmitDisabled(change2Result.getChangeId());
+
+    submitWithConflict(
+        change2Result.getChangeId(),
+        "Failed to submit 2 changes due to the following problems:\n"
+            + "Change "
+            + change2Result.getChange().getId()
+            + ": Depends on change that was not submitted."
+            + " Commit "
+            + change2Result.getCommit().name()
+            + " depends on commit "
+            + changeResult.getCommit().name()
+            + ", which is outdated patch set "
+            + patchSetId.get()
+            + " of change "
+            + changeResult.getChange().getId()
+            + ". The latest patch set is "
+            + changeResult.getPatchSetId().get()
+            + ".");
+
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
index 5fe741d..995de0d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.BranchNameKey;
-import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
@@ -534,48 +533,6 @@
   }
 
   @Test
-  public void dependencyOnOutdatedPatchSetPreventsMerge() throws Throwable {
-    // Create a change
-    PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
-    PushOneCommit.Result changeResult = change.to("refs/for/master");
-    PatchSet.Id patchSetId = changeResult.getPatchSetId();
-
-    // Create a successor change.
-    PushOneCommit change2 =
-        pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
-    PushOneCommit.Result change2Result = change2.to("refs/for/master");
-
-    // Create new patch set for first change.
-    testRepo.reset(changeResult.getCommit().name());
-    amendChange(changeResult.getChangeId());
-
-    // Approve both changes
-    approve(changeResult.getChangeId());
-    approve(change2Result.getChangeId());
-
-    submitWithConflict(
-        change2Result.getChangeId(),
-        "Failed to submit 2 changes due to the following problems:\n"
-            + "Change "
-            + change2Result.getChange().getId()
-            + ": Depends on change that was not submitted."
-            + " Commit "
-            + change2Result.getCommit().name()
-            + " depends on commit "
-            + changeResult.getCommit().name()
-            + ", which is outdated patch set "
-            + patchSetId.get()
-            + " of change "
-            + changeResult.getChange().getId()
-            + ". The latest patch set is "
-            + changeResult.getPatchSetId().get()
-            + ".");
-
-    assertRefUpdatedEvents();
-    assertChangeMergedEvents();
-  }
-
-  @Test
   public void dependencyOnDeletedChangePreventsMerge() throws Throwable {
     // Create a change
     PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
diff --git a/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java b/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
index 9c17a5a..4453345 100644
--- a/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gson.reflect.TypeToken;
 import java.util.Map;
 import org.junit.Test;
@@ -32,6 +33,7 @@
     Map<String, GroupInfo> groupMap =
         newGson()
             .fromJson(response.getReader(), new TypeToken<Map<String, GroupInfo>>() {}.getType());
-    assertThat(groupMap.keySet()).containsExactly("Administrators", "Service Users");
+    assertThat(groupMap.keySet())
+        .containsExactly("Administrators", ServiceUserClassifier.SERVICE_USERS);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
index 191d5c5..531357a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
@@ -31,7 +31,9 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.PushResult;
@@ -52,6 +54,13 @@
     }
   }
 
+  @ConfigSuite.Config
+  public static Config skipFalse() {
+    Config config = new Config();
+    config.setBoolean("auth", null, "skipFullRefEvaluationIfAllRefsAreVisible", false);
+    return config;
+  }
+
   @Inject private ProjectOperations projectOperations;
 
   private RevCommit initialHead;
@@ -93,6 +102,20 @@
   }
 
   @Test
+  public void createTagForExistingCommit_withoutGlobalReadPermissions() throws Exception {
+    removeReadAccessOnRefsStar();
+    grantReadAccessOnRefsHeadsStar();
+    createTagForExistingCommit();
+  }
+
+  @Test
+  public void createTagForNewCommit_withoutGlobalReadPermissions() throws Exception {
+    removeReadAccessOnRefsStar();
+    grantReadAccessOnRefsHeadsStar();
+    createTagForNewCommit();
+  }
+
+  @Test
   public void fastForward() throws Exception {
     allowTagCreation();
     String tagName = pushTagForExistingCommit(Status.OK);
@@ -109,6 +132,15 @@
     fastForwardTagToExistingCommit(tagName, expectedStatus);
     fastForwardTagToNewCommit(tagName, expectedStatus);
 
+    // Above we just fast-forwarded the tag to a new commit which is not part of any branch. By
+    // default this tag is not visible, as users can only see tags that point to commits that are
+    // part of visible branches, which is not the case for this tag. It's odd that we allow the user
+    // to create such a tag that is then not visible to the creator. Below we want to fast-forward
+    // this tag, but this is only possible if the tag is visible. To make it visible we must allow
+    // the user to read all tags, regardless of whether it points to a commit that is part of a
+    // visible branch.
+    allowReadingAllTag();
+
     allowForcePushOnRefsTags();
     fastForwardTagToExistingCommit(tagName, Status.OK);
     fastForwardTagToNewCommit(tagName, Status.OK);
@@ -234,6 +266,49 @@
     assertWithMessage(tagType.name()).that(refUpdate.getStatus()).isEqualTo(expectedStatus);
   }
 
+  private void removeReadAccessOnRefsStar() {
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .remove(permissionKey(Permission.READ).ref("refs/*"))
+        .update();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(permissionKey(Permission.READ).ref("refs/*"))
+        .update();
+  }
+
+  private void grantReadAccessOnRefsHeadsStar() {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
+  }
+
+  private void allowReadingAllTag() throws Exception {
+    // Tags are only visible if the commits to which they point are part of a visible branch.
+    // To make all tags visible, including tags that point to commits that are not part of a visible
+    // branch, either auth.skipFullRefEvaluationIfAllRefsAreVisible in gerrit.config needs to be
+    // true, or the user must have READ access for all refs in the repository.
+
+    if (cfg.getBoolean("auth", "skipFullRefEvaluationIfAllRefsAreVisible", true)) {
+      return;
+    }
+
+    // By default READ access in the All-Projects project is granted to registered users on refs/*,
+    // which makes all refs, except refs/meta/config, visible to them. refs/meta/config is not
+    // visible since by default READ access to it is exclusively granted to the project owners only.
+    // This means to make all refs, and thus all tags, visible, we must allow registered users to
+    // see the refs/meta/config branch.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
+  }
+
   private void allowTagCreation() throws Exception {
     projectOperations
         .project(project)
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java
new file mode 100644
index 0000000..b4b1be0
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java
@@ -0,0 +1,594 @@
+// Copyright (C) 2020 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.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.projects.BranchInfo;
+import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.api.projects.TagInput;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+public class GetBranchIT extends AbstractDaemonTest {
+  @Inject private ChangeOperations changeOperations;
+  @Inject private GroupOperations groupOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @ConfigSuite.Config
+  public static Config skipFalse() {
+    Config config = new Config();
+    config.setBoolean("auth", null, "skipFullRefEvaluationIfAllRefsAreVisible", false);
+    return config;
+  }
+
+  @Test
+  public void cannotGetNonExistingBranch() {
+    assertBranchNotFound(project, RefNames.fullName("non-existing"));
+  }
+
+  @Test
+  public void getBranch() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchFound(project, RefNames.fullName("master"));
+  }
+
+  @Test
+  public void getBranchByShortName() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchFound(project, "master");
+  }
+
+  @Test
+  public void cannotGetNonVisibleBranch() {
+    String branchName = "master";
+
+    // block read access to the branch
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref(RefNames.fullName(branchName)).group(ANONYMOUS_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchNotFound(project, RefNames.fullName(branchName));
+  }
+
+  @Test
+  public void cannotGetNonVisibleBranchByShortName() {
+    String branchName = "master";
+
+    // block read access to the branch
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref(RefNames.fullName(branchName)).group(ANONYMOUS_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchNotFound(project, branchName);
+  }
+
+  @Test
+  public void getChangeRef() throws Exception {
+    // create a change
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+
+    // a user without the 'Access Database' capability can see the change ref
+    requestScopeOperations.setApiUser(user.id());
+    String changeRef = RefNames.patchSetRef(PatchSet.id(changeId, 1));
+    assertBranchFound(project, changeRef);
+  }
+
+  @Test
+  public void getChangeRefOfNonVisibleChange() throws Exception {
+    // create a change
+    String branchName = "master";
+    Change.Id changeId = changeOperations.newChange().project(project).branch(branchName).create();
+
+    // block read access to the branch
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref(RefNames.fullName(branchName)).group(ANONYMOUS_USERS))
+        .update();
+
+    // a user without the 'Access Database' capability cannot see the change ref
+    requestScopeOperations.setApiUser(user.id());
+    String changeRef = RefNames.patchSetRef(PatchSet.id(changeId, 1));
+    assertBranchNotFound(project, changeRef);
+
+    // a user with the 'Access Database' capability can see the change ref
+    testGetRefWithAccessDatabase(project, changeRef);
+  }
+
+  @Test
+  public void getChangeEditRef() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    // create a change
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+
+    // create a change edit by 'user'
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(changeId.get()).edit().create();
+
+    // every user can see their own change edit refs
+    String changeEditRef = RefNames.refsEdit(user.id(), changeId, PatchSet.id(changeId, 1));
+    assertBranchFound(project, changeEditRef);
+
+    // a user without the 'Access Database' capability cannot see the change edit ref of another
+    // user
+    requestScopeOperations.setApiUser(user2.id());
+    assertBranchNotFound(project, changeEditRef);
+
+    // a user with the 'Access Database' capability can see the change edit ref of another user
+    testGetRefWithAccessDatabase(project, changeEditRef);
+  }
+
+  @Test
+  public void cannotGetChangeEditRefOfNonVisibleChange() throws Exception {
+    // create a change
+    String branchName = "master";
+    Change.Id changeId = changeOperations.newChange().project(project).branch(branchName).create();
+
+    // create a change edit by 'user'
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(changeId.get()).edit().create();
+
+    // make the change non-visible by blocking read access on the destination
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref(RefNames.fullName(branchName)).group(ANONYMOUS_USERS))
+        .update();
+
+    // user cannot see their own change edit refs if the change is no longer visible
+    String changeEditRef = RefNames.refsEdit(user.id(), changeId, PatchSet.id(changeId, 1));
+    assertBranchNotFound(project, changeEditRef);
+
+    // a user with the 'Access Database' capability can see the change edit ref
+    testGetRefWithAccessDatabase(project, changeEditRef);
+  }
+
+  @Test
+  public void getChangeMetaRef() throws Exception {
+    // create a change
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+
+    // A user without the 'Access Database' capability can see the change meta ref.
+    // This may be surprising, as 'Access Database' guards access to meta refs and the change meta
+    // ref is a meta ref, however change meta refs have been always visible to all users that can
+    // see the change and some tools rely on seeing these refs, so we have to keep the current
+    // behaviour.
+    requestScopeOperations.setApiUser(user.id());
+    String changeMetaRef = RefNames.changeMetaRef(changeId);
+    assertBranchFound(project, changeMetaRef);
+  }
+
+  @Test
+  public void getRefsMetaConfig() throws Exception {
+    // a non-project owner cannot get the refs/meta/config branch
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchNotFound(project, RefNames.REFS_CONFIG);
+
+    // a non-project owner cannot get the refs/meta/config branch even with the 'Access Database'
+    // capability
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
+    try {
+      assertBranchNotFound(project, RefNames.REFS_CONFIG);
+    } finally {
+      projectOperations
+          .allProjectsForUpdate()
+          .remove(
+              capabilityKey(GlobalCapability.ACCESS_DATABASE)
+                  .group(SystemGroupBackend.REGISTERED_USERS))
+          .update();
+    }
+
+    requestScopeOperations.setApiUser(user.id());
+
+    // a project owner can get the refs/meta/config branch
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    assertBranchFound(project, RefNames.REFS_CONFIG);
+  }
+
+  @Test
+  public void getUserRefOfOtherUser() throws Exception {
+    String userRef = RefNames.refsUsers(admin.id());
+
+    // a user without the 'Access Database' capability cannot see the user ref of another user
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchNotFound(allUsers, userRef);
+
+    // a user with the 'Access Database' capability can see the user ref of another user
+    testGetRefWithAccessDatabase(allUsers, userRef);
+  }
+
+  @Test
+  public void getOwnUserRef() throws Exception {
+    // every user can see the own user ref
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchFound(allUsers, RefNames.refsUsers(user.id()));
+
+    // TODO: every user can see the own user ref via the magic ref/users/self ref
+    // requestScopeOperations.setApiUser(user.id());
+    // assertBranchFound(allUsers, RefNames.REFS_USERS_SELF);
+  }
+
+  @Test
+  public void getExternalIdsRefs() throws Exception {
+    // a user without the 'Access Database' capability cannot see the refs/meta/external-ids ref
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchNotFound(allUsers, RefNames.REFS_EXTERNAL_IDS);
+
+    // a user with the 'Access Database' capability can see the refs/meta/external-ids ref
+    testGetRefWithAccessDatabase(allUsers, RefNames.REFS_EXTERNAL_IDS);
+  }
+
+  @Test
+  public void getGroupRef() throws Exception {
+    // create a group
+    AccountGroup.UUID ownerGroupUuid =
+        groupOperations.newGroup().name("owner-group").addMember(admin.id()).create();
+    AccountGroup.UUID testGroupUuid =
+        groupOperations.newGroup().name("test-group").ownerGroupUuid(ownerGroupUuid).create();
+
+    // a non-group owner without the 'Access Database' capability cannot see the group ref
+    requestScopeOperations.setApiUser(user.id());
+    String groupRef = RefNames.refsGroups(testGroupUuid);
+    assertBranchNotFound(allUsers, groupRef);
+
+    // a non-group owner with the 'Access Database' capability can see the group ref
+    testGetRefWithAccessDatabase(allUsers, groupRef);
+
+    // a group owner can see the group ref if the group ref is visible
+    groupOperations.group(ownerGroupUuid).forUpdate().addMember(user.id()).update();
+    assertBranchFound(allUsers, groupRef);
+
+    // A group owner cannot see the group ref if the group ref is not visible.
+    // The READ access for refs/groups/* must be blocked on All-Projects rather than All-Users.
+    // This is because READ access for refs/groups/* on All-Users is by default granted to
+    // REGISTERED_USERS, and if an ALLOW rule and a BLOCK rule are on the same project and ref,
+    // the ALLOW rule takes precedence.
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/groups/*").group(ANONYMOUS_USERS))
+        .update();
+    assertBranchNotFound(allUsers, groupRef);
+  }
+
+  @Test
+  public void getGroupNamesRef() throws Exception {
+    // a user without the 'Access Database' capability cannot see the refs/meta/group-names ref
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchNotFound(allUsers, RefNames.REFS_GROUPNAMES);
+
+    // a user with the 'Access Database' capability can see the refs/meta/group-names ref
+    testGetRefWithAccessDatabase(allUsers, RefNames.REFS_GROUPNAMES);
+  }
+
+  @Test
+  public void getDeletedGroupRef() throws Exception {
+    // Create a deleted group ref. We must create a directly in the repo, since group deletion is
+    // not supported yet.
+    String deletedGroupRef = RefNames.refsDeletedGroups(AccountGroup.uuid("deleted-group"));
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(allUsers))) {
+      testRepo
+          .branch(deletedGroupRef)
+          .commit()
+          .message("Some Message")
+          .add("group.config", "content")
+          .create();
+    }
+
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchNotFound(allUsers, deletedGroupRef);
+
+    // a user with the 'Access Database' capability can see the deleted group ref
+    testGetRefWithAccessDatabase(allUsers, deletedGroupRef);
+  }
+
+  @Test
+  public void getDraftCommentsRef() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    // create a change
+    String fileName = "a.txt";
+    Change change = createChange("A Change", fileName, "content").getChange().change();
+
+    // create a draft comment by the by 'user'
+    requestScopeOperations.setApiUser(user.id());
+    DraftInput draftInput = new DraftInput();
+    draftInput.path = fileName;
+    draftInput.line = 0;
+    draftInput.message = "Some Comment";
+    gApi.changes().id(change.getChangeId()).current().createDraft(draftInput);
+
+    // every user can see their own draft comments refs
+    // TODO: is this a bug?
+    String draftCommentsRef = RefNames.refsDraftComments(change.getId(), user.id());
+    assertBranchFound(allUsers, draftCommentsRef);
+
+    // a user without the 'Access Database' capability cannot see the draft comments ref of another
+    // user
+    requestScopeOperations.setApiUser(user2.id());
+    assertBranchNotFound(allUsers, draftCommentsRef);
+
+    // a user with the 'Access Database' capability can see the draft comments ref of another user
+    testGetRefWithAccessDatabase(allUsers, draftCommentsRef);
+  }
+
+  @Test
+  public void getStarredChangesRef() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    // create a change
+    Change change = createChange().getChange().change();
+
+    // let user star the change
+    requestScopeOperations.setApiUser(user.id());
+    gApi.accounts().self().starChange(Integer.toString(change.getChangeId()));
+
+    // every user can see their own starred changes refs
+    // TODO: is this a bug?
+    String starredChangesRef = RefNames.refsStarredChanges(change.getId(), user.id());
+    assertBranchFound(allUsers, starredChangesRef);
+
+    // a user without the 'Access Database' capability cannot see the starred changes ref of another
+    // user
+    requestScopeOperations.setApiUser(user2.id());
+    assertBranchNotFound(allUsers, starredChangesRef);
+
+    // a user with the 'Access Database' capability can see the starred changes ref of another user
+    testGetRefWithAccessDatabase(allUsers, starredChangesRef);
+  }
+
+  @Test
+  public void getTagRef() throws Exception {
+    // create a tag
+    TagInput input = new TagInput();
+    input.message = "My Tag";
+    input.revision = projectOperations.project(project).getHead("master").name();
+    TagInfo tagInfo = gApi.projects().name(project.get()).tag("my-tag").create(input).get();
+
+    // any user who can see the project, can see the tag
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchFound(project, tagInfo.ref);
+  }
+
+  @Test
+  public void cannotGetTagRefThatPointsToNonVisibleBranch() throws Exception {
+    // create a tag
+    TagInput input = new TagInput();
+    input.message = "My Tag";
+    input.revision = projectOperations.project(project).getHead("master").name();
+    TagInfo tagInfo = gApi.projects().name(project.get()).tag("my-tag").create(input).get();
+
+    // block read access to the branch
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref(RefNames.fullName("master")).group(ANONYMOUS_USERS))
+        .update();
+
+    // if the user cannot see the project, the tag is not visible
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchNotFound(project, tagInfo.ref);
+  }
+
+  @Test
+  public void getSymbolicRef() throws Exception {
+    // 'HEAD' is visible since it points to 'master' that is visible
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchFound(project, "HEAD");
+  }
+
+  @Test
+  public void cannotGetSymbolicRefThatPointsToNonVisibleBranch() {
+    // block read access to the branch to which HEAD points by default
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref(RefNames.fullName("master")).group(ANONYMOUS_USERS))
+        .update();
+
+    // since 'master' is not visible, 'HEAD' which points to 'master' is also not visible
+    requestScopeOperations.setApiUser(user.id());
+    assertBranchNotFound(project, "HEAD");
+  }
+
+  @Test
+  public void getAccountSequenceRef() throws Exception {
+    // a user without the 'Access Database' capability cannot see the refs/sequences/accounts ref
+    requestScopeOperations.setApiUser(user.id());
+    String accountSequenceRef = RefNames.REFS_SEQUENCES + Sequences.NAME_ACCOUNTS;
+    assertBranchNotFound(allUsers, accountSequenceRef);
+
+    // a user with the 'Access Database' capability can see the refs/sequences/accounts ref
+    testGetRefWithAccessDatabase(allUsers, accountSequenceRef);
+  }
+
+  @Test
+  public void getChangeSequenceRef() throws Exception {
+    // a user without the 'Access Database' capability cannot see the refs/sequences/changes ref
+    requestScopeOperations.setApiUser(user.id());
+    String changeSequenceRef = RefNames.REFS_SEQUENCES + Sequences.NAME_CHANGES;
+    assertBranchNotFound(allProjects, changeSequenceRef);
+
+    // a user with the 'Access Database' capability can see the refs/sequences/changes ref
+    testGetRefWithAccessDatabase(allProjects, changeSequenceRef);
+  }
+
+  @Test
+  public void getGroupSequenceRef() throws Exception {
+    // a user without the 'Access Database' capability cannot see the refs/sequences/groups ref
+    requestScopeOperations.setApiUser(user.id());
+    String groupSequenceRef = RefNames.REFS_SEQUENCES + Sequences.NAME_GROUPS;
+    assertBranchNotFound(allUsers, groupSequenceRef);
+
+    // a user with the 'Access Database' capability can see the refs/sequences/groups ref
+    testGetRefWithAccessDatabase(allUsers, groupSequenceRef);
+  }
+
+  @Test
+  public void getVersionMetaRef() throws Exception {
+    // TODO: a user without the 'Access Database' capability cannot see the refs/meta/version ref
+    // requestScopeOperations.setApiUser(user.id());
+    // assertBranchNotFound(allProjects, RefNames.REFS_VERSION);
+
+    // a user with the 'Access Database' capability can see the refs/meta/vaersion ref
+    testGetRefWithAccessDatabase(allProjects, RefNames.REFS_VERSION);
+  }
+
+  @Test
+  public void cannotGetAutoMergeRef() throws Exception {
+    String file = "foo/a.txt";
+
+    // Create a base change.
+    Change.Id baseChange =
+        changeOperations
+            .newChange()
+            .project(project)
+            .branch("master")
+            .file(file)
+            .content("base content")
+            .create();
+    approve(Integer.toString(baseChange.get()));
+    gApi.changes().id(baseChange.get()).current().submit();
+
+    // Create another branch
+    String branchName = "foo";
+    createBranchWithRevision(
+        BranchNameKey.create(project, branchName),
+        projectOperations.project(project).getHead("master").name());
+
+    // Create a change in master that touches the file.
+    Change.Id changeInMaster =
+        changeOperations
+            .newChange()
+            .project(project)
+            .branch("master")
+            .file(file)
+            .content("master content")
+            .create();
+    approve(Integer.toString(changeInMaster.get()));
+    gApi.changes().id(changeInMaster.get()).current().submit();
+
+    // Create a change in the other branch and that touches the file.
+    Change.Id changeInOtherBranch =
+        changeOperations
+            .newChange()
+            .project(project)
+            .branch(branchName)
+            .file(file)
+            .content("other content")
+            .create();
+    approve(Integer.toString(changeInOtherBranch.get()));
+    gApi.changes().id(changeInOtherBranch.get()).current().submit();
+
+    // Create a merge change with a conflict resolution for the file.
+    Change.Id mergeChange =
+        changeOperations
+            .newChange()
+            .project(project)
+            .branch("master")
+            .mergeOfButBaseOnFirst()
+            .tipOfBranch("master")
+            .and()
+            .tipOfBranch(branchName)
+            .file(file)
+            .content("merged content")
+            .create();
+
+    String mergeRevision =
+        changeOperations.change(mergeChange).currentPatchset().get().commitId().name();
+    assertBranchNotFound(project, RefNames.refsCacheAutomerge(mergeRevision));
+  }
+
+  private void testGetRefWithAccessDatabase(Project.NameKey project, String ref)
+      throws RestApiException {
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allowCapability(GlobalCapability.ACCESS_DATABASE).group(REGISTERED_USERS))
+        .update();
+    try {
+      requestScopeOperations.setApiUser(user.id());
+      assertBranchFound(project, ref);
+    } finally {
+      projectOperations
+          .allProjectsForUpdate()
+          .remove(
+              capabilityKey(GlobalCapability.ACCESS_DATABASE)
+                  .group(SystemGroupBackend.REGISTERED_USERS))
+          .update();
+    }
+  }
+
+  private void assertBranchNotFound(Project.NameKey project, String ref) {
+    ResourceNotFoundException exception =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.projects().name(project.get()).branch(ref).get());
+    assertThat(exception).hasMessageThat().isEqualTo("Not found: " + ref);
+  }
+
+  private void assertBranchFound(Project.NameKey project, String ref) throws RestApiException {
+    BranchInfo branchInfo = gApi.projects().name(project.get()).branch(ref).get();
+    assertThat(branchInfo.ref).isEqualTo(RefNames.fullName(ref));
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/account/ServiceUserClassifierIT.java b/javatests/com/google/gerrit/acceptance/server/account/ServiceUserClassifierIT.java
index 1e33c69..df84fd7 100644
--- a/javatests/com/google/gerrit/acceptance/server/account/ServiceUserClassifierIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/account/ServiceUserClassifierIT.java
@@ -44,7 +44,7 @@
 
   @Test
   public void userWithDirectMembershipInServiceUserIsAServiceUser() throws Exception {
-    TestAccount user = accountCreator.create(null, "Service Users");
+    TestAccount user = accountCreator.create(null, ServiceUserClassifier.SERVICE_USERS);
     assertThat(serviceUserClassifier.isServiceUser(user.id())).isTrue();
   }
 
@@ -91,7 +91,7 @@
 
   private AccountGroup.UUID serviceUsersUUID() {
     return groupCache
-        .get(AccountGroup.nameKey("Service Users"))
+        .get(AccountGroup.nameKey(ServiceUserClassifier.SERVICE_USERS))
         .orElseThrow(() -> new IllegalStateException("unable to find 'Service Users'"))
         .getGroupUUID();
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index b4dd4b3..2c42d0a 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -1090,6 +1090,76 @@
             contextLines("2", "line_2", "3", "line_3", "4", "line_4", "5", "line_5"));
   }
 
+  @Test
+  public void commentContextForCommentsOnDifferentPatchsets() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+
+    ImmutableList.Builder<String> content = ImmutableList.builder();
+    for (int i = 1; i <= 10; i++) {
+      content.add("line_" + i);
+    }
+
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                SUBJECT,
+                FILE_NAME,
+                String.join("\n", content.build()),
+                r1.getChangeId())
+            .to("refs/for/master");
+
+    PushOneCommit.Result r3 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                SUBJECT,
+                FILE_NAME,
+                content.build().stream().collect(Collectors.joining("\n")),
+                r1.getChangeId())
+            .to("refs/for/master");
+
+    addCommentOnLine(r2, "r2: please fix", 1);
+    addCommentOnRange(r2, "r2: looks good", commentRangeInLines(2, 3));
+    addCommentOnLine(r3, "r3: please fix", 6);
+    addCommentOnRange(r3, "r3: looks good", commentRangeInLines(7, 8));
+
+    List<CommentInfo> comments =
+        gApi.changes().id(r2.getChangeId()).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(4);
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("r2: please fix"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(contextLines("1", "line_1"));
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("r2: looks good"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(contextLines("2", "line_2", "3", "line_3"));
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("r3: please fix"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(contextLines("6", "line_6"));
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("r3: looks good"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(contextLines("7", "line_7", "8", "line_8"));
+  }
+
   private List<ContextLineInfo> contextLines(String... args) {
     List<ContextLineInfo> result = new ArrayList<>();
     for (int i = 0; i < args.length; i += 2) {
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 9b12f29..b2a349e 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -1714,7 +1714,7 @@
         .sent("newpatchset", sc)
         .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
         .to(sc.reviewer)
-        .to(other)
+        .cc(other)
         .cc(sc.ccer)
         .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .noOneElse();
@@ -1730,7 +1730,7 @@
         .sent("newpatchset", sc)
         .notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
         .to(sc.reviewer)
-        .to(other)
+        .cc(other)
         .cc(sc.ccer)
         .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .noOneElse();
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
index 4f79e09..5679c41 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
@@ -459,7 +459,7 @@
   private ImmutableSet<CommentInfo> getCommentsAndRobotComments(String changeId)
       throws RestApiException {
     return Streams.concat(
-            gApi.changes().id(changeId).comments().values().stream(),
+            gApi.changes().id(changeId).commentsRequest().get().values().stream(),
             gApi.changes().id(changeId).robotComments().values().stream())
         .flatMap(Collection::stream)
         .collect(toImmutableSet());
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/BUILD b/javatests/com/google/gerrit/acceptance/server/permissions/BUILD
new file mode 100644
index 0000000..e89e8d1
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_permissions",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java
new file mode 100644
index 0000000..d68d681
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java
@@ -0,0 +1,184 @@
+// Copyright (C) 2020 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.permissions;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.group.testing.TestGroupBackend;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.query.change.GroupBackedUser;
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import javax.inject.Inject;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests that permission logic used by {@link GroupBackedUser} works as expected. */
+public class GroupBackedUserPermissionIT extends AbstractDaemonTest {
+  @Inject private ChangeOperations changeOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private PermissionBackend permissionBackend;
+  @Inject private ChangeNotes.Factory changeNotesFactory;
+
+  private final TestGroupBackend testGroupBackend = new TestGroupBackend();
+  private final AccountGroup.UUID externalGroup = AccountGroup.uuid("testbackend:test");
+
+  @Before
+  public void setUp() {
+    // Allow only read on refs/heads/master by default
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .remove(permissionKey(Permission.READ).ref("refs/*").group(ANONYMOUS_USERS))
+        .add(allow(Permission.READ).ref("refs/heads/master").group(ANONYMOUS_USERS))
+        .update();
+  }
+
+  @Override
+  public Module createModule() {
+    /** Binding a {@link TestGroupBackend} to test adding external groups * */
+    return new AbstractModule() {
+      @Override
+      protected void configure() {
+        DynamicSet.bind(binder(), GroupBackend.class).toInstance(testGroupBackend);
+      }
+    };
+  }
+
+  @Test
+  public void defaultRefFilter_changeVisibilityIsAgnosticOfProvidedGroups() throws Exception {
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    Change.Id changeOnMaster = changeOperations.newChange().project(project).create();
+    Change.Id changeOnRefsMetaConfig =
+        changeOperations.newChange().project(project).branch("refs/meta/config").create();
+    // Check that only the change on the default branch is visible
+    assertThat(getVisibleRefNames(user))
+        .containsExactly(
+            "HEAD",
+            "refs/heads/master",
+            RefNames.changeMetaRef(changeOnMaster),
+            RefNames.patchSetRef(PatchSet.id(changeOnMaster, 1)));
+    // Grant access
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(externalGroup))
+        .update();
+    // Check that both changes are visible now
+    assertThat(getVisibleRefNames(user))
+        .containsExactly(
+            "HEAD",
+            "refs/heads/master",
+            "refs/meta/config",
+            RefNames.changeMetaRef(changeOnMaster),
+            RefNames.patchSetRef(PatchSet.id(changeOnMaster, 1)),
+            RefNames.changeMetaRef(changeOnRefsMetaConfig),
+            RefNames.patchSetRef(PatchSet.id(changeOnRefsMetaConfig, 1)));
+  }
+
+  @Test
+  public void defaultRefFilter_refVisibilityIsAgnosticOfProvidedGroups() throws Exception {
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    // Check that refs/meta/config isn't visible by default
+    assertThat(getVisibleRefNames(user)).containsExactly("HEAD", "refs/heads/master");
+    // Grant access
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(externalGroup))
+        .update();
+    // Check that refs/meta/config became visible
+    assertThat(getVisibleRefNames(user))
+        .containsExactly("HEAD", "refs/heads/master", "refs/meta/config");
+  }
+
+  @Test
+  public void changeVisibility_changeOnInvisibleBranchNotVisible() throws Exception {
+    // Create a change that is not visible to members of 'externalGroup'
+    Change.Id invisibleChange =
+        changeOperations.newChange().project(project).branch("refs/meta/config").create();
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                permissionBackend
+                    .user(user)
+                    .change(changeNotesFactory.create(project, invisibleChange))
+                    .check(ChangePermission.READ));
+    assertThat(thrown).hasMessageThat().isEqualTo("read not permitted");
+  }
+
+  @Test
+  public void changeVisibility_changeOnBranchVisibleToAnonymousIsVisible() throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    permissionBackend
+        .user(user)
+        .change(changeNotesFactory.create(project, changeId))
+        .check(ChangePermission.READ);
+  }
+
+  @Test
+  public void changeVisibility_changeOnBranchVisibleToRegisteredUsersIsVisible() throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    blockAnonymousRead();
+    permissionBackend
+        .user(user)
+        .change(changeNotesFactory.create(project, changeId))
+        .check(ChangePermission.READ);
+  }
+
+  private ImmutableList<String> getVisibleRefNames(CurrentUser user) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      return permissionBackend.user(user).project(project)
+          .filter(
+              repo.getRefDatabase().getRefs(), repo, PermissionBackend.RefFilterOptions.defaults())
+          .stream()
+          .map(Ref::getName)
+          .collect(toImmutableList());
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/LifecycleListenersIT.java b/javatests/com/google/gerrit/acceptance/ssh/LifecycleListenersIT.java
new file mode 100644
index 0000000..0596cad
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/LifecycleListenersIT.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2020 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.ssh;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.gerrit.acceptance.AbstractLifecycleListenersTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.inject.Inject;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+@UseSsh
+public class LifecycleListenersIT extends AbstractLifecycleListenersTest {
+  @Inject private InvocationCheck invocationCheck;
+
+  @Before
+  public void before() {
+    invocationCheck.setStartInvoked(false);
+    invocationCheck.setStopInvoked(false);
+  }
+
+  @Test
+  public void lifecycleListenerSuccessfulInvocation() throws Exception {
+    try (AutoCloseable ignored = installPlugin("my-plugin", SimpleModule.class)) {
+      adminSshSession.exec("gerrit query --format json status:open --my-plugin--opt");
+      adminSshSession.assertSuccess();
+      assertTrue(invocationCheck.isStartInvoked());
+      assertTrue(invocationCheck.isStopInvoked());
+    }
+  }
+
+  @Test
+  public void lifecycleListenerUnsuccessfulInvocation() throws Exception {
+    try (AutoCloseable ignored = installPlugin("my-plugin", SimpleModule.class)) {
+      adminSshSession.exec("gerrit ls-projects");
+      adminSshSession.assertSuccess();
+      assertFalse(invocationCheck.isStartInvoked());
+      assertFalse(invocationCheck.isStopInvoked());
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java b/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
index 4bf7c19..38293f9 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.server.query.change.OutputStreamQuery;
 import com.google.gson.Gson;
 import com.google.gson.reflect.TypeToken;
@@ -52,12 +53,55 @@
   }
 
   @Test
+  public void querySingleChangeWithBulkAttribute() throws Exception {
+    getSingleChangeWithPluginDefinedBulkAttribute(
+        id -> pluginInfosFromList(adminSshSession.exec(changeQueryCmd(id))));
+  }
+
+  @Test
   public void queryChangeWithOption() throws Exception {
     getChangeWithOption(
         id -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id))),
         (id, opts) -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id, opts))));
   }
 
+  @Test
+  public void queryPluginDefinedAttributeChangeWithOption() throws Exception {
+    getChangeWithPluginDefinedBulkAttributeOption(
+        id -> pluginInfosFromList(adminSshSession.exec(changeQueryCmd(id))),
+        (id, opts) -> pluginInfosFromList(adminSshSession.exec(changeQueryCmd(id, opts))));
+  }
+
+  @Test
+  public void queryMultipleChangesWithPluginDefinedAttribute() throws Exception {
+    getMultipleChangesWithPluginDefinedBulkAttribute(
+        () -> pluginInfosFromList(adminSshSession.exec("gerrit query --format json status:open")));
+  }
+
+  @Test
+  public void queryChangesByCommitMessageWithPluginDefinedBulkAttribute() throws Exception {
+    getChangesByCommitMessageWithPluginDefinedBulkAttribute(
+        () -> pluginInfosFromList(adminSshSession.exec("gerrit query --format json status:open")));
+  }
+
+  @Test
+  public void getMultipleChangesWithPluginDefinedAndChangeAttributes() throws Exception {
+    getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
+        () -> pluginInfosFromList(adminSshSession.exec("gerrit query --format json status:open")));
+  }
+
+  @Test
+  public void getMultipleChangesWithPluginDefinedAttributeInSingleCall() throws Exception {
+    getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
+        () -> pluginInfosFromList(adminSshSession.exec("gerrit query --format json status:open")));
+  }
+
+  @Test
+  public void getChangeWithPluginDefinedException() throws Exception {
+    getChangeWithPluginDefinedBulkAttributeWithException(
+        id -> pluginInfosFromList(adminSshSession.exec(changeQueryCmd(id))));
+  }
+
   private String changeQueryCmd(Change.Id id) {
     return changeQueryCmd(id, ImmutableListMultimap.of());
   }
@@ -72,7 +116,22 @@
   }
 
   @Nullable
-  private static List<MyInfo> pluginInfoFromSingletonList(String sshOutput) throws Exception {
+  private static List<PluginDefinedInfo> pluginInfoFromSingletonList(String sshOutput)
+      throws Exception {
+    List<Map<String, Object>> changeAttrs = getChangeAttrs(sshOutput);
+
+    assertThat(changeAttrs).hasSize(1);
+    return decodeRawPluginsList(GSON, changeAttrs.get(0).get("plugins"));
+  }
+
+  @Nullable
+  private static Map<Change.Id, List<PluginDefinedInfo>> pluginInfosFromList(String sshOutput)
+      throws Exception {
+    List<Map<String, Object>> changeAttrs = getChangeAttrs(sshOutput);
+    return getPluginInfosFromChangeInfos(GSON, changeAttrs);
+  }
+
+  private static List<Map<String, Object>> getChangeAttrs(String sshOutput) throws Exception {
     List<Map<String, Object>> changeAttrs = new ArrayList<>();
     for (String line : CharStreams.readLines(new StringReader(sshOutput))) {
       Map<String, Object> changeAttr =
@@ -81,8 +140,6 @@
         changeAttrs.add(changeAttr);
       }
     }
-
-    assertThat(changeAttrs).hasSize(1);
-    return decodeRawPluginsList(GSON, changeAttrs.get(0).get("plugins"));
+    return changeAttrs;
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshDaemonIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshDaemonIT.java
new file mode 100644
index 0000000..827c192
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshDaemonIT.java
@@ -0,0 +1,100 @@
+// Copyright (C) 2020 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.ssh;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.restapi.config.ListTasks;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import java.time.LocalDateTime;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@NoHttpd
+@UseSsh
+@Sandboxed
+@RunWith(ConfigSuite.class)
+@SuppressWarnings("unused")
+public class SshDaemonIT extends AbstractDaemonTest {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  @Inject private ListTasks listTasks;
+  @Inject private SitePaths gerritSitePath;
+
+  @ConfigSuite.Parameter protected Config config;
+
+  @ConfigSuite.Config
+  public static Config gracefulConfig() {
+    Config config = new Config();
+    config.setString("sshd", null, "gracefulStopTimeout", "10s");
+    return config;
+  }
+
+  @Override
+  public Module createSshModule() {
+    return new TestSshCommandModule();
+  }
+
+  public Future<Integer> startCommand(String command) throws Exception {
+    Callable<Integer> gracefulSession =
+        () -> {
+          int returnCode = -1;
+          logger.atFine().log("Before Command");
+          returnCode = userSshSession.execAndReturnStatus(command);
+          logger.atFine().log("After Command");
+          return returnCode;
+        };
+
+    ExecutorService executor = Executors.newFixedThreadPool(1);
+    Future<Integer> future = executor.submit(gracefulSession);
+
+    LocalDateTime timeout = LocalDateTime.now().plusSeconds(10);
+
+    TestCommand.syncPoint.await();
+
+    return future;
+  }
+
+  @Test
+  public void NonGracefulCommandIsStoppedImmediately() throws Exception {
+    Future<Integer> future = startCommand("non-graceful -d 5");
+    restart();
+    Assert.assertTrue(future.get() == -1);
+  }
+
+  @Test
+  public void GracefulCommandIsStoppedGracefully() throws Exception {
+    Future<Integer> future = startCommand("graceful -d 5");
+    restart();
+    if (cfg.getTimeUnit("sshd", null, "gracefulStopTimeout", 0, TimeUnit.SECONDS) == 0) {
+      Assert.assertTrue(future.get() == -1);
+    } else {
+      Assert.assertTrue(future.get() == 0);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/PatchsetOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/PatchsetOperationsImplTest.java
index 46d9abf..080c22c 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/change/PatchsetOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/PatchsetOperationsImplTest.java
@@ -1177,7 +1177,7 @@
   }
 
   private List<CommentInfo> getCommentsFromServer(Change.Id changeId) throws RestApiException {
-    return gApi.changes().id(changeId.get()).commentsAsList();
+    return gApi.changes().id(changeId.get()).commentsRequest().getAsList();
   }
 
   private List<RobotCommentInfo> getRobotCommentsFromServerFromCurrentPatchset(Change.Id changeId)
@@ -1191,7 +1191,7 @@
 
   private CommentInfo getCommentFromServer(Change.Id changeId, String uuid)
       throws RestApiException {
-    return gApi.changes().id(changeId.get()).commentsAsList().stream()
+    return gApi.changes().id(changeId.get()).commentsRequest().getAsList().stream()
         .filter(comment -> comment.id.equals(uuid))
         .findAny()
         .orElseThrow(
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
index 62dfc63..00d01d6 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
@@ -38,9 +38,12 @@
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestPermission;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.server.project.ProjectConfig;
@@ -56,6 +59,7 @@
 public class ProjectOperationsImplTest extends AbstractDaemonTest {
 
   @Inject private ProjectOperations projectOperations;
+  @Inject private GroupOperations groupsOperations;
 
   @Test
   public void defaultName() throws Exception {
@@ -114,6 +118,20 @@
   }
 
   @Test
+  public void permissionOnly() throws Exception {
+    Project.NameKey key = projectOperations.newProject().permissionOnly(true).create();
+    String head = gApi.projects().name(key.get()).head();
+    assertThat(head).isEqualTo(RefNames.REFS_CONFIG);
+  }
+
+  @Test
+  public void createWithOwners() throws Exception {
+    AccountGroup.UUID uuid = groupsOperations.newGroup().create();
+    Project.NameKey key = projectOperations.newProject().addOwner(uuid).create();
+    assertPermissions(key, groupRef(uuid), "refs/*", false, Permission.OWNER);
+  }
+
+  @Test
   public void getProjectConfig() throws Exception {
     Project.NameKey key = projectOperations.newProject().create();
     assertThat(projectOperations.project(key).getProjectConfig().getProject().getDescription())
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
index f6421a5..48fd38c 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableSet;
@@ -29,7 +28,6 @@
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.CurrentUser.PropertyKey;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -75,19 +73,6 @@
   }
 
   @Test
-  public void resetCurrentApiUserClearsCachedState() throws Exception {
-    requestScopeOperations.setApiUser(user.id());
-    PropertyKey<String> key = PropertyKey.create();
-    atrScope.get().getUser().put(key, "foo");
-    assertThat(atrScope.get().getUser().get(key)).hasValue("foo");
-
-    AcceptanceTestRequestScope.Context oldCtx = requestScopeOperations.resetCurrentApiUser();
-    checkCurrentUser(user.id());
-    assertThat(atrScope.get().getUser().get(key)).isEmpty();
-    assertThat(oldCtx.getUser().get(key)).hasValue("foo");
-  }
-
-  @Test
   public void setApiUserAnonymousSetsAnonymousUser() throws Exception {
     fastCheckCurrentUser(admin.id());
     requestScopeOperations.setApiUserAnonymous();
diff --git a/javatests/com/google/gerrit/elasticsearch/BUILD b/javatests/com/google/gerrit/elasticsearch/BUILD
index ab2bb12..e269fc2 100644
--- a/javatests/com/google/gerrit/elasticsearch/BUILD
+++ b/javatests/com/google/gerrit/elasticsearch/BUILD
@@ -17,8 +17,11 @@
         "//lib:junit",
         "//lib/guice",
         "//lib/httpcomponents:httpcore",
+        "//lib/jackson:jackson-annotations",
         "//lib/log:api",
         "//lib/testcontainers",
+        "//lib/testcontainers:docker-java-api",
+        "//lib/testcontainers:docker-java-transport",
         "//lib/testcontainers:testcontainers-elasticsearch",
     ],
 )
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
index 86829b9..48295ea 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
@@ -19,6 +19,7 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.testcontainers.elasticsearch.ElasticsearchContainer;
+import org.testcontainers.utility.DockerImageName;
 
 /* Helper class for running ES integration tests in docker container */
 public class ElasticContainer extends ElasticsearchContainer {
@@ -39,7 +40,7 @@
   private static String getImageName(ElasticVersion version) {
     switch (version) {
       case V6_8:
-        return "blacktop/elasticsearch:6.8.12";
+        return "blacktop/elasticsearch:6.8.13";
       case V7_0:
         return "blacktop/elasticsearch:7.0.1";
       case V7_1:
@@ -63,7 +64,9 @@
   }
 
   private ElasticContainer(ElasticVersion version) {
-    super(getImageName(version));
+    super(
+        DockerImageName.parse(getImageName(version))
+            .asCompatibleSubstituteFor("docker.elastic.co/elasticsearch/elasticsearch"));
   }
 
   @Override
diff --git a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
new file mode 100644
index 0000000..5d420d3
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
@@ -0,0 +1,103 @@
+// Copyright (C) 2018 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.cache;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import java.util.function.Supplier;
+import org.junit.Test;
+
+public class PerThreadCacheTest {
+
+  @SuppressWarnings("TruthIncompatibleType")
+  @Test
+  public void key_respectsClass() {
+    assertThat(PerThreadCache.Key.create(String.class))
+        .isEqualTo(PerThreadCache.Key.create(String.class));
+    assertThat(PerThreadCache.Key.create(String.class))
+        .isNotEqualTo(
+            /* expected: Key<String>, actual: Key<Integer> */ PerThreadCache.Key.create(
+                Integer.class));
+  }
+
+  @Test
+  public void key_respectsIdentifiers() {
+    assertThat(PerThreadCache.Key.create(String.class, "id1"))
+        .isEqualTo(PerThreadCache.Key.create(String.class, "id1"));
+    assertThat(PerThreadCache.Key.create(String.class, "id1"))
+        .isNotEqualTo(PerThreadCache.Key.create(String.class, "id2"));
+  }
+
+  @Test
+  public void endToEndCache() {
+    try (PerThreadCache ignored = PerThreadCache.create()) {
+      PerThreadCache cache = PerThreadCache.get();
+      PerThreadCache.Key<String> key1 = PerThreadCache.Key.create(String.class);
+
+      String value1 = cache.get(key1, () -> "value1");
+      assertThat(value1).isEqualTo("value1");
+
+      Supplier<String> neverCalled =
+          () -> {
+            throw new IllegalStateException("this method must not be called");
+          };
+      assertThat(cache.get(key1, neverCalled)).isEqualTo("value1");
+    }
+  }
+
+  @Test
+  public void cleanUp() {
+    PerThreadCache.Key<String> key = PerThreadCache.Key.create(String.class);
+    try (PerThreadCache ignored = PerThreadCache.create()) {
+      PerThreadCache cache = PerThreadCache.get();
+      String value1 = cache.get(key, () -> "value1");
+      assertThat(value1).isEqualTo("value1");
+    }
+
+    // Create a second cache and assert that it is not connected to the first one.
+    // This ensures that the cleanup is actually working.
+    try (PerThreadCache ignored = PerThreadCache.create()) {
+      PerThreadCache cache = PerThreadCache.get();
+      String value1 = cache.get(key, () -> "value2");
+      assertThat(value1).isEqualTo("value2");
+    }
+  }
+
+  @Test
+  public void doubleInstantiationFails() {
+    try (PerThreadCache ignored = PerThreadCache.create()) {
+      IllegalStateException thrown =
+          assertThrows(IllegalStateException.class, () -> PerThreadCache.create());
+      assertThat(thrown).hasMessageThat().contains("called create() twice on the same request");
+    }
+  }
+
+  @Test
+  public void enforceMaxSize() {
+    try (PerThreadCache cache = PerThreadCache.create()) {
+      // Fill the cache
+      for (int i = 0; i < 50; i++) {
+        PerThreadCache.Key<String> key = PerThreadCache.Key.create(String.class, i);
+        cache.get(key, () -> "cached value");
+      }
+      // Assert that the value was not persisted
+      PerThreadCache.Key<String> key = PerThreadCache.Key.create(String.class, 1000);
+      cache.get(key, () -> "new value");
+      String value = cache.get(key, () -> "directly served");
+      assertThat(value).isEqualTo("directly served");
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/BUILD b/javatests/com/google/gerrit/server/cache/serialize/BUILD
index fa6a717..6976d19 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/BUILD
+++ b/javatests/com/google/gerrit/server/cache/serialize/BUILD
@@ -5,6 +5,7 @@
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/cache/serialize",
         "//java/com/google/gerrit/server/cache/testing",
diff --git a/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
new file mode 100644
index 0000000..84f290c
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
@@ -0,0 +1,41 @@
+package com.google.gerrit.server.cache.serialize;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.comment.CommentContextCacheImpl.CommentContextSerializer.INSTANCE;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.CommentContext;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.comment.CommentContextKey;
+import org.junit.Test;
+
+public class CommentContextSerializerTest {
+  @Test
+  public void roundTripValue() {
+    CommentContext commentContext =
+        CommentContext.create(ImmutableMap.of(1, "line_1", 2, "line_2"));
+
+    byte[] serialized = INSTANCE.serialize(commentContext);
+    CommentContext deserialized = INSTANCE.deserialize(serialized);
+
+    assertThat(commentContext).isEqualTo(deserialized);
+  }
+
+  @Test
+  public void roundTripKey() {
+    Project.NameKey proj = Project.NameKey.parse("project");
+    Change.Id changeId = Change.Id.tryParse("1234").get();
+
+    CommentContextKey k =
+        CommentContextKey.builder()
+            .project(proj)
+            .changeId(changeId)
+            .id("commentId")
+            .path("pathHash")
+            .patchset(1)
+            .build();
+    byte[] serialized = CommentContextKey.Serializer.INSTANCE.serialize(k);
+    assertThat(k).isEqualTo(CommentContextKey.Serializer.INSTANCE.deserialize(serialized));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD b/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
index 7fe73d5..b84febb 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
@@ -7,6 +7,7 @@
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/cache/serialize",
         "//java/com/google/gerrit/server/cache/serialize/entities",
         "//java/com/google/gerrit/server/cache/testing",
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/GitModifiedFilesCacheKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/GitModifiedFilesCacheKeySerializerTest.java
new file mode 100644
index 0000000..caf1fbb
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/GitModifiedFilesCacheKeySerializerTest.java
@@ -0,0 +1,43 @@
+//  Copyright (C) 2020 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.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheKey;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheKey.Serializer;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class GitModifiedFilesCacheKeySerializerTest {
+  private static final ObjectId TREE_ID_1 =
+      ObjectId.fromString("123e9fa8a286255ac7d5ba11b598892735758391");
+  private static final ObjectId TREE_ID_2 =
+      ObjectId.fromString("d07a03a9818c120301cb5b4a969b035479400b5f");
+
+  @Test
+  public void roundTrip() {
+    GitModifiedFilesCacheKey key =
+        GitModifiedFilesCacheKey.builder()
+            .project(Project.NameKey.parse("Project/X"))
+            .aTree(TREE_ID_1)
+            .bTree(TREE_ID_2)
+            .renameScore(65)
+            .build();
+    byte[] serialized = Serializer.INSTANCE.serialize(key);
+    assertThat(Serializer.INSTANCE.deserialize(serialized)).isEqualTo(key);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesCacheKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesCacheKeySerializerTest.java
new file mode 100644
index 0000000..b39ba57
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesCacheKeySerializerTest.java
@@ -0,0 +1,42 @@
+//  Copyright (C) 2020 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.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.patch.diff.ModifiedFilesCacheKey;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class ModifiedFilesCacheKeySerializerTest {
+  private static final ObjectId COMMIT_ID_1 =
+      ObjectId.fromString("123e9fa8a286255ac7d5ba11b598892735758391");
+  private static final ObjectId COMMIT_ID_2 =
+      ObjectId.fromString("d07a03a9818c120301cb5b4a969b035479400b5f");
+
+  @Test
+  public void roundTrip() {
+    ModifiedFilesCacheKey key =
+        ModifiedFilesCacheKey.builder()
+            .project(Project.NameKey.parse("Project/X"))
+            .aCommit(COMMIT_ID_1)
+            .bCommit(COMMIT_ID_2)
+            .renameScore(65)
+            .build();
+    byte[] serialized = ModifiedFilesCacheKey.Serializer.INSTANCE.serialize(key);
+    assertThat(ModifiedFilesCacheKey.Serializer.INSTANCE.deserialize(serialized)).isEqualTo(key);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesSerializerTest.java
new file mode 100644
index 0000000..bff0c5d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesSerializerTest.java
@@ -0,0 +1,56 @@
+//  Copyright (C) 2020 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.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheImpl.ValueSerializer;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+import java.util.Optional;
+import org.junit.Test;
+
+public class ModifiedFilesSerializerTest {
+  @Test
+  public void roundTrip() {
+    ImmutableList.Builder<ModifiedFile> builder = ImmutableList.builder();
+
+    builder.add(
+        ModifiedFile.builder()
+            .changeType(ChangeType.DELETED)
+            .oldPath(Optional.of("file_1.txt"))
+            .newPath(Optional.of("file_2.txt"))
+            .build());
+    builder.add(
+        ModifiedFile.builder()
+            .changeType(ChangeType.ADDED)
+            .oldPath(Optional.empty())
+            .newPath(Optional.of("file_3.txt"))
+            .build());
+
+    // Note: the default value for strings in protocol buffers is the empty string, hence the
+    // serializer will not be able to differentiate between an empty optional and an optional
+    // with an empty string, i.e. if we serialize an optional with an empty string, the deserialized
+    // object will be an empty optional. That should not be problematic in this case because file
+    // paths cannot be empty anyway.
+
+    ImmutableList<ModifiedFile> modifiedFiles = builder.build();
+
+    byte[] serialized = ValueSerializer.INSTANCE.serialize(modifiedFiles);
+
+    assertThat(ValueSerializer.INSTANCE.deserialize(serialized)).isEqualTo(modifiedFiles);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
index 1cdca1b..de23ef4 100644
--- a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
+++ b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
@@ -101,6 +101,11 @@
         }
 
         @Override
+        public Object getCacheKey() {
+          return new Object();
+        }
+
+        @Override
         public boolean isIdentifiedUser() {
           return true;
         }
diff --git a/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java b/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
index 698acd8..7eb6bc7 100644
--- a/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
+++ b/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
@@ -24,6 +24,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.RefNames;
 import java.util.Map;
 import org.eclipse.jgit.lib.ObjectId;
@@ -60,17 +61,18 @@
   }
 
   @Test
-  public void noCache_tipsFromObjectIdDelegatesToRefDbAndFiltersByPrefix() throws Exception {
+  public void noCache_tipsFromObjectIdDelegatesToRefDb() throws Exception {
     Ref refBla = newRef("refs/bla", "badc0feebadc0feebadc0feebadc0feebadc0fee");
-    Ref refheads = newRef(RefNames.REFS_HEADS, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    String patchSetRef = RefNames.REFS_CHANGES + "01/1/1";
+    Ref patchSet = newRef(patchSetRef, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
 
     RefDatabase mockRefDb = mock(RefDatabase.class);
     ReceivePackRefCache cache = ReceivePackRefCache.noCache(mockRefDb);
     when(mockRefDb.getTipsWithSha1(ObjectId.zeroId()))
-        .thenReturn(ImmutableSet.of(refBla, refheads));
+        .thenReturn(ImmutableSet.of(refBla, patchSet));
 
-    assertThat(cache.tipsFromObjectId(ObjectId.zeroId(), RefNames.REFS_HEADS))
-        .containsExactly(refheads);
+    assertThat(cache.patchSetIdsFromObjectId(ObjectId.zeroId()))
+        .containsExactly(PatchSet.Id.fromRef(patchSetRef));
     verify(mockRefDb).getTipsWithSha1(ObjectId.zeroId());
     verifyNoMoreInteractions(mockRefDb);
   }
@@ -107,25 +109,14 @@
   }
 
   @Test
-  public void advertisedRefs_tipsFromObjectIdWithNoPrefix() throws Exception {
+  public void advertisedRefs_patchSetIdsFromObjectId() throws Exception {
     Map<String, Ref> refs = setupTwoChanges();
     ReceivePackRefCache cache = ReceivePackRefCache.withAdvertisedRefs(() -> refs);
 
     assertThat(
-            cache.tipsFromObjectId(
-                ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee"), null))
-        .containsExactly(refs.get("refs/changes/01/1/1"));
-  }
-
-  @Test
-  public void advertisedRefs_tipsFromObjectIdWithPrefix() throws Exception {
-    Map<String, Ref> refs = setupTwoChanges();
-    ReceivePackRefCache cache = ReceivePackRefCache.withAdvertisedRefs(() -> refs);
-
-    assertThat(
-            cache.tipsFromObjectId(
-                ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee"), "/refs/some"))
-        .isEmpty();
+            cache.patchSetIdsFromObjectId(
+                ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee")))
+        .containsExactly(PatchSet.Id.fromRef("refs/changes/01/1/1"));
   }
 
   private static Ref newRef(String name, String sha1) {
diff --git a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
index 733d784..8d019f3 100644
--- a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
+++ b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
@@ -24,8 +24,8 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import org.eclipse.jgit.lib.Config;
@@ -76,7 +76,7 @@
       // Create a performance log record.
       TraceContext.newTimer("test").close();
 
-      SortedMap<String, SortedSet<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
+      Map<String, ? extends Set<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
       assertThat(tagMap.keySet()).containsExactly("foo");
       assertThat(tagMap.get("foo")).containsExactly("bar");
       assertForceLogging(true);
@@ -90,7 +90,7 @@
               () -> {
                 // Verify that the tags and force logging flag have been propagated to the new
                 // thread.
-                SortedMap<String, SortedSet<Object>> threadTagMap =
+                Map<String, ? extends Set<Object>> threadTagMap =
                     LoggingContext.getInstance().getTags().asMap();
                 expect.that(threadTagMap.keySet()).containsExactly("foo");
                 expect.that(threadTagMap.get("foo")).containsExactly("bar");
diff --git a/javatests/com/google/gerrit/server/logging/MutableTagsTest.java b/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
index f6f3b46..200c49d 100644
--- a/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
+++ b/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
@@ -21,8 +21,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import java.util.Map;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Set;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -157,7 +156,7 @@
   }
 
   private void assertTags(ImmutableMap<String, ImmutableSet<String>> expectedTagMap) {
-    SortedMap<String, SortedSet<Object>> actualTagMap = tags.getTags().asMap();
+    Map<String, ? extends Set<Object>> actualTagMap = tags.getTags().asMap();
     assertThat(actualTagMap.keySet()).containsExactlyElementsIn(expectedTagMap.keySet());
     for (Map.Entry<String, ImmutableSet<String>> expectedEntry : expectedTagMap.entrySet()) {
       assertThat(actualTagMap.get(expectedEntry.getKey()))
diff --git a/javatests/com/google/gerrit/server/logging/TraceContextTest.java b/javatests/com/google/gerrit/server/logging/TraceContextTest.java
index 13f2035..6a3632d 100644
--- a/javatests/com/google/gerrit/server/logging/TraceContextTest.java
+++ b/javatests/com/google/gerrit/server/logging/TraceContextTest.java
@@ -21,8 +21,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.server.logging.TraceContext.TraceIdConsumer;
 import java.util.Map;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Set;
 import org.junit.After;
 import org.junit.Test;
 
@@ -254,7 +253,7 @@
   }
 
   private void assertTags(ImmutableMap<String, ImmutableSet<String>> expectedTagMap) {
-    SortedMap<String, SortedSet<Object>> actualTagMap =
+    Map<String, ? extends Set<Object>> actualTagMap =
         LoggingContext.getInstance().getTags().asMap();
     assertThat(actualTagMap.keySet()).containsExactlyElementsIn(expectedTagMap.keySet());
     for (Map.Entry<String, ImmutableSet<String>> expectedEntry : expectedTagMap.entrySet()) {
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index dd3238f..321e4da 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -634,6 +634,39 @@
   }
 
   @Test
+  public void serializeAllAttentionSetUpdates() throws Exception {
+    assertRoundTrip(
+        newBuilder()
+            .allAttentionSetUpdates(
+                ImmutableList.of(
+                    AttentionSetUpdate.createFromRead(
+                        Instant.EPOCH.plusSeconds(23),
+                        Account.id(1000),
+                        AttentionSetUpdate.Operation.ADD,
+                        "reason 1"),
+                    AttentionSetUpdate.createFromRead(
+                        Instant.EPOCH.plusSeconds(42),
+                        Account.id(2000),
+                        AttentionSetUpdate.Operation.REMOVE,
+                        "reason 2")))
+            .build(),
+        newProtoBuilder()
+            .addAllAttentionSetUpdate(
+                AttentionSetUpdateProto.newBuilder()
+                    .setTimestampMillis(23_000) // epoch millis
+                    .setAccount(1000)
+                    .setOperation("ADD")
+                    .setReason("reason 1"))
+            .addAllAttentionSetUpdate(
+                AttentionSetUpdateProto.newBuilder()
+                    .setTimestampMillis(42_000) // epoch millis
+                    .setAccount(2000)
+                    .setOperation("REMOVE")
+                    .setReason("reason 2"))
+            .build());
+  }
+
+  @Test
   public void serializeAssigneeUpdates() throws Exception {
     assertRoundTrip(
         newBuilder()
@@ -793,6 +826,9 @@
                     "attentionSet",
                     new TypeLiteral<ImmutableSet<AttentionSetUpdate>>() {}.getType())
                 .put(
+                    "allAttentionSetUpdates",
+                    new TypeLiteral<ImmutableList<AttentionSetUpdate>>() {}.getType())
+                .put(
                     "assigneeUpdates",
                     new TypeLiteral<ImmutableList<AssigneeStatusUpdate>>() {}.getType())
                 .put("submitRecords", new TypeLiteral<ImmutableList<SubmitRecord>>() {}.getType())
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 938fffc..cc0b109 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -699,6 +699,13 @@
   }
 
   @Test
+  public void defaultAttentionSetUpdatesIsEmpty() throws Exception {
+    Change c = newChange();
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getAttentionSetUpdates()).isEmpty();
+  }
+
+  @Test
   public void addAttentionStatus() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -712,6 +719,19 @@
   }
 
   @Test
+  public void addAllAttentionUpdates() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    AttentionSetUpdate attentionSetUpdate =
+        AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getAttentionSetUpdates()).containsExactly(addTimestamp(attentionSetUpdate, c));
+  }
+
+  @Test
   public void filterLatestAttentionStatus() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -730,6 +750,28 @@
   }
 
   @Test
+  public void DoesNotFilterLatestAttentionSetUpdates() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    AttentionSetUpdate firstAttentionSetUpdate =
+        AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(firstAttentionSetUpdate));
+    update.commit();
+    update = newUpdate(c, changeOwner);
+    firstAttentionSetUpdate = addTimestamp(firstAttentionSetUpdate, c);
+
+    AttentionSetUpdate secondAttentionSetUpdate =
+        AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.REMOVE, "test");
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(secondAttentionSetUpdate));
+    update.commit();
+    secondAttentionSetUpdate = addTimestamp(secondAttentionSetUpdate, c);
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getAttentionSetUpdates())
+        .containsExactly(secondAttentionSetUpdate, firstAttentionSetUpdate);
+  }
+
+  @Test
   public void addAttentionStatus_rejectTimestamp() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -767,6 +809,8 @@
   public void addAttentionStatusForMultipleUsers() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
+    // put the user as cc to ensure that the user took part in this change.
+    update.putReviewer(otherUser.getAccount().id(), CC);
     AttentionSetUpdate attentionSetUpdate0 =
         AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
     AttentionSetUpdate attentionSetUpdate1 =
diff --git a/javatests/com/google/gerrit/server/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index 64f9392..65196bf 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -48,6 +48,7 @@
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.ListGroupMembership;
 import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.index.SingleVersionModule.SingleVersionListener;
 import com.google.gerrit.server.project.ProjectCache;
@@ -63,6 +64,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.Optional;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
@@ -90,6 +92,18 @@
     assertWithMessage("not owner").that(u.isOwner()).isFalse();
   }
 
+  private void assertAllRefsAreVisible(ProjectControl u) {
+    assertWithMessage("all refs visible")
+        .that(u.allRefsAreVisible(Collections.emptySet()))
+        .isTrue();
+  }
+
+  private void assertAllRefsAreNotVisible(ProjectControl u) {
+    assertWithMessage("all refs NOT visible")
+        .that(u.allRefsAreVisible(Collections.emptySet()))
+        .isFalse();
+  }
+
   private void assertNotOwner(String ref, ProjectControl u) {
     assertWithMessage("NOT OWN " + ref).that(u.controlForRef(ref).isOwner()).isFalse();
   }
@@ -105,11 +119,21 @@
   }
 
   private void assertCanRead(String ref, ProjectControl u) {
-    assertWithMessage("can read " + ref).that(u.controlForRef(ref).isVisible()).isTrue();
+    assertWithMessage("can read " + ref)
+        .that(
+            u.controlForRef(ref)
+                .hasReadPermissionOnRef(
+                    true)) // This should be false but the test relies on inheritance into refs/tags
+        .isTrue();
   }
 
   private void assertCannotRead(String ref, ProjectControl u) {
-    assertWithMessage("cannot read " + ref).that(u.controlForRef(ref).isVisible()).isFalse();
+    assertWithMessage("cannot read " + ref)
+        .that(
+            u.controlForRef(ref)
+                .hasReadPermissionOnRef(
+                    true)) // This should be false but the test relies on inheritance into refs/tags
+        .isFalse();
   }
 
   private void assertCanSubmit(String ref, ProjectControl u) {
@@ -171,6 +195,7 @@
   private final Project.NameKey parentKey = Project.nameKey("parent");
 
   @Inject private AllProjectsName allProjectsName;
+  @Inject private AllUsersName allUsersName;
   @Inject private InMemoryRepositoryManager repoManager;
   @Inject private MetaDataUpdate.Server metaDataUpdateFactory;
   @Inject private ProjectCache projectCache;
@@ -262,6 +287,32 @@
   }
 
   @Test
+  public void allRefsAreVisibleForRegularProject() throws Exception {
+    projectOperations
+        .project(localKey)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(DEVS))
+        .add(allow(READ).ref("refs/groups/*").group(DEVS))
+        .add(allow(READ).ref("refs/users/default").group(DEVS))
+        .update();
+
+    assertAllRefsAreVisible(user(localKey, DEVS));
+  }
+
+  @Test
+  public void allRefsAreNotVisibleForAllUsers() throws Exception {
+    projectOperations
+        .project(allUsersName)
+        .forUpdate()
+        .add(allow(READ).ref("refs/*").group(DEVS))
+        .add(allow(READ).ref("refs/groups/*").group(DEVS))
+        .add(allow(READ).ref("refs/users/default").group(DEVS))
+        .update();
+
+    assertAllRefsAreNotVisible(user(allUsersName, DEVS));
+  }
+
+  @Test
   public void branchDelegation1() throws Exception {
     projectOperations
         .project(localKey)
@@ -1198,6 +1249,11 @@
     }
 
     @Override
+    public Object getCacheKey() {
+      return new Object();
+    }
+
+    @Override
     public Optional<String> getUserName() {
       return Optional.ofNullable(username);
     }
diff --git a/javatests/com/google/gerrit/server/project/GroupListTest.java b/javatests/com/google/gerrit/server/project/GroupListTest.java
index 7d4b7ca..853507d 100644
--- a/javatests/com/google/gerrit/server/project/GroupListTest.java
+++ b/javatests/com/google/gerrit/server/project/GroupListTest.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.git.ValidationError;
 import java.io.IOException;
 import java.util.Collection;
@@ -57,7 +58,7 @@
     GroupReference groupReference = groupList.byUUID(uuid);
 
     assertEquals(uuid, groupReference.getUUID());
-    assertEquals("Service Users", groupReference.getName());
+    assertEquals(ServiceUserClassifier.SERVICE_USERS, groupReference.getName());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 02f514a..1de548f 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -3055,6 +3055,14 @@
     gApi.changes().id(change.getChangeId()).addToAttentionSet(input);
     Account.Id user2Id =
         accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+
+    // Add the second user as cc to ensure that user took part of the change and can be added to the
+    // attention set.
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.reviewer = user2Id.toString();
+    addReviewerInput.state = ReviewerState.CC;
+    gApi.changes().id(change.getChangeId()).addReviewer(addReviewerInput);
+
     input = new AttentionSetInput(user2Id.toString(), "reason 2");
     gApi.changes().id(change.getChangeId()).addToAttentionSet(input);
 
@@ -3085,6 +3093,7 @@
     assertQuery("-assignee:" + user.getUserName().get(), change2);
   }
 
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
   @Test
   public void userDestination() throws Exception {
     TestRepository<Repo> repo1 = createProject("repo1");
@@ -3096,6 +3105,8 @@
         .hasMessageThat()
         .isEqualTo("Unknown named destination: foo");
 
+    Account.Id anotherUserId =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
     String destination1 = "refs/heads/master\trepo1";
     String destination2 = "refs/heads/master\trepo2";
     String destination3 = "refs/heads/master\trepo1\nrefs/heads/master\trepo2";
@@ -3111,8 +3122,32 @@
       allUsers.branch(refsUsers).commit().add("destinations/destination4", destination4).create();
       allUsers.branch(refsUsers).commit().add("destinations/destination5", destination5).create();
 
+      String anotherRefsUsers = RefNames.refsUsers(anotherUserId);
+      allUsers
+          .branch(anotherRefsUsers)
+          .commit()
+          .add("destinations/destination6", destination1)
+          .create();
+      allUsers
+          .branch(anotherRefsUsers)
+          .commit()
+          .add("destinations/destination7", destination2)
+          .create();
+      allUsers
+          .branch(anotherRefsUsers)
+          .commit()
+          .add("destinations/destination8", destination3)
+          .create();
+      allUsers
+          .branch(anotherRefsUsers)
+          .commit()
+          .add("destinations/destination9", destination4)
+          .create();
+
       Ref userRef = allUsers.getRepository().exactRef(refsUsers);
+      Ref anotherUserRef = allUsers.getRepository().exactRef(anotherRefsUsers);
       assertThat(userRef).isNotNull();
+      assertThat(anotherUserRef).isNotNull();
     }
 
     assertQuery("destination:destination1", change1);
@@ -3120,38 +3155,87 @@
     assertQuery("destination:destination3", change2, change1);
     assertQuery("destination:destination4");
     assertQuery("destination:destination5");
+    assertQuery("destination:destination6,user=" + anotherUserId, change1);
+    assertQuery("destination:name=destination6,user=" + anotherUserId, change1);
+    assertQuery("destination:user=" + anotherUserId + ",destination7", change2);
+    assertQuery("destination:user=" + anotherUserId + ",name=destination8", change2, change1);
+    assertQuery("destination:destination9,user=" + anotherUserId);
+
+    assertThatQueryException("destination:destination3,user=" + anotherUserId)
+        .hasMessageThat()
+        .isEqualTo("Unknown named destination: destination3");
+    assertThatQueryException("destination:destination3,user=test")
+        .hasMessageThat()
+        .isEqualTo("Account 'test' not found");
+
+    requestContext.setContext(newRequestContext(anotherUserId));
+    // account 1000000 is not visible to 'anotheruser' as they are not an admin
+    assertThatQueryException("destination:destination3,user=" + userId)
+        .hasMessageThat()
+        .isEqualTo("Account '1000000' not found");
   }
 
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
   @Test
   public void userQuery() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChangeForBranch(repo, "stable"));
 
+    Account.Id anotherUserId =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
     String queryListText =
         "query1\tproject:repo\n"
             + "query2\tproject:repo status:open\n"
             + "query3\tproject:repo branch:stable\n"
             + "query4\tproject:repo branch:other";
+    String anotherQueryListText =
+        "query5\tproject:repo\n"
+            + "query6\tproject:repo status:merged\n"
+            + "query7\tproject:repo branch:stable\n"
+            + "query8\tproject:repo branch:other";
 
     try (TestRepository<Repo> allUsers =
             new TestRepository<>(repoManager.openRepository(allUsersName));
-        MetaDataUpdate md = metaDataUpdateFactory.create(allUsersName)) {
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsersName);
+        MetaDataUpdate anotherMd = metaDataUpdateFactory.create(allUsersName)) {
       VersionedAccountQueries queries = VersionedAccountQueries.forUser(userId);
       queries.load(md);
       queries.setQueryList(queryListText);
       queries.commit(md);
+      VersionedAccountQueries anotherQueries = VersionedAccountQueries.forUser(anotherUserId);
+      anotherQueries.load(anotherMd);
+      anotherQueries.setQueryList(anotherQueryListText);
+      anotherQueries.commit(anotherMd);
     }
 
     assertThatQueryException("query:foo").hasMessageThat().isEqualTo("Unknown named query: foo");
+    assertThatQueryException("query:query1,user=" + anotherUserId)
+        .hasMessageThat()
+        .isEqualTo("Unknown named query: query1");
+    assertThatQueryException("query:query1,user=test")
+        .hasMessageThat()
+        .isEqualTo("Account 'test' not found");
+
+    requestContext.setContext(newRequestContext(anotherUserId));
+    // account 1000000 is not visible to 'anotheruser' as they are not an admin
+    assertThatQueryException("query:query1,user=" + userId)
+        .hasMessageThat()
+        .isEqualTo("Account '1000000' not found");
+    requestContext.setContext(newRequestContext(userId));
 
     assertQuery("query:query1", change2, change1);
     assertQuery("query:query2", change2, change1);
+    assertQuery("query:name=query5,user=" + anotherUserId, change2, change1);
+    assertQuery("query:user=" + anotherUserId + ",name=query6");
     gApi.changes().id(change1.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(change1.getChangeId()).current().submit();
     assertQuery("query:query2", change2);
     assertQuery("query:query3", change2);
     assertQuery("query:query4");
+    assertQuery("query:query6,user=" + anotherUserId, change1);
+    assertQuery("query:user=" + anotherUserId + ",query7", change2);
+    assertQuery("query:query8,user=" + anotherUserId);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
index d6c5b5a..e6a6497 100644
--- a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
+++ b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.GroupUuid;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.Sequences;
@@ -88,7 +89,7 @@
     expectedConfig.fromText(getDefaultAllProjectsWithAllDefaultSections());
 
     GroupReference adminsGroup = createGroupReference("Administrators");
-    GroupReference batchUsersGroup = createGroupReference("Service Users");
+    GroupReference batchUsersGroup = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
     AllProjectsInput allProjectsInput =
         AllProjectsInput.builder()
             .administratorsGroup(adminsGroup)
diff --git a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
index 01a44f3..fc6b412 100644
--- a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
+++ b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -82,7 +83,7 @@
 
   @Test
   public void groupIsCreatedWhenSchemaIsCreated() throws Exception {
-    assertThat(hasGroup("Service Users")).isTrue();
+    assertThat(hasGroup(ServiceUserClassifier.SERVICE_USERS)).isTrue();
     assertThat(hasGroup("Non-Interactive Users")).isFalse();
   }
 
diff --git a/lib/auto/BUILD b/lib/auto/BUILD
index 1da7f50..18b9b91 100644
--- a/lib/auto/BUILD
+++ b/lib/auto/BUILD
@@ -27,6 +27,21 @@
     ],
 )
 
+java_plugin(
+    name = "auto-value-gson-plugin",
+    processor_class = "com.ryanharter.auto.value.gson.factory.AutoValueGsonAdapterFactoryProcessor",
+    deps = [
+        "@auto-value-annotations//jar",
+        "@auto-value-gson-extension//jar",
+        "@auto-value-gson-factory//jar",
+        "@auto-value-gson-runtime//jar",
+        "@auto-value//jar",
+        "@autotransient//jar",
+        "@gson//jar",
+        "@javapoet//jar",
+    ],
+)
+
 java_library(
     name = "auto-value",
     data = ["//lib:LICENSE-Apache2.0"],
@@ -50,3 +65,17 @@
     visibility = ["//visibility:public"],
     exports = ["@auto-value-annotations//jar"],
 )
+
+java_library(
+    name = "auto-value-gson",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exported_plugins = [
+        ":auto-value-gson-plugin",
+    ],
+    visibility = ["//visibility:public"],
+    exports = [
+        "@auto-value-gson-extension//jar",
+        "@auto-value-gson-factory//jar",
+        "@auto-value-gson-runtime//jar",
+    ],
+)
diff --git a/lib/jackson/BUILD b/lib/jackson/BUILD
index d5253a0..f11b96d 100644
--- a/lib/jackson/BUILD
+++ b/lib/jackson/BUILD
@@ -1,6 +1,14 @@
 load("@rules_java//java:defs.bzl", "java_library")
 
 java_library(
+    name = "jackson-annotations",
+    testonly = True,
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@jackson-annotations//jar"],
+)
+
+java_library(
     name = "jackson-core",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = [
diff --git a/lib/nongoogle_test.sh b/lib/nongoogle_test.sh
index 0cdad1a..8369024 100755
--- a/lib/nongoogle_test.sh
+++ b/lib/nongoogle_test.sh
@@ -12,6 +12,8 @@
 
 cat << EOF > $TMP/want
 cglib-3_2
+docker-java-api
+docker-java-transport
 dropwizard-core
 duct-tape
 eddsa
@@ -22,6 +24,7 @@
 httpasyncclient
 httpcore-nio
 j2objc
+jackson-annotations
 jackson-core
 jna
 jruby
diff --git a/lib/testcontainers/BUILD b/lib/testcontainers/BUILD
index a37b733..693a386 100644
--- a/lib/testcontainers/BUILD
+++ b/lib/testcontainers/BUILD
@@ -1,6 +1,22 @@
 load("@rules_java//java:defs.bzl", "java_library")
 
 java_library(
+    name = "docker-java-api",
+    testonly = True,
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@docker-java-api//jar"],
+)
+
+java_library(
+    name = "docker-java-transport",
+    testonly = True,
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@docker-java-transport//jar"],
+)
+
+java_library(
     name = "duct-tape",
     testonly = True,
     data = ["//lib:LICENSE-testcontainers"],
diff --git a/modules/jgit b/modules/jgit
index 9fe5406..e2663a8 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 9fe54061197c42faedc9417bdc70797681aa06d6
+Subproject commit e2663a8b85cf92f6a84d72834257243a84066e9d
diff --git a/package.json b/package.json
index 70f290b..913b7a8 100644
--- a/package.json
+++ b/package.json
@@ -2,11 +2,12 @@
   "name": "gerrit",
   "version": "3.1.0-SNAPSHOT",
   "description": "Gerrit Code Review",
-  "dependencies": {},
+  "dependencies": {
+    "@bazel/rollup": "^2.2.2",
+    "@bazel/terser": "^2.2.2",
+    "@bazel/typescript": "^2.2.2"
+  },
   "devDependencies": {
-    "@bazel/rollup": "^2.0.0",
-    "@bazel/terser": "^2.0.0",
-    "@bazel/typescript": "^2.0.0",
     "eslint": "^6.6.0",
     "eslint-config-google": "^0.13.0",
     "eslint-plugin-html": "^6.0.0",
@@ -29,8 +30,8 @@
     "eslint": "npm run safe_bazelisk test polygerrit-ui/app:lint_test",
     "eslintfix": "npm run safe_bazelisk run polygerrit-ui/app:lint_bin -- -- --fix $(pwd)/polygerrit-ui/app",
     "polylint": "npm run safe_bazelisk test polygerrit-ui/app:polylint_test",
-    "test:debug": "npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --browsers ChromeDev --no-single-run --testFiles",
-    "test:single": "npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --testFiles"
+    "test:debug": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --browsers ChromeDev --no-single-run --testFiles",
+    "test:single": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --testFiles"
   },
   "repository": {
     "type": "git",
diff --git a/plugins/delete-project b/plugins/delete-project
index 60ce67d..bfe159d 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit 60ce67dd53ad64c33a2c34aae31e9ee823979109
+Subproject commit bfe159d3007db0f07e967473b53f679ba8f432df
diff --git a/plugins/hooks b/plugins/hooks
index 7ed555f..ad4f877 160000
--- a/plugins/hooks
+++ b/plugins/hooks
@@ -1 +1 @@
-Subproject commit 7ed555fe88f4be028acbfd5c245ac78537ac3666
+Subproject commit ad4f877749928b69ef94b62176c5797f6648887d
diff --git a/plugins/plugin-manager b/plugins/plugin-manager
index d6a3381..00e5794 160000
--- a/plugins/plugin-manager
+++ b/plugins/plugin-manager
@@ -1 +1 @@
-Subproject commit d6a33818440eb20aca64a761f79652525b3eb060
+Subproject commit 00e57948f4f112c226028bc5c8d8fe60f770038f
diff --git a/plugins/replication b/plugins/replication
index bc47d23..1b822fa 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit bc47d238733981d957a820f4c66f7128fca80663
+Subproject commit 1b822fa63b04596faa93a13df7fcb8682bccb98b
diff --git a/polygerrit-ui/FE_Style_Guide.md b/polygerrit-ui/FE_Style_Guide.md
index c9a5d9b..a636119 100644
--- a/polygerrit-ui/FE_Style_Guide.md
+++ b/polygerrit-ui/FE_Style_Guide.md
@@ -10,11 +10,20 @@
 Additionally to the rules above, Gerrit frontend uses the following rules (some of them have automated checks,
 some don't):
 
+- [Prefer null over undefined](#prefer-null)
 - [Use destructuring imports only](#destructuring-imports-only)
 - [Use classes and services for storing and manipulating global state](#services-for-global-state)
 - [Pass required services in the constructor for plain classes](#pass-dependencies-in-constructor)
 - [Assign required services in a HTML/Polymer element constructor](#assign-dependencies-in-html-element-constructor)
 
+## <a name="prefer-undefined"></a>Prefer `undefined` over `null`
+
+It is more confusing than helpful to work with both `null` and `undefined`. We prefer to only use `undefined` in
+our code base. Try to avoid `null`.
+
+Some browser and library APIs are using `null`, so we cannot remove `null` completely from our code base. But even
+then try to convert return values and leak as few `nulls` as possible.
+
 ## <a name="destructuring-imports-only"></a>Use destructuring imports only
 Always use destructuring import statement and specify all required names explicitly (e.g. `import {a,b,c} from '...'`)
 where possible.
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index 3e95e42..2266ba0 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -1,12 +1,5 @@
 # Gerrit Polymer Frontend
 
-**Warning**: DON'T ADD MORE TYPESCRIPT FILES/TYPES. Gerrit Polymer Frontend
-contains several typescript files and uses typescript compiler. This is a
-preparation for the upcoming migration to typescript and we actively working on
-it. We want to avoid massive typescript-related changes until the preparation
-work is done. Thanks for your understanding!
-
-
 Follow the
 [setup instructions for Gerrit backend developers](https://gerrit-review.googlesource.com/Documentation/dev-readme.html)
 where applicable, the most important command is:
@@ -279,6 +272,245 @@
 npm run polylint
 ```
 
+## Migrating tests to Typescript
+
+You can use the following steps for migrating tests to Typescript:
+
+1. Rename the `_test.js` file to `_test.ts`
+2. Remove `.js` extensions from all imports:
+   ```
+   // Before:
+   import ... from 'x/y/z.js`
+ 
+   // After
+   import .. from 'x/y/z'
+   ```
+3. Fix typescript and eslint errors.
+
+Common errors and fixes are:
+
+* An object in the test doesn't have all required properties. You can use
+existing helpers to create an object with all required properties:
+```
+// Before:
+sinon.stub(element.$.restAPI, 'getPreferences').returns(
+    Promise.resolve({default_diff_view: 'UNIFIED'}));
+
+// After:
+Promise.resolve({
+  ...createPreferences(),
+  default_diff_view: DiffViewMode.UNIFIED,
+})
+```
+
+Some helpers receive parameters:
+```
+// Before
+element._change = {
+  change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+  revisions: {
+    rev1: {_number: 1, commit: {parents: []}},
+    rev2: {_number: 2, commit: {parents: []}},
+  },
+  current_revision: 'rev1',
+  status: ChangeStatus.MERGED,
+  labels: {},
+  actions: {},
+};
+
+// After
+element._change = {
+  ...createChange(),
+  // The change_id is set by createChange.
+  // The exact change_id is not important in the test, so it was removed.
+  revisions: {
+    rev1: createRevision(1), // _number is a parameter here
+    rev2: createRevision(2), // _number is a parameter here
+  },
+  current_revision: 'rev1' as CommitId,
+  status: ChangeStatus.MERGED,
+  labels: {},
+  actions: {},
+};
+```
+* Typescript reports some weird messages about `window` property - sometimes an
+IDE adds wrong import. Just remove it.
+```
+// The wrong import added by IDE, must be removed
+import window = Mocha.reporters.Base.window;
+```
+
+* `TS2531: Object is possibly 'null'`. To fix use either non-null assertion
+operator `!` or nullish coalescing operator `?.`:
+```
+// Before:
+const rows = element
+  .shadowRoot.querySelector('table')
+  .querySelectorAll('tbody tr');
+...
+// The _robotCommentThreads declared as _robotCommentThreads?: CommentThread
+assert.equal(element._robotCommentThreads.length, 2);
+  
+// Fix with non-null assertion operator:
+const rows = element
+  .shadowRoot!.querySelector('table')! // '!' after shadowRoot and querySelector
+  .querySelectorAll('tbody tr');
+
+assert.equal(element._robotCommentThreads!.length, 2); 
+
+// Fix with nullish coalescing operator:
+ assert.equal(element._robotCommentThreads?.length, 2); 
+```
+Usually the fix with `!` is preferable, because it gives more clear error
+when an intermediate property is `null/undefined`. If the _robotComments is
+`undefined` in the example above, the `element._robotCommentThreads!.length`
+crashes with the error `Cannot read property 'length' of undefined`. At the
+same time the fix with
+`?.` doesn't distinct between 2 cases: _robotCommentThreads is `undefined`
+and `length` is `undefined`.
+
+* `TS2339: Property '...' does not exist on type 'Element'.` for elements
+returned by `querySelector/querySelectorAll`. To fix it, use generic versions
+of those methods:
+```
+// Before:
+const radios = parentTable
+  .querySelectorAll('input[type=radio]');
+const radio = parentRow
+  .querySelector('input[type=radio]');
+
+// After:
+const radios = parentTable
+  .querySelectorAll<HTMLInputElement>('input[type=radio]');
+const radio = parentRow
+  .querySelector<HTMLInputElement>('input[type=radio]');
+```
+
+* Sinon: `TS2339: Property 'lastCall' does not exist on type '...` (the same
+for other sinon properties). Store stub/spy in a variable and then use the
+variable:
+```
+// Before:
+sinon.stub(GerritNav, 'getUrlForChange')
+...
+assert.equal(GerritNav.getUrlForChange.lastCall.args[4], '#message-a12345');
+
+// After:
+const getUrlStub = sinon.stub(GerritNav, 'getUrlForChange');
+...
+assert.equal(getUrlStub.lastCall.args[4], '#message-a12345');
+```
+
+If you need to define a type for such variable, you can use one of the following
+options:
+```
+suite('my suite', () => {
+    // Non static members, option 1
+    let updateHeightSpy: SinonSpyMember<typeof element._updateRelatedChangeMaxHeight>;
+    // Non static members, option 2
+    let updateHeightSpy_prototype: SinonSpyMember<typeof GrChangeView.prototype._updateRelatedChangeMaxHeight>;
+    // Static members
+    let navigateToChangeStub: SinonStubbedMember<typeof GerritNav.navigateToChange>;
+    // For interfaces
+    let getMergeableStub: SinonStubbedMember<RestApiService['getMergeable']>;
+});
+```
+
+* Typescript reports errors when stubbing/faking methods:
+```
+// The JS code:
+const reloadStub = sinon
+    .stub(element, '_reload')
+    .callsFake(() => Promise.resolve());
+
+stub('gr-rest-api-interface', {
+  getDiffComments() { return Promise.resolve({}); },
+  getDiffRobotComments() { return Promise.resolve({}); },
+  getDiffDrafts() { return Promise.resolve({}); },
+  _fetchSharedCacheURL() { return Promise.resolve({}); },
+});
+```
+
+In such cases, validate the input and output of a stub/fake method. Quite often
+tests return null instead of undefined or `[]` instead of `{}`, etc...
+Fix types if they are not correct:
+```
+const reloadStub = sinon
+  .stub(element, '_reload')
+  // GrChangeView._reload method returns an array
+  .callsFake(() => Promise.resolve([])); // return [] here
+
+stub('gr-rest-api-interface', {
+  ...
+  // Fix return type:
+  _fetchSharedCacheURL() { return Promise.resolve({} as ParsedJSON); },
+});
+```
+
+If a method has multiple overloads, you can use one of 2 options:
+```
+// Option 1: less accurate, but shorter:
+function getCommentsStub() {
+  return Promise.resolve({});
+}
+
+stub('gr-rest-api-interface', {
+  ...
+  getDiffComments: (getCommentsStub as unknown) as RestApiService['getDiffComments'],
+  getDiffRobotComments: (getCommentsStub as unknown) as RestApiService['getDiffRobotComments'],
+  getDiffDrafts: (getCommentsStub as unknown) as RestApiService['getDiffDrafts'],
+  ...
+});
+
+// Option 2: more accurate, but longer.
+// Step 1: define the same overloads for stub:
+function getDiffCommentsStub(
+  changeNum: NumericChangeId
+): Promise<PathToCommentsInfoMap | undefined>;
+function getDiffCommentsStub(
+  changeNum: NumericChangeId,
+  basePatchNum: PatchSetNum,
+  patchNum: PatchSetNum,
+  path: string
+): Promise<GetDiffCommentsOutput>;
+
+// Step 2: implement stub method for differnt input
+function getDiffCommentsStub(
+  _: NumericChangeId,
+  basePatchNum?: PatchSetNum,
+):
+  | Promise<PathToCommentsInfoMap | undefined>
+  | Promise<GetDiffCommentsOutput> {
+  if (basePatchNum) {
+    return Promise.resolve({
+      baseComments: [],
+      comments: [],
+    });
+  }
+  return Promise.resolve({});
+}
+
+// Step 3: use stubbed function:
+stub('gr-rest-api-interface', {
+  ...
+  getDiffComments: getDiffCommentsStub,
+  ...
+});
+```
+
+* If a test requires a `@types/...` library, install the required library
+in the `polygerrit_ui/node_modules` and update the `typeRoots` in the
+`polygerrit-ui/app/tsconfig_bazel_test.json` file.
+
+The same update should be done if a test requires a .d.ts file from a library
+that already exists in `polygerrit_ui/node_modules`.
+
+**Note:** Types from a library located in `polygerrit_ui/app/node_modules` are
+handle automatically.
+
+* If a test imports a library from `polygerrit_ui/node_modules` - update
+`paths` in `polygerrit-ui/app/tsconfig_bazel_test.json`.
+ 
 ## Contributing
 
 Our users report bugs / feature requests related to the UI through [Monorail Issues - PolyGerrit](https://bugs.chromium.org/p/gerrit/issues/list?q=component%3APolyGerrit).
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index 226cc4c..9834ddc 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -226,9 +226,6 @@
     "import/no-unused-modules": 2,
     // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-default-export.md
     "import/no-default-export": 2,
-    // Custom rule from the //tools/js/eslint-rules directory.
-    // See //tools/js/eslint-rules/README.md for details
-    "goog-module-id": 2,
     // Prevents certain identifiers being used.
     // Prefer flush() over flushAsynchronousOperations().
     "id-blacklist": ["error", "flushAsynchronousOperations"],
@@ -277,6 +274,17 @@
         "@typescript-eslint/restrict-plus-operands": "error",
         // https://github.com/mysticatea/eslint-plugin-node/blob/master/docs/rules/no-unsupported-features/node-builtins.md
         "node/no-unsupported-features/node-builtins": "off",
+        // Disable no-invalid-this for ts files, because it incorrectly reports
+        // errors in some cases (see https://github.com/typescript-eslint/typescript-eslint/issues/491)
+        // At the same time, we are using typescript in a strict mode and
+        // it catches almost all errors related to invalid usage of this.
+        "no-invalid-this": "off",
+
+        "node/no-extraneous-import": "off",
+
+        // Typescript already checks for undef
+        "no-undef": "off",
+
         "jsdoc/no-types": 2,
       },
       "parserOptions": {
@@ -284,22 +292,6 @@
       }
     },
     {
-      "files": ["**/*.ts"],
-      "excludedFiles": "*.d.ts",
-      "rules": {
-        // Custom rule from the //tools/js/eslint-rules directory.
-        // See //tools/js/eslint-rules/README.md for details
-        "ts-imports-js": 2,
-      }
-    },
-    {
-      "files": ["**/*.d.ts"],
-      "rules": {
-        // See details in the //tools/js/eslint-rules/report-ts-error.js file.
-        "report-ts-error": "error",
-      }
-    },
-    {
       "files": ["*.html", "test.js", "test-infra.js"],
       "rules": {
         "jsdoc/require-file-overview": "off"
@@ -308,8 +300,6 @@
     {
       "files": [
         "*.html",
-        "common-test-setup.js",
-        "common-test-setup-karma.js",
         "*_test.js",
         "a11y-test-utils.js",
       ],
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 41c3f17..c29663c 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -3,7 +3,8 @@
 
 package(default_visibility = ["//visibility:public"])
 
-# This list must be in sync with the "include" list in the tsconfig.json file
+# This list must be in sync with the "include" list in the follwoing files:
+# tsconfig.json, tsconfig_bazel.json, tsconfig_bazel_test.json
 src_dirs = [
     "constants",
     "elements",
@@ -27,6 +28,7 @@
         ]],
         exclude = [
             "**/*_test.js",
+            "**/*_test.ts",
         ],
     ),
     # The same outdir also appears in the following files:
@@ -40,6 +42,7 @@
         [
             "**/*.js",
             "**/*.ts",
+            "test/@types/*.d.ts",
         ],
         exclude = [
             "node_modules/**",
@@ -48,6 +51,7 @@
             "rollup.config.js",
         ],
     ),
+    include_tests = True,
     # The same outdir also appears in the following files:
     # wct_test.sh
     # karma.conf.js
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 2db076f..061c5cd 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -21,10 +21,11 @@
 export enum PrimaryTab {
   FILES = 'files',
   /**
-   * When renaming this, the links in UrlFormatter must be updated.
+   * When renaming 'comments' or 'findings', UrlFormatter.java must be updated.
    */
   COMMENT_THREADS = 'comments',
   FINDINGS = 'findings',
+  CHECKS = 'checks',
 }
 
 /**
@@ -190,6 +191,17 @@
   INHERIT = 'INHERIT',
 }
 
+/**
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#mergeable-info
+ */
+export enum MergeStrategy {
+  RECURSIVE = 'recursive',
+  RESOLVE = 'resolve',
+  SIMPLE_TWO_WAY_IN_CORE = 'simple-two-way-in-core',
+  OURS = 'ours',
+  THEIRS = 'theirs',
+}
+
 /*
  * Enum for possible configured value in InheritedBooleanInfo.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#inherited-boolean-info
@@ -292,10 +304,21 @@
 export enum EmailStrategy {
   ENABLED = 'ENABLED',
   CC_ON_OWN_COMMENTS = 'CC_ON_OWN_COMMENTS',
+  ATTENTION_SET_ONLY = 'ATTENTION_SET_ONLY',
   DISABLED = 'DISABLED',
 }
 
 /**
+ * The type of email format to use.
+ * Doesn't mentioned in doc, but exists in Java class GeneralPreferencesInfo.
+ */
+
+export enum EmailFormat {
+  PLAINTEXT = 'PLAINTEXT',
+  HTML_PLAINTEXT = 'HTML_PLAINTEXT',
+}
+
+/**
  * The base which should be pre-selected in the 'Diff Against' drop-down list when the change screen is opened for a merge commit
  * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
  */
@@ -305,17 +328,6 @@
 }
 
 /**
- * Whether whitespace changes should be ignored and if yes, which whitespace changes should be ignored
- * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#diff-preferences-input
- */
-export enum IgnoreWhitespaceType {
-  IGNORE_NONE = 'IGNORE_NONE',
-  IGNORE_TRAILING = 'IGNORE_TRAILING',
-  IGNORE_LEADING_AND_TRAILING = 'IGNORE_LEADING_AND_TRAILING',
-  IGNORE_ALL = 'IGNORE_ALL',
-}
-
-/**
  * how draft comments are handled
  */
 export enum DraftsAction {
@@ -330,3 +342,51 @@
   OWNER_REVIEWERS = 'OWNER_REVIEWERS',
   ALL = 'ALL',
 }
+
+/**
+ * The authentication type that is configured on the server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
+ */
+export enum AuthType {
+  OPENID = 'OPENID',
+  OPENID_SSO = 'OPENID_SSO',
+  OAUTH = 'OAUTH',
+  HTTP = 'HTTP',
+  HTTP_LDAP = 'HTTP_LDAP',
+  CLIENT_SSL_CERT_LDAP = 'CLIENT_SSL_CERT_LDAP',
+  LDAP = 'LDAP',
+  LDAP_BIND = 'LDAP_BIND',
+  CUSTOM_EXTENSION = 'CUSTOM_EXTENSION',
+  DEVELOPMENT_BECOME_ANY_ACCOUNT = 'DEVELOPMENT_BECOME_ANY_ACCOUNT',
+}
+
+/**
+ * Controls visibility of other users' dashboard pages and completion suggestions to web users
+ * https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#accounts.visibility
+ */
+export enum AccountsVisibility {
+  ALL = 'ALL',
+  SAME_GROUP = 'SAME_GROUP',
+  VISIBLE_GROUP = 'VISIBLE_GROUP',
+  NONE = 'NONE',
+}
+
+/**
+ * Account fields that are editable
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
+ */
+export enum EditableAccountField {
+  FULL_NAME = 'FULL_NAME',
+  USER_NAME = 'USER_NAME',
+  REGISTER_NEW_EMAIL = 'REGISTER_NEW_EMAIL',
+}
+
+/**
+ * This setting determines when Gerrit computes if a change is mergeable or not.
+ * https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#change.mergeabilityComputationBehavior
+ */
+export enum MergeabilityComputationBehavior {
+  API_REF_UPDATED_AND_CHANGE_REINDEX = 'API_REF_UPDATED_AND_CHANGE_REINDEX',
+  REF_UPDATED_AND_CHANGE_REINDEX = 'REF_UPDATED_AND_CHANGE_REINDEX',
+  NEVER = 'NEVER',
+}
diff --git a/polygerrit-ui/app/constants/messages.ts b/polygerrit-ui/app/constants/messages.ts
index 15692bd..5b4a534 100644
--- a/polygerrit-ui/app/constants/messages.ts
+++ b/polygerrit-ui/app/constants/messages.ts
@@ -15,10 +15,6 @@
  * limitations under the License.
  */
 
-/** @desc Default message shown when no threads in gr-thread-list */
-export const NO_THREADS_MSG =
-  'There are no inline comment threads on any diff for this change.';
-
 /** @desc Message shown when no threads in gr-thread-list for robot comments */
 export const NO_ROBOT_COMMENTS_THREADS_MSG =
   'There are no findings for this patchset.';
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
index b9039ae..44ab784 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
@@ -31,9 +31,10 @@
 import {customElement, property, observe, computed} from '@polymer/decorators';
 import {AppElementAdminParams} from '../../gr-app-types';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {GroupId, GroupInfo} from '../../../types/common';
+import {GroupId, GroupInfo, GroupName} from '../../../types/common';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrCreateGroupDialog} from '../gr-create-group-dialog/gr-create-group-dialog';
+import {fireTitleChange} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -100,13 +101,7 @@
   attached() {
     super.attached();
     this._getCreateGroupCapability();
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Groups'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Groups');
     this._maybeOpenCreateOverlay(this.params);
   }
 
@@ -160,7 +155,7 @@
         }
         this._groups = Object.keys(groups).map(key => {
           const group = groups[key];
-          group.name = key;
+          group.name = key as GroupName;
           return group;
         });
         this._loading = false;
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
deleted file mode 100644
index d861681..0000000
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
+++ /dev/null
@@ -1,313 +0,0 @@
-/**
- * @license
- * 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.
- */
-import '../../../styles/gr-menu-page-styles.js';
-import '../../../styles/gr-page-nav-styles.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-dropdown-list/gr-dropdown-list.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import '../../shared/gr-page-nav/gr-page-nav.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-admin-group-list/gr-admin-group-list.js';
-import '../gr-group/gr-group.js';
-import '../gr-group-audit-log/gr-group-audit-log.js';
-import '../gr-group-members/gr-group-members.js';
-import '../gr-plugin-list/gr-plugin-list.js';
-import '../gr-repo/gr-repo.js';
-import '../gr-repo-access/gr-repo-access.js';
-import '../gr-repo-commands/gr-repo-commands.js';
-import '../gr-repo-dashboards/gr-repo-dashboards.js';
-import '../gr-repo-detail-list/gr-repo-detail-list.js';
-import '../gr-repo-list/gr-repo-list.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-admin-view_html.js';
-import {getBaseUrl} from '../../../utils/url-util.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {getAdminLinks} from '../../../utils/admin-nav-util.js';
-
-const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
-
-/**
- * @extends PolymerElement
- */
-class GrAdminView extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-admin-view'; }
-
-  static get properties() {
-    return {
-    /** @type {?} */
-      params: Object,
-      path: String,
-      adminView: String,
-
-      _breadcrumbParentName: String,
-      _repoName: String,
-      _groupId: {
-        type: Number,
-        observer: '_computeGroupName',
-      },
-      _groupIsInternal: Boolean,
-      _groupName: String,
-      _groupOwner: {
-        type: Boolean,
-        value: false,
-      },
-      _subsectionLinks: Array,
-      _filteredLinks: Array,
-      _showDownload: {
-        type: Boolean,
-        value: false,
-      },
-      _isAdmin: {
-        type: Boolean,
-        value: false,
-      },
-      _showGroup: Boolean,
-      _showGroupAuditLog: Boolean,
-      _showGroupList: Boolean,
-      _showGroupMembers: Boolean,
-      _showRepoAccess: Boolean,
-      _showRepoCommands: Boolean,
-      _showRepoDashboards: Boolean,
-      _showRepoDetailList: Boolean,
-      _showRepoMain: Boolean,
-      _showRepoList: Boolean,
-      _showPluginList: Boolean,
-    };
-  }
-
-  static get observers() {
-    return [
-      '_paramsChanged(params)',
-    ];
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this.reload();
-  }
-
-  reload() {
-    const promises = [
-      this.$.restAPI.getAccount(),
-      getPluginLoader().awaitPluginsLoaded(),
-    ];
-    return Promise.all(promises).then(result => {
-      this._account = result[0];
-      let options;
-      if (this._repoName) {
-        options = {repoName: this._repoName};
-      } else if (this._groupId) {
-        options = {
-          groupId: this._groupId,
-          groupName: this._groupName,
-          groupIsInternal: this._groupIsInternal,
-          isAdmin: this._isAdmin,
-          groupOwner: this._groupOwner,
-        };
-      }
-
-      return getAdminLinks(this._account,
-          params => this.$.restAPI.getAccountCapabilities(params),
-          () => this.$.jsAPI.getAdminMenuLinks(),
-          options)
-          .then(res => {
-            this._filteredLinks = res.links;
-            this._breadcrumbParentName = res.expandedSection ?
-              res.expandedSection.name : '';
-
-            if (!res.expandedSection) {
-              this._subsectionLinks = [];
-              return;
-            }
-            this._subsectionLinks = [res.expandedSection]
-                .concat(res.expandedSection.children).map(section => {
-                  return {
-                    text: !section.detailType ? 'Home' : section.name,
-                    value: section.view + (section.detailType || ''),
-                    view: section.view,
-                    url: section.url,
-                    detailType: section.detailType,
-                    parent: this._groupId || this._repoName || '',
-                  };
-                });
-          });
-    });
-  }
-
-  _computeSelectValue(params) {
-    if (!params || !params.view) { return; }
-    return params.view + (params.detail || '');
-  }
-
-  _selectedIsCurrentPage(selected) {
-    return (selected.parent === (this._repoName || this._groupId) &&
-        selected.view === this.params.view &&
-        selected.detailType === this.params.detail);
-  }
-
-  _handleSubsectionChange(e) {
-    const selected = this._subsectionLinks
-        .find(section => section.value === e.detail.value);
-
-    // This is when it gets set initially.
-    if (this._selectedIsCurrentPage(selected)) {
-      return;
-    }
-    GerritNav.navigateToRelativeUrl(selected.url);
-  }
-
-  _paramsChanged(params) {
-    const isGroupView = params.view === GerritNav.View.GROUP;
-    const isRepoView = params.view === GerritNav.View.REPO;
-    const isAdminView = params.view === GerritNav.View.ADMIN;
-
-    this.set('_showGroup', isGroupView && !params.detail);
-    this.set('_showGroupAuditLog', isGroupView &&
-        params.detail === GerritNav.GroupDetailView.LOG);
-    this.set('_showGroupMembers', isGroupView &&
-        params.detail === GerritNav.GroupDetailView.MEMBERS);
-
-    this.set('_showGroupList', isAdminView &&
-        params.adminView === 'gr-admin-group-list');
-
-    this.set('_showRepoAccess', isRepoView &&
-        params.detail === GerritNav.RepoDetailView.ACCESS);
-    this.set('_showRepoCommands', isRepoView &&
-        params.detail === GerritNav.RepoDetailView.COMMANDS);
-    this.set('_showRepoDetailList', isRepoView &&
-        (params.detail === GerritNav.RepoDetailView.BRANCHES ||
-         params.detail === GerritNav.RepoDetailView.TAGS));
-    this.set('_showRepoDashboards', isRepoView &&
-        params.detail === GerritNav.RepoDetailView.DASHBOARDS);
-    this.set('_showRepoMain', isRepoView && !params.detail);
-
-    this.set('_showRepoList', isAdminView &&
-        params.adminView === 'gr-repo-list');
-
-    this.set('_showPluginList', isAdminView &&
-        params.adminView === 'gr-plugin-list');
-
-    let needsReload = false;
-    if (params.repo !== this._repoName) {
-      this._repoName = params.repo || '';
-      // Reloads the admin menu.
-      needsReload = true;
-    }
-    if (params.groupId !== this._groupId) {
-      this._groupId = params.groupId || '';
-      // Reloads the admin menu.
-      needsReload = true;
-    }
-    if (this._breadcrumbParentName && !params.groupId && !params.repo) {
-      needsReload = true;
-    }
-    if (!needsReload) { return; }
-    this.reload();
-  }
-
-  // TODO (beckysiegel): Update these functions after router abstraction is
-  // updated. They are currently copied from gr-dropdown (and should be
-  // updated there as well once complete).
-  _computeURLHelper(host, path) {
-    return '//' + host + getBaseUrl() + path;
-  }
-
-  _computeRelativeURL(path) {
-    const host = window.location.host;
-    return this._computeURLHelper(host, path);
-  }
-
-  _computeLinkURL(link) {
-    if (!link || typeof link.url === 'undefined') { return ''; }
-    if (link.target || !link.noBaseUrl) {
-      return link.url;
-    }
-    return this._computeRelativeURL(link.url);
-  }
-
-  /**
-   * @param {string} itemView
-   * @param {Object} params
-   * @param {string=} opt_detailType
-   */
-  _computeSelectedClass(itemView, params, opt_detailType) {
-    if (!params) return '';
-    // Group params are structured differently from admin params. Compute
-    // selected differently for groups.
-    // TODO(wyatta): Simplify this when all routes work like group params.
-    if (params.view === GerritNav.View.GROUP &&
-        itemView === GerritNav.View.GROUP) {
-      if (!params.detail && !opt_detailType) { return 'selected'; }
-      if (params.detail === opt_detailType) { return 'selected'; }
-      return '';
-    }
-
-    if (params.view === GerritNav.View.REPO &&
-        itemView === GerritNav.View.REPO) {
-      if (!params.detail && !opt_detailType) { return 'selected'; }
-      if (params.detail === opt_detailType) { return 'selected'; }
-      return '';
-    }
-
-    if (params.detailType && params.detailType !== opt_detailType) {
-      return '';
-    }
-    return itemView === params.adminView ? 'selected' : '';
-  }
-
-  _computeGroupName(groupId) {
-    if (!groupId) { return ''; }
-
-    const promises = [];
-    this.$.restAPI.getGroupConfig(groupId).then(group => {
-      if (!group || !group.name) { return; }
-
-      this._groupName = group.name;
-      this._groupIsInternal = !!group.id.match(INTERNAL_GROUP_REGEX);
-      this.reload();
-
-      promises.push(this.$.restAPI.getIsAdmin().then(isAdmin => {
-        this._isAdmin = isAdmin;
-      }));
-
-      promises.push(this.$.restAPI.getIsGroupOwner(group.name).then(
-          isOwner => {
-            this._groupOwner = isOwner;
-          }));
-
-      return Promise.all(promises).then(() => {
-        this.reload();
-      });
-    });
-  }
-
-  _updateGroupName(e) {
-    this._groupName = e.detail.name;
-    this.reload();
-  }
-}
-
-customElements.define(GrAdminView.is, GrAdminView);
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
new file mode 100644
index 0000000..7fb713f
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
@@ -0,0 +1,463 @@
+/**
+ * @license
+ * 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.
+ */
+import '../../../styles/gr-menu-page-styles';
+import '../../../styles/gr-page-nav-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-dropdown-list/gr-dropdown-list';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import '../../shared/gr-page-nav/gr-page-nav';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-admin-group-list/gr-admin-group-list';
+import '../gr-group/gr-group';
+import '../gr-group-audit-log/gr-group-audit-log';
+import '../gr-group-members/gr-group-members';
+import '../gr-plugin-list/gr-plugin-list';
+import '../gr-repo/gr-repo';
+import '../gr-repo-access/gr-repo-access';
+import '../gr-repo-commands/gr-repo-commands';
+import '../gr-repo-dashboards/gr-repo-dashboards';
+import '../gr-repo-detail-list/gr-repo-detail-list';
+import '../gr-repo-list/gr-repo-list';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-admin-view_html';
+import {getBaseUrl} from '../../../utils/url-util';
+import {
+  GerritNav,
+  GerritView,
+  GroupDetailView,
+  RepoDetailView,
+} from '../../core/gr-navigation/gr-navigation';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {
+  AdminNavLinksOption,
+  getAdminLinks,
+  NavLink,
+  SubsectionInterface,
+} from '../../../utils/admin-nav-util';
+import {customElement, observe, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  AppElementAdminParams,
+  AppElementGroupParams,
+  AppElementRepoParams,
+} from '../../gr-app-types';
+import {
+  AccountDetailInfo,
+  GroupId,
+  GroupName,
+  RepoName,
+} from '../../../types/common';
+import {GroupNameChangedDetail} from '../gr-group/gr-group';
+import {ValueChangeDetail} from '../../shared/gr-dropdown-list/gr-dropdown-list';
+import {GrJsApiInterface} from '../../shared/gr-js-api-interface/gr-js-api-interface-element';
+
+const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
+
+export interface GrAdminView {
+  $: {
+    restAPI: RestApiService & Element;
+    jsAPI: GrJsApiInterface;
+  };
+}
+
+interface AdminSubsectionLink {
+  text: string;
+  value: string;
+  view: GerritView;
+  url: string;
+  detailType?: GroupDetailView | RepoDetailView;
+  parent?: GroupId | RepoName;
+}
+
+// The type is matched to the _showAdminView function from the gr-app-element
+type AdminViewParams =
+  | AppElementAdminParams
+  | AppElementGroupParams
+  | AppElementRepoParams;
+
+function getAdminViewParamsDetail(
+  params: AdminViewParams
+): GroupDetailView | RepoDetailView | undefined {
+  if (params.view !== GerritView.ADMIN) {
+    return params.detail;
+  }
+  return undefined;
+}
+
+@customElement('gr-admin-view')
+export class GrAdminView extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  private _account?: AccountDetailInfo;
+
+  @property({type: Object})
+  params?: AdminViewParams;
+
+  @property({type: String})
+  path?: string;
+
+  @property({type: String})
+  adminView?: string;
+
+  @property({type: String})
+  _breadcrumbParentName?: string;
+
+  @property({type: String})
+  _repoName?: RepoName;
+
+  @property({type: String, observer: '_computeGroupName'})
+  _groupId?: GroupId;
+
+  @property({type: Boolean})
+  _groupIsInternal?: boolean;
+
+  @property({type: String})
+  _groupName?: GroupName;
+
+  @property({type: Boolean})
+  _groupOwner = false;
+
+  @property({type: Array})
+  _subsectionLinks?: AdminSubsectionLink[];
+
+  @property({type: Array})
+  _filteredLinks?: NavLink[];
+
+  @property({type: Boolean})
+  _showDownload = false;
+
+  @property({type: Boolean})
+  _isAdmin = false;
+
+  @property({type: Boolean})
+  _showGroup?: boolean;
+
+  @property({type: Boolean})
+  _showGroupAuditLog?: boolean;
+
+  @property({type: Boolean})
+  _showGroupList?: boolean;
+
+  @property({type: Boolean})
+  _showGroupMembers?: boolean;
+
+  @property({type: Boolean})
+  _showRepoAccess?: boolean;
+
+  @property({type: Boolean})
+  _showRepoCommands?: boolean;
+
+  @property({type: Boolean})
+  _showRepoDashboards?: boolean;
+
+  @property({type: Boolean})
+  _showRepoDetailList?: boolean;
+
+  @property({type: Boolean})
+  _showRepoMain?: boolean;
+
+  @property({type: Boolean})
+  _showRepoList?: boolean;
+
+  @property({type: Boolean})
+  _showPluginList?: boolean;
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.reload();
+  }
+
+  reload() {
+    const promises: [Promise<AccountDetailInfo | undefined>, Promise<void>] = [
+      this.$.restAPI.getAccount(),
+      getPluginLoader().awaitPluginsLoaded(),
+    ];
+    return Promise.all(promises).then(result => {
+      this._account = result[0];
+      let options: AdminNavLinksOption | undefined = undefined;
+      if (this._repoName) {
+        options = {repoName: this._repoName};
+      } else if (this._groupId) {
+        options = {
+          groupId: this._groupId,
+          groupName: this._groupName,
+          groupIsInternal: this._groupIsInternal,
+          isAdmin: this._isAdmin,
+          groupOwner: this._groupOwner,
+        };
+      }
+
+      return getAdminLinks(
+        this._account,
+        () =>
+          this.$.restAPI.getAccountCapabilities().then(capabilities => {
+            if (!capabilities) {
+              throw new Error('getAccountCapabilities returns undefined');
+            }
+            return capabilities;
+          }),
+        () => this.$.jsAPI.getAdminMenuLinks(),
+        options
+      ).then(res => {
+        this._filteredLinks = res.links;
+        this._breadcrumbParentName = res.expandedSection
+          ? res.expandedSection.name
+          : '';
+
+        if (!res.expandedSection) {
+          this._subsectionLinks = [];
+          return;
+        }
+        this._subsectionLinks = [res.expandedSection]
+          .concat(res.expandedSection.children ?? [])
+          .map(section => {
+            return {
+              text: !section.detailType ? 'Home' : section.name,
+              value: section.view + (section.detailType ?? ''),
+              view: section.view,
+              url: section.url,
+              detailType: section.detailType,
+              parent: this._groupId ?? this._repoName,
+            };
+          });
+      });
+    });
+  }
+
+  _computeSelectValue(params: AdminViewParams) {
+    if (!params || !params.view) return;
+    return `${params.view}${getAdminViewParamsDetail(params) ?? ''}`;
+  }
+
+  _selectedIsCurrentPage(selected: AdminSubsectionLink) {
+    if (!this.params) return false;
+
+    return (
+      selected.parent === (this._repoName ?? this._groupId) &&
+      selected.view === this.params.view &&
+      selected.detailType === getAdminViewParamsDetail(this.params)
+    );
+  }
+
+  _handleSubsectionChange(e: CustomEvent<ValueChangeDetail>) {
+    if (!this._subsectionLinks) return;
+
+    // The GrDropdownList items are _subsectionLinks, so find(...) always return
+    // an item _subsectionLinks and never returns undefined
+    const selected = this._subsectionLinks.find(
+      section => section.value === e.detail.value
+    )!;
+
+    // This is when it gets set initially.
+    if (this._selectedIsCurrentPage(selected)) return;
+    GerritNav.navigateToRelativeUrl(selected.url);
+  }
+
+  @observe('params')
+  _paramsChanged(params: AdminViewParams) {
+    this.set('_showGroup', params.view === GerritView.GROUP && !params.detail);
+    this.set(
+      '_showGroupAuditLog',
+      params.view === GerritView.GROUP && params.detail === GroupDetailView.LOG
+    );
+    this.set(
+      '_showGroupMembers',
+      params.view === GerritView.GROUP &&
+        params.detail === GroupDetailView.MEMBERS
+    );
+
+    this.set(
+      '_showGroupList',
+      params.view === GerritView.ADMIN &&
+        params.adminView === 'gr-admin-group-list'
+    );
+
+    this.set(
+      '_showRepoAccess',
+      params.view === GerritView.REPO && params.detail === RepoDetailView.ACCESS
+    );
+    this.set(
+      '_showRepoCommands',
+      params.view === GerritView.REPO &&
+        params.detail === RepoDetailView.COMMANDS
+    );
+    this.set(
+      '_showRepoDetailList',
+      params.view === GerritView.REPO &&
+        (params.detail === RepoDetailView.BRANCHES ||
+          params.detail === RepoDetailView.TAGS)
+    );
+    this.set(
+      '_showRepoDashboards',
+      params.view === GerritView.REPO &&
+        params.detail === RepoDetailView.DASHBOARDS
+    );
+    this.set(
+      '_showRepoMain',
+      params.view === GerritView.REPO && !params.detail
+    );
+
+    this.set(
+      '_showRepoList',
+      params.view === GerritView.ADMIN && params.adminView === 'gr-repo-list'
+    );
+
+    this.set(
+      '_showPluginList',
+      params.view === GerritView.ADMIN && params.adminView === 'gr-plugin-list'
+    );
+
+    let needsReload = false;
+    const newRepoName =
+      params.view === GerritView.REPO ? params.repo : undefined;
+    if (newRepoName !== this._repoName) {
+      this._repoName = newRepoName;
+      // Reloads the admin menu.
+      needsReload = true;
+    }
+    const newGroupId =
+      params.view === GerritView.GROUP ? params.groupId : undefined;
+    if (newGroupId !== this._groupId) {
+      this._groupId = newGroupId;
+      // Reloads the admin menu.
+      needsReload = true;
+    }
+    if (
+      this._breadcrumbParentName &&
+      (params.view !== GerritView.GROUP || !params.groupId) &&
+      (params.view !== GerritView.REPO || !params.repo)
+    ) {
+      needsReload = true;
+    }
+    if (!needsReload) {
+      return;
+    }
+    this.reload();
+  }
+
+  // TODO (beckysiegel): Update these functions after router abstraction is
+  // updated. They are currently copied from gr-dropdown (and should be
+  // updated there as well once complete).
+  _computeURLHelper(host: string, path: string) {
+    return '//' + host + getBaseUrl() + path;
+  }
+
+  _computeRelativeURL(path: string) {
+    const host = window.location.host;
+    return this._computeURLHelper(host, path);
+  }
+
+  _computeLinkURL(link: NavLink | SubsectionInterface) {
+    if (!link || typeof link.url === 'undefined') return '';
+
+    if ((link as NavLink).target || !(link as NavLink).noBaseUrl) {
+      return link.url;
+    }
+    return this._computeRelativeURL(link.url);
+  }
+
+  _computeSelectedClass(
+    itemView?: GerritView,
+    params?: AdminViewParams,
+    detailType?: GroupDetailView | RepoDetailView
+  ) {
+    if (!params) return '';
+    // Group params are structured differently from admin params. Compute
+    // selected differently for groups.
+    // TODO(wyatta): Simplify this when all routes work like group params.
+    if (params.view === GerritView.GROUP && itemView === GerritView.GROUP) {
+      if (!params.detail && !detailType) {
+        return 'selected';
+      }
+      if (params.detail === detailType) {
+        return 'selected';
+      }
+      return '';
+    }
+
+    if (params.view === GerritView.REPO && itemView === GerritView.REPO) {
+      if (!params.detail && !detailType) {
+        return 'selected';
+      }
+      if (params.detail === detailType) {
+        return 'selected';
+      }
+      return '';
+    }
+    // TODO(TS): The following condtion seems always false, because params
+    // never has detailType property. Remove it.
+    if (
+      ((params as unknown) as AdminSubsectionLink).detailType &&
+      ((params as unknown) as AdminSubsectionLink).detailType !== detailType
+    ) {
+      return '';
+    }
+    return params.view === GerritView.ADMIN && itemView === params.adminView
+      ? 'selected'
+      : '';
+  }
+
+  _computeGroupName(groupId?: GroupId) {
+    if (!groupId) return;
+
+    const promises: Array<Promise<void>> = [];
+    this.$.restAPI.getGroupConfig(groupId).then(group => {
+      if (!group || !group.name) {
+        return;
+      }
+
+      this._groupName = group.name;
+      this._groupIsInternal = !!group.id.match(INTERNAL_GROUP_REGEX);
+      this.reload();
+
+      promises.push(
+        this.$.restAPI.getIsAdmin().then(isAdmin => {
+          this._isAdmin = !!isAdmin;
+        })
+      );
+
+      promises.push(
+        this.$.restAPI.getIsGroupOwner(group.name).then(isOwner => {
+          this._groupOwner = isOwner;
+        })
+      );
+
+      return Promise.all(promises).then(() => {
+        this.reload();
+      });
+    });
+  }
+
+  _updateGroupName(e: CustomEvent<GroupNameChangedDetail>) {
+    this._groupName = e.detail.name;
+    this.reload();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-admin-view': GrAdminView;
+  }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
index 5fe479a..44fd4d6 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
@@ -18,7 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-admin-view.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation.js';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 import {stubBaseUrl} from '../../../test/test-utils.js';
 
@@ -149,7 +149,7 @@
     return element.reload().then(() => {
       assert.equal(element._filteredLinks.length, 3);
       assert.deepEqual(element._filteredLinks[1], {
-        capability: null,
+        capability: undefined,
         url: '/internal/link/url',
         name: 'internal link text',
         noBaseUrl: true,
@@ -158,7 +158,7 @@
         target: null,
       });
       assert.deepEqual(element._filteredLinks[2], {
-        capability: null,
+        capability: undefined,
         url: 'http://external/link/url',
         name: 'external link text',
         noBaseUrl: false,
@@ -244,10 +244,10 @@
         'getAccount')
         .callsFake(() => Promise.resolve({_id: 1}));
     sinon.stub(element, 'reload');
-    element.params = {repo: 'Test Repo', adminView: 'gr-repo'};
+    element.params = {repo: 'Test Repo', view: GerritView.REPO};
     assert.equal(element.reload.callCount, 1);
     element.params = {repo: 'Test Repo 2',
-      adminView: 'gr-repo'};
+      view: GerritView.REPO};
     assert.equal(element.reload.callCount, 2);
   });
 
@@ -266,7 +266,7 @@
         'getAccount')
         .callsFake(() => Promise.resolve({_id: 1}));
     sinon.stub(element, 'reload');
-    element.params = {groupId: '1', adminView: 'gr-group'};
+    element.params = {groupId: '1', view: GerritView.GROUP};
     assert.equal(element.reload.callCount, 1);
   });
 
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
index aee5a14..2a6c7a8 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
@@ -37,6 +37,8 @@
 import {InheritedBooleanInfoConfiguredValue} from '../../../constants/constants';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 
 const SUGGESTIONS_LIMIT = 15;
 const REF_PREFIX = 'refs/heads/';
@@ -45,6 +47,9 @@
   $: {
     restAPI: RestApiService & Element;
     privateChangeCheckBox: HTMLInputElement;
+    branchInput: GrAutocomplete;
+    tagNameInput: HTMLInputElement;
+    messageInput: IronAutogrowTextareaElement;
   };
 }
 @customElement('gr-create-change-dialog')
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
similarity index 64%
rename from polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.js
rename to polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
index b570a7b..e529730 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
@@ -17,40 +17,48 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-create-change-dialog.js';
+import {GrCreateChangeDialog} from './gr-create-change-dialog';
+import {BranchName, GitRef, RepoName} from '../../../types/common';
+import {InheritedBooleanInfoConfiguredValue} from '../../../constants/constants';
+import {createChange, createConfig} from '../../../test/test-data-generators';
 
 const basicFixture = fixtureFromElement('gr-create-change-dialog');
 
 suite('gr-create-change-dialog tests', () => {
-  let element;
+  let element: GrCreateChangeDialog;
 
   setup(() => {
     stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
+      getLoggedIn() {
+        return Promise.resolve(true);
+      },
       getRepoBranches(input) {
         if (input.startsWith('test')) {
           return Promise.resolve([
             {
-              ref: 'refs/heads/test-branch',
+              ref: 'refs/heads/test-branch' as GitRef,
               revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
               can_delete: true,
             },
           ]);
         } else {
-          return Promise.resolve({});
+          return Promise.resolve([]);
         }
       },
     });
     element = basicFixture.instantiate();
-    element.repoName = 'test-repo';
+    element.repoName = 'test-repo' as RepoName;
     element._repoConfig = {
+      ...createConfig(),
       private_by_default: {
-        configured_value: 'FALSE',
+        value: false,
+        configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
         inherited_value: false,
       },
     };
   });
 
-  test('new change created with default', done => {
+  test('new change created with default', async () => {
     const configInputObj = {
       branch: 'test-branch',
       subject: 'first change created with polygerrit ui',
@@ -59,35 +67,32 @@
       work_in_progress: true,
     };
 
-    const saveStub = sinon.stub(element.$.restAPI,
-        'createChange').callsFake(() => Promise.resolve({}));
+    const saveStub = sinon
+      .stub(element.$.restAPI, 'createChange')
+      .callsFake(() => Promise.resolve(createChange()));
 
-    element.branch = 'test-branch';
+    element.branch = 'test-branch' as BranchName;
     element.topic = 'test-topic';
     element.subject = 'first change created with polygerrit ui';
     assert.isFalse(element.$.privateChangeCheckBox.checked);
 
-    element.$.branchInput.bindValue = configInputObj.branch;
-    element.$.tagNameInput.bindValue = configInputObj.topic;
     element.$.messageInput.bindValue = configInputObj.subject;
 
-    element.handleCreateChange().then(() => {
-      // Private change
-      assert.isFalse(saveStub.lastCall.args[4]);
-      // WIP Change
-      assert.isTrue(saveStub.lastCall.args[5]);
-      assert.isTrue(saveStub.called);
-      done();
-    });
+    await element.handleCreateChange();
+    // Private change
+    assert.isFalse(saveStub.lastCall.args[4]);
+    // WIP Change
+    assert.isTrue(saveStub.lastCall.args[5]);
+    assert.isTrue(saveStub.called);
   });
 
-  test('new change created with private', done => {
+  test('new change created with private', async () => {
     element.privateByDefault = {
-      configured_value: 'TRUE',
+      configured_value: InheritedBooleanInfoConfiguredValue.TRUE,
       inherited_value: false,
+      value: true,
     };
-    sinon.stub(element, '_formatBooleanString')
-        .callsFake(() => Promise.resolve(true));
+    sinon.stub(element, '_formatBooleanString').callsFake(() => true);
     flush();
 
     const configInputObj = {
@@ -98,26 +103,23 @@
       work_in_progress: true,
     };
 
-    const saveStub = sinon.stub(element.$.restAPI,
-        'createChange').callsFake(() => Promise.resolve({}));
+    const saveStub = sinon
+      .stub(element.$.restAPI, 'createChange')
+      .callsFake(() => Promise.resolve(createChange()));
 
-    element.branch = 'test-branch';
+    element.branch = 'test-branch' as BranchName;
     element.topic = 'test-topic';
     element.subject = 'first change created with polygerrit ui';
     assert.isTrue(element.$.privateChangeCheckBox.checked);
 
-    element.$.branchInput.bindValue = configInputObj.branch;
-    element.$.tagNameInput.bindValue = configInputObj.topic;
     element.$.messageInput.bindValue = configInputObj.subject;
 
-    element.handleCreateChange().then(() => {
-      // Private change
-      assert.isTrue(saveStub.lastCall.args[4]);
-      // WIP Change
-      assert.isTrue(saveStub.lastCall.args[5]);
-      assert.isTrue(saveStub.called);
-      done();
-    });
+    await element.handleCreateChange();
+    // Private change
+    assert.isTrue(saveStub.lastCall.args[4]);
+    // WIP Change
+    assert.isTrue(saveStub.lastCall.args[5]);
+    assert.isTrue(saveStub.called);
   });
 
   test('_getRepoBranchesSuggestions empty', done => {
@@ -145,4 +147,3 @@
     assert.equal(element._computePrivateSectionClass(false), '');
   });
 });
-
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
index e8b5d78..1d8b7db 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
@@ -26,7 +26,7 @@
 import {page} from '../../../utils/page-wrapper-utils';
 import {customElement, property, observe} from '@polymer/decorators';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {GroupId} from '../../../types/common';
+import {GroupName} from '../../../types/common';
 
 export interface GrCreateGroupDialog {
   $: {
@@ -46,7 +46,7 @@
   hasNewGroupName = false;
 
   @property({type: String})
-  _name = '';
+  _name: GroupName | '' = '';
 
   @property({type: Boolean})
   _groupCreated = false;
@@ -61,20 +61,17 @@
   }
 
   handleCreateGroup() {
-    return this.$.restAPI
-      .createGroup({name: this._name})
-      .then(groupRegistered => {
-        if (groupRegistered.status !== 201) {
-          return;
-        }
-        this._groupCreated = true;
-        return this.$.restAPI
-          .getGroupConfig(this._name as GroupId)
-          .then(group => {
-            // TODO(TS): should group always defined ?
-            page.show(this._computeGroupUrl(group!.group_id!));
-          });
+    const name = this._name as GroupName;
+    return this.$.restAPI.createGroup({name}).then(groupRegistered => {
+      if (groupRegistered.status !== 201) {
+        return;
+      }
+      this._groupCreated = true;
+      return this.$.restAPI.getGroupConfig(name).then(group => {
+        // TODO(TS): should group always defined ?
+        page.show(this._computeGroupUrl(group!.group_id!));
       });
+    });
   }
 }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
index f7cffac..e5a1586 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
@@ -37,6 +37,7 @@
   EncodedGroupId,
   GroupAuditEventInfo,
 } from '../../../types/common';
+import {firePageError, fireTitleChange} from '../../../utils/event-util';
 
 const GROUP_EVENTS = ['ADD_GROUP', 'REMOVE_GROUP'];
 
@@ -65,13 +66,7 @@
   /** @override */
   attached() {
     super.attached();
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Audit Log'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Audit Log');
   }
 
   /** @override */
@@ -86,13 +81,7 @@
     }
 
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(this, response);
     };
 
     return this.$.restAPI
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
index f8f1fee..01571a2 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
@@ -41,10 +41,16 @@
   AccountId,
   AccountInfo,
   GroupInfo,
+  GroupName,
 } from '../../../types/common';
 import {AutocompleteQuery} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {PolymerDomRepeatEvent} from '../../../types/types';
 import {hasOwnProperty} from '../../../utils/common-util';
+import {
+  fireAlert,
+  firePageError,
+  fireTitleChange,
+} from '../../../utils/event-util';
 
 const SUGGESTIONS_LIMIT = 15;
 const SAVING_ERROR_TEXT =
@@ -85,7 +91,7 @@
   _loading = true;
 
   @property({type: String})
-  _groupName?: GroupId;
+  _groupName?: GroupName;
 
   @property({type: Object})
   _groupMembers?: AccountInfo[];
@@ -124,13 +130,7 @@
     super.attached();
     this._loadGroupDetails();
 
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Members'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Members');
   }
 
   _loadGroupDetails() {
@@ -141,13 +141,7 @@
     const promises: Promise<void>[] = [];
 
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(this, response);
     };
 
     return this.$.restAPI.getGroupConfig(this.groupId, errFn).then(config => {
@@ -155,7 +149,7 @@
         return Promise.resolve();
       }
 
-      this._groupName = config.name as GroupId;
+      this._groupName = config.name;
 
       promises.push(
         this.$.restAPI.getIsAdmin().then(isAdmin => {
@@ -296,13 +290,7 @@
         (errResponse, err) => {
           if (errResponse) {
             if (errResponse.status === 404) {
-              this.dispatchEvent(
-                new CustomEvent('show-alert', {
-                  detail: {message: SAVING_ERROR_TEXT},
-                  bubbles: true,
-                  composed: true,
-                })
-              );
+              fireAlert(this, SAVING_ERROR_TEXT);
               return errResponse;
             }
             throw Error(errResponse.statusText);
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
index 7bc0312..4525543 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -32,12 +32,13 @@
   AutocompleteSuggestion,
   AutocompleteQuery,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
-import {GroupId, GroupInfo} from '../../../types/common';
+import {GroupId, GroupInfo, GroupName} from '../../../types/common';
 import {
   ErrorCallback,
   RestApiService,
 } from '../../../services/services/gr-rest-api/gr-rest-api';
 import {hasOwnProperty} from '../../../utils/common-util';
+import {firePageError, fireTitleChange} from '../../../utils/event-util';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
@@ -59,6 +60,11 @@
   };
 }
 
+export interface GroupNameChangedDetail {
+  name: GroupName;
+  external: boolean;
+}
+
 declare global {
   interface HTMLElementTagNameMap {
     'gr-group': GrGroup;
@@ -140,13 +146,7 @@
     const promises: Promise<unknown>[] = [];
 
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(this, response);
     };
 
     return this.$.restAPI.getGroupConfig(this.groupId, errFn).then(config => {
@@ -164,7 +164,7 @@
       );
 
       promises.push(
-        this.$.restAPI.getIsGroupOwner(config.name as GroupId).then(isOwner => {
+        this.$.restAPI.getIsGroupOwner(config.name).then(isOwner => {
           this._groupOwner = !!isOwner;
         })
       );
@@ -178,13 +178,7 @@
       }
       this._groupConfig = config;
 
-      this.dispatchEvent(
-        new CustomEvent('title-change', {
-          detail: {title: config.name},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireTitleChange(this, config.name);
 
       return Promise.all(promises).then(() => {
         this._loading = false;
@@ -205,17 +199,19 @@
     if (!this.groupId || !groupConfig || !groupConfig.name) {
       return Promise.reject(new Error('invalid groupId or config name'));
     }
+    const groupName = groupConfig.name;
     return this.$.restAPI
-      .saveGroupName(this.groupId, groupConfig.name)
+      .saveGroupName(this.groupId, groupName)
       .then(config => {
         if (config.status === 200) {
-          this._groupName = groupConfig.name;
+          this._groupName = groupName;
+          const detail: GroupNameChangedDetail = {
+            name: groupName,
+            external: !this._groupIsInternal,
+          };
           this.dispatchEvent(
             new CustomEvent('name-changed', {
-              detail: {
-                name: groupConfig.name,
-                external: !this._groupIsInternal,
-              },
+              detail,
               composed: true,
               bubbles: true,
             })
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
index 27e6f32..c998cd8 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -306,10 +306,7 @@
 
   _computeLabelValues(values: LabelTypeInfoValues): ComputedLabelValue[] {
     const valuesArr: ComputedLabelValue[] = [];
-    const keys = Object.keys(values).sort(
-      // TODO(TS): change parseInto to Number(...) according to typescript style guide
-      (a, b) => parseInt(a, 10) - parseInt(b, 10)
-    );
+    const keys = Object.keys(values).sort((a, b) => Number(a) - Number(b));
 
     for (const key of keys) {
       let text = values[key];
@@ -318,7 +315,7 @@
       }
       // The value from the server being used to choose which item is
       // selected is in integer form, so this must be converted.
-      valuesArr.push({value: parseInt(key, 10), text});
+      valuesArr.push({value: Number(key), text});
     }
     return valuesArr;
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
index 5039972..9337042 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
@@ -30,6 +30,8 @@
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {ErrorCallback} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {PluginInfo} from '../../../types/common';
+import {firePageError} from '../../../utils/event-util';
+import {fireTitleChange} from '../../../utils/event-util';
 
 interface PluginInfoWithName extends PluginInfo {
   name: string;
@@ -84,13 +86,7 @@
   /** @override */
   attached() {
     super.attached();
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Plugins'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Plugins');
   }
 
   _paramsChanged(params: ListViewParams) {
@@ -103,13 +99,7 @@
 
   _getPlugins(filter: string, pluginsPerPage: number, offset?: number) {
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(this, response);
     };
     return this.$.restAPI
       .getPlugins(filter, pluginsPerPage, offset, errFn)
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
index b96ec2c..31e1f65 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
@@ -52,6 +52,7 @@
   PropertyTreeNode,
   PrimitiveValue,
 } from './gr-repo-access-interfaces';
+import {firePageError, fireAlert} from '../../../utils/event-util';
 
 const NOTHING_TO_SAVE = 'No changes to save.';
 
@@ -155,13 +156,7 @@
 
   _reload(repo: RepoName) {
     const errFn = (response?: Response | null) => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(this, response);
     };
 
     this._editing = false;
@@ -516,13 +511,7 @@
       !Object.keys(addRemoveObj.remove).length &&
       !addRemoveObj.parent
     ) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: NOTHING_TO_SAVE},
-          bubbles: true,
-          composed: true,
-        })
-      );
+      fireAlert(this, NOTHING_TO_SAVE);
       return;
     }
     const obj: ProjectAccessInput = ({
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
index a74f4bb..14cfedd 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
@@ -42,6 +42,11 @@
 } from '../../../types/common';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrCreateChangeDialog} from '../gr-create-change-dialog/gr-create-change-dialog';
+import {
+  fireAlert,
+  firePageError,
+  fireTitleChange,
+} from '../../../utils/event-util';
 
 const GC_MESSAGE = 'Garbage collection completed successfully.';
 const CONFIG_BRANCH = 'refs/meta/config' as BranchName;
@@ -95,13 +100,7 @@
     super.attached();
     this._loadRepo();
 
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Repo Commands'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Repo Commands');
   }
 
   _loadRepo() {
@@ -109,13 +108,7 @@
       // Do not process the error, if the component is not attached to the DOM
       // anymore, which at least in tests can happen.
       if (!this.isConnected) return;
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(this, response);
     };
 
     this.$.restAPI.getProjectConfig(this.repo, errFn).then(config => {
@@ -142,13 +135,7 @@
       .runRepoGC(this.repo)
       .then(response => {
         if (response?.status === 200) {
-          this.dispatchEvent(
-            new CustomEvent('show-alert', {
-              detail: {message: GC_MESSAGE},
-              bubbles: true,
-              composed: true,
-            })
-          );
+          fireAlert(this, GC_MESSAGE);
         }
       })
       .finally(() => {
@@ -190,13 +177,7 @@
         const message = change
           ? CREATE_CHANGE_SUCCEEDED_MESSAGE
           : CREATE_CHANGE_FAILED_MESSAGE;
-        this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message},
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fireAlert(this, message);
         if (!change) {
           return;
         }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
index d9d8560..99f85fa 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
@@ -27,6 +27,7 @@
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {RepoName, DashboardId, DashboardInfo} from '../../../types/common';
 import {ErrorCallback} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {firePageError} from '../../../utils/event-util';
 
 interface DashboardRef {
   section: string;
@@ -62,13 +63,7 @@
     }
 
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(this, response);
     };
 
     return this.$.restAPI
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
index 828d447..17414c0 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
@@ -53,6 +53,7 @@
 import {AppElementRepoParams} from '../../gr-app-types';
 import {PolymerDomRepeatEvent} from '../../../types/types';
 import {RepoDetailView} from '../../core/gr-navigation/gr-navigation';
+import {firePageError} from '../../../utils/event-util';
 
 const PGP_START = '-----BEGIN PGP SIGNATURE-----';
 
@@ -131,6 +132,17 @@
       return Promise.reject(new Error('undefined repo'));
     }
 
+    // paramsChanged is called before gr-admin-view can set _showRepoDetailList
+    // to false and polymer removes this component, hence check for params
+    if (
+      !(
+        params?.detail === RepoDetailView.BRANCHES ||
+        params?.detail === RepoDetailView.TAGS
+      )
+    ) {
+      return;
+    }
+
     this._repo = params.repo;
 
     this._getLoggedIn().then(loggedIn => {
@@ -171,14 +183,9 @@
     this._items = [];
     flush();
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(this, response);
     };
+
     if (detailType === RepoDetailView.BRANCHES) {
       return this.$.restAPI
         .getRepoBranches(filter, repo, itemsPerPage, offset, errFn)
@@ -215,6 +222,11 @@
     return webLinks.length ? webLinks : null;
   }
 
+  _computeFirstWebLink(repo: ProjectInfo) {
+    const webLinks = this._computeWeblink(repo);
+    return webLinks ? webLinks[0].url : null;
+  }
+
   _computeMessage(message?: string) {
     if (!message) {
       return;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts
index 8955092..196797f 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts
@@ -104,7 +104,9 @@
         <template is="dom-repeat" items="[[_shownItems]]">
           <tr class="table">
             <td class$="[[detailType]] name">
-              [[_stripRefs(item.ref, detailType)]]
+              <a href$="[[_computeFirstWebLink(item)]]">
+                [[_stripRefs(item.ref, detailType)]]
+              </a>
             </td>
             <td
               class$="[[detailType]] revision [[_computeCanEditClass(item.ref, detailType, _isOwner)]]"
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
index ba2d850..a566eb0 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
@@ -34,6 +34,7 @@
 import {RepoName, ProjectInfoWithName} from '../../../types/common';
 import {GrCreateRepoDialog} from '../gr-create-repo-dialog/gr-create-repo-dialog';
 import {ProjectState} from '../../../constants/constants';
+import {fireTitleChange} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -93,13 +94,7 @@
   attached() {
     super.attached();
     this._getCreateRepoCapability();
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'Repos'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'Repos');
     this._maybeOpenCreateOverlay(this.params);
   }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index 101c77a..426e512 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -48,6 +48,7 @@
 import {ProjectState} from '../../../constants/constants';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {hasOwnProperty} from '../../../utils/common-util';
+import {firePageError, fireTitleChange} from '../../../utils/event-util';
 
 const STATES = {
   active: {value: ProjectState.ACTIVE, label: 'Active'},
@@ -149,13 +150,7 @@
     super.attached();
     this._loadRepo();
 
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: this.repo},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, `${this.repo}`);
   }
 
   _computePluginData(
@@ -182,13 +177,7 @@
     const promises = [];
 
     const errFn: ErrorCallback = response => {
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      firePageError(this, response);
     };
 
     promises.push(
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
index 26d05c3..2e86758 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
@@ -52,8 +52,8 @@
     <div class="info">
       <h1 id="Title" class="heading-1">
         [[repo]]
-        <hr />
       </h1>
+      <hr />
       <div>
         <a href$="[[_computeChangesUrl(repo)]]">(view changes)</a>
       </div>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index 20cc207..64342f4 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -46,6 +46,7 @@
   ServerInfo,
   AccountInfo,
   QuickLabelInfo,
+  Timestamp,
 } from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
 
@@ -67,6 +68,11 @@
   REJECTED = 'REJECTED',
 }
 
+export interface ChangeListToggleReviewedDetail {
+  change: ChangeInfo;
+  reviewed: boolean;
+}
+
 // How many reviewers should be shown with an account-label?
 const PRIMARY_REVIEWERS_COUNT = 2;
 
@@ -146,15 +152,19 @@
     if (!label || category === LabelCategory.NOT_APPLICABLE) {
       return 'Label not applicable';
     }
+    const titleParts: string[] = [];
     if (category === LabelCategory.UNRESOLVED_COMMENTS) {
       const num = change?.unresolved_comment_count ?? 0;
       const plural = num > 1 ? 's' : '';
-      return `${num} unresolved comment${plural}`;
+      titleParts.push(`${num} unresolved comment${plural}`);
     }
     const significantLabel =
       label.rejected || label.approved || label.disliked || label.recommended;
-    if (significantLabel && significantLabel.name) {
-      return `${labelName}\nby ${significantLabel.name}`;
+    if (significantLabel?.name) {
+      titleParts.push(`${labelName} by ${significantLabel.name}`);
+    }
+    if (titleParts.length > 0) {
+      return titleParts.join(',\n');
     }
     return labelName;
   }
@@ -383,14 +393,27 @@
     }
   }
 
+  _computeWaiting(
+    account?: AccountInfo,
+    change?: ChangeInfo
+  ): Timestamp | undefined {
+    if (!account?._account_id || !change?.attention_set) return undefined;
+    return change?.attention_set[account._account_id]?.last_update;
+  }
+
   toggleReviewed() {
+    if (!this.change) return;
     const newVal = !this.change?.reviewed;
     this.set('change.reviewed', newVal);
+    const detail: ChangeListToggleReviewedDetail = {
+      change: this.change,
+      reviewed: newVal,
+    };
     this.dispatchEvent(
       new CustomEvent('toggle-reviewed', {
         bubbles: true,
         composed: true,
-        detail: {change: this.change, reviewed: newVal},
+        detail,
       })
     );
   }
@@ -408,6 +431,16 @@
       isOwner: selfId === ownerId,
     });
   }
+
+  _computeCommaHidden(index?: number, change?: ChangeInfo) {
+    if (index === undefined) return false;
+    if (change === undefined) return false;
+
+    const additionalCount = this._computeAdditionalReviewersCount(change);
+    const primaryCount = this._computePrimaryReviewers(change).length;
+    const isLast = index === primaryCount - 1;
+    return isLast && additionalCount === 0;
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
index 5316fe5..fdb4534 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
@@ -32,9 +32,6 @@
       font-weight: var(--font-weight-bold);
       color: var(--primary-text-color);
     }
-    :host([highlight]) {
-      background-color: var(--assignee-highlight-color);
-    }
     .container {
       position: relative;
     }
@@ -113,9 +110,6 @@
     .cell.label iron-icon {
       vertical-align: top;
     }
-    .lastChildHidden:last-of-type {
-      display: none;
-    }
     @media only screen and (max-width: 50em) {
       :host {
         display: flex;
@@ -197,6 +191,7 @@
         is="dom-repeat"
         items="[[_computePrimaryReviewers(change)]]"
         as="reviewer"
+        indexAs="index"
       >
         <gr-account-link
           hide-avatar=""
@@ -206,7 +201,11 @@
           change="[[change]]"
           account="[[reviewer]]"
         ></gr-account-link
-        ><span class="lastChildHidden" aria-hidden="true">, </span>
+        ><span
+          hidden$="[[_computeCommaHidden(index, change)]]"
+          aria-hidden="true"
+          >,
+        </span>
       </template>
       <template is="dom-if" if="[[_computeAdditionalReviewersCount(change)]]">
         <span title="[[_computeAdditionalReviewersTitle(change, config)]]">
@@ -266,6 +265,26 @@
     ></gr-date-formatter>
   </td>
   <td
+    class="cell submitted"
+    hidden$="[[isColumnHidden('Submitted', visibleChangeTableColumns)]]"
+  >
+    <gr-date-formatter
+      has-tooltip=""
+      date-str="[[change.submitted]]"
+    ></gr-date-formatter>
+  </td>
+  <td
+    class="cell waiting"
+    hidden$="[[isColumnHidden('Waiting', visibleChangeTableColumns)]]"
+  >
+    <gr-date-formatter
+      has-tooltip=""
+      force-relative=""
+      relative-option-no-ago=""
+      date-str="[[_computeWaiting(account, change)]]"
+    ></gr-date-formatter>
+  </td>
+  <td
     class="cell size"
     hidden$="[[isColumnHidden('Size', visibleChangeTableColumns)]]"
   >
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js
index d3274f3..38cc772 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js
@@ -88,35 +88,35 @@
         'Label not applicable');
     assert.equal(element._computeLabelTitle(
         {labels: {Verified: {approved: {name: 'Diffy'}}}}, 'Verified'),
-    'Verified\nby Diffy');
+    'Verified by Diffy');
     assert.equal(element._computeLabelTitle(
         {labels: {Verified: {approved: {name: 'Diffy'}}}}, 'Code-Review'),
     'Label not applicable');
     assert.equal(element._computeLabelTitle(
         {labels: {Verified: {rejected: {name: 'Diffy'}}}}, 'Verified'),
-    'Verified\nby Diffy');
+    'Verified by Diffy');
     assert.equal(element._computeLabelTitle(
         {labels: {'Code-Review': {disliked: {name: 'Diffy'}, value: -1}}},
-        'Code-Review'), 'Code-Review\nby Diffy');
+        'Code-Review'), 'Code-Review by Diffy');
     assert.equal(element._computeLabelTitle(
         {labels: {'Code-Review': {recommended: {name: 'Diffy'}, value: 1}}},
-        'Code-Review'), 'Code-Review\nby Diffy');
+        'Code-Review'), 'Code-Review by Diffy');
     assert.equal(element._computeLabelTitle(
         {labels: {'Code-Review': {recommended: {name: 'Diffy'},
           rejected: {name: 'Admin'}}}}, 'Code-Review'),
-    'Code-Review\nby Admin');
+    'Code-Review by Admin');
     assert.equal(element._computeLabelTitle(
         {labels: {'Code-Review': {approved: {name: 'Diffy'},
           rejected: {name: 'Admin'}}}}, 'Code-Review'),
-    'Code-Review\nby Admin');
+    'Code-Review by Admin');
     assert.equal(element._computeLabelTitle(
         {labels: {'Code-Review': {recommended: {name: 'Diffy'},
           disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
-    'Code-Review\nby Admin');
+    'Code-Review by Admin');
     assert.equal(element._computeLabelTitle(
         {labels: {'Code-Review': {approved: {name: 'Diffy'},
           disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
-    'Code-Review\nby Diffy');
+    'Code-Review by Diffy');
     assert.equal(element._computeLabelTitle(
         {
           labels: {'Code-Review': {approved: true, value: 1}},
@@ -125,6 +125,12 @@
     '1 unresolved comment');
     assert.equal(element._computeLabelTitle(
         {
+          labels: {'Code-Review': {approved: {name: 'Diffy'}, value: 1}},
+          unresolved_comment_count: 1,
+        }, 'Code-Review'),
+    '1 unresolved comment,\nCode-Review by Diffy');
+    assert.equal(element._computeLabelTitle(
+        {
           labels: {'Code-Review': {approved: true, value: 1}},
           unresolved_comment_count: 2,
         }, 'Code-Review'),
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
deleted file mode 100644
index b6bc03c..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
+++ /dev/null
@@ -1,306 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-change-list/gr-change-list.js';
-import '../gr-repo-header/gr-repo-header.js';
-import '../gr-user-header/gr-user-header.js';
-import '../../../styles/shared-styles.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-change-list-view_html.js';
-import {page} from '../../../utils/page-wrapper-utils.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-const LookupQueryPatterns = {
-  CHANGE_ID: /^\s*i?[0-9a-f]{7,40}\s*$/i,
-  CHANGE_NUM: /^\s*[1-9][0-9]*\s*$/g,
-  COMMIT: /[0-9a-f]{40}/,
-};
-
-const USER_QUERY_PATTERN = /^owner:\s?("[^"]+"|[^ ]+)$/;
-
-const REPO_QUERY_PATTERN =
-    /^project:\s?("[^"]+"|[^ ]+)(\sstatus\s?:(open|"open"))?$/;
-
-const LIMIT_OPERATOR_PATTERN = /\blimit:(\d+)/i;
-
-/**
- * @extends PolymerElement
- */
-class GrChangeListView extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-change-list-view'; }
-  /**
-   * Fired when the title of the page should change.
-   *
-   * @event title-change
-   */
-
-  static get properties() {
-    return {
-    /**
-     * URL params passed from the router.
-     */
-      params: {
-        type: Object,
-        observer: '_paramsChanged',
-      },
-
-      /**
-       * True when user is logged in.
-       */
-      _loggedIn: {
-        type: Boolean,
-        computed: '_computeLoggedIn(account)',
-      },
-
-      account: {
-        type: Object,
-        value: null,
-      },
-
-      /**
-       * State persisted across restamps of the element.
-       *
-       * Need sub-property declaration since it is used in template before
-       * assignment.
-       *
-       * @type {{ selectedChangeIndex: (number|undefined) }}
-       *
-       */
-      viewState: {
-        type: Object,
-        notify: true,
-        value() { return {}; },
-      },
-
-      preferences: Object,
-
-      _changesPerPage: Number,
-
-      /**
-       * Currently active query.
-       */
-      _query: {
-        type: String,
-        value: '',
-      },
-
-      /**
-       * Offset of currently visible query results.
-       */
-      _offset: Number,
-
-      /**
-       * Change objects loaded from the server.
-       */
-      _changes: {
-        type: Array,
-        observer: '_changesChanged',
-      },
-
-      /**
-       * For showing a "loading..." string during ajax requests.
-       */
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-
-      /** @type {?string} */
-      _userId: {
-        type: String,
-        value: null,
-      },
-
-      /** @type {?string} */
-      _repo: {
-        type: String,
-        value: null,
-      },
-    };
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('next-page',
-        () => this._handleNextPage());
-    this.addEventListener('previous-page',
-        () => this._handlePreviousPage());
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._loadPreferences();
-  }
-
-  _paramsChanged(value) {
-    if (value.view !== GerritNav.View.SEARCH) { return; }
-
-    this._loading = true;
-    this._query = value.query;
-    this._offset = value.offset || 0;
-    if (this.viewState.query != this._query ||
-        this.viewState.offset != this._offset) {
-      this.set('viewState.selectedChangeIndex', 0);
-      this.set('viewState.query', this._query);
-      this.set('viewState.offset', this._offset);
-    }
-
-    // NOTE: This method may be called before attachment. Fire title-change
-    // in an async so that attachment to the DOM can take place first.
-    this.async(() => this.dispatchEvent(new CustomEvent('title-change', {
-      detail: {title: this._query},
-      composed: true, bubbles: true,
-    })));
-
-    this._getPreferences()
-        .then(prefs => {
-          this._changesPerPage = prefs.changes_per_page;
-          return this._getChanges();
-        })
-        .then(changes => {
-          changes = changes || [];
-          if (this._query && changes.length === 1) {
-            for (const query in LookupQueryPatterns) {
-              if (LookupQueryPatterns.hasOwnProperty(query) &&
-              this._query.match(LookupQueryPatterns[query])) {
-                // "Back"/"Forward" buttons work correctly only with
-                // opt_redirect options
-                GerritNav.navigateToChange(changes[0], null, null, null, true);
-                return;
-              }
-            }
-          }
-          this._changes = changes;
-          this._loading = false;
-        });
-  }
-
-  _loadPreferences() {
-    return this.$.restAPI.getLoggedIn().then(loggedIn => {
-      if (loggedIn) {
-        this._getPreferences().then(preferences => {
-          this.preferences = preferences;
-        });
-      } else {
-        this.preferences = {};
-      }
-    });
-  }
-
-  _getChanges() {
-    return this.$.restAPI.getChanges(this._changesPerPage, this._query,
-        this._offset);
-  }
-
-  _getPreferences() {
-    return this.$.restAPI.getPreferences();
-  }
-
-  _limitFor(query, defaultLimit) {
-    const match = query.match(LIMIT_OPERATOR_PATTERN);
-    if (!match) {
-      return defaultLimit;
-    }
-    return parseInt(match[1], 10);
-  }
-
-  _computeNavLink(query, offset, direction, changesPerPage) {
-    // Offset could be a string when passed from the router.
-    offset = +(offset || 0);
-    const limit = this._limitFor(query, changesPerPage);
-    const newOffset = Math.max(0, offset + (limit * direction));
-    return GerritNav.getUrlForSearchQuery(query, newOffset);
-  }
-
-  _computePrevArrowClass(offset) {
-    return offset === 0 ? 'hide' : '';
-  }
-
-  _computeNextArrowClass(changes) {
-    const more = changes.length && changes[changes.length - 1]._more_changes;
-    return more ? '' : 'hide';
-  }
-
-  _computeNavClass(loading) {
-    return loading || !this._changes || !this._changes.length ? 'hide' : '';
-  }
-
-  _handleNextPage() {
-    if (this.$.nextArrow.hidden) { return; }
-    page.show(this._computeNavLink(
-        this._query, this._offset, 1, this._changesPerPage));
-  }
-
-  _handlePreviousPage() {
-    if (this.$.prevArrow.hidden) { return; }
-    page.show(this._computeNavLink(
-        this._query, this._offset, -1, this._changesPerPage));
-  }
-
-  _changesChanged(changes) {
-    this._userId = null;
-    this._repo = null;
-    if (!changes || !changes.length) {
-      return;
-    }
-    if (USER_QUERY_PATTERN.test(this._query)) {
-      const owner = changes[0].owner;
-      const userId = owner._account_id ? owner._account_id : owner.email;
-      if (userId) {
-        this._userId = userId;
-        return;
-      }
-    }
-    if (REPO_QUERY_PATTERN.test(this._query)) {
-      this._repo = changes[0].project;
-    }
-  }
-
-  _computeHeaderClass(id) {
-    return id ? '' : 'hide';
-  }
-
-  _computePage(offset, changesPerPage) {
-    return offset / changesPerPage + 1;
-  }
-
-  _computeLoggedIn(account) {
-    return !!(account && Object.keys(account).length > 0);
-  }
-
-  _handleToggleStar(e) {
-    this.$.restAPI.saveChangeStarred(e.detail.change._number,
-        e.detail.starred);
-  }
-
-  _handleToggleReviewed(e) {
-    this.$.restAPI.saveChangeReviewed(e.detail.change._number,
-        e.detail.reviewed);
-  }
-}
-
-customElements.define(GrChangeListView.is, GrChangeListView);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
new file mode 100644
index 0000000..3391901
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -0,0 +1,300 @@
+/**
+ * @license
+ * 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.
+ */
+
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-change-list/gr-change-list';
+import '../gr-repo-header/gr-repo-header';
+import '../gr-user-header/gr-user-header';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-change-list-view_html';
+import {page} from '../../../utils/page-wrapper-utils';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property} from '@polymer/decorators';
+import {AppElementParams} from '../../gr-app-types';
+import {
+  AccountDetailInfo,
+  AccountId,
+  ChangeInfo,
+  EmailAddress,
+  PreferencesInput,
+} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {ChangeListToggleReviewedDetail} from '../gr-change-list-item/gr-change-list-item';
+import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {ChangeListViewState} from '../../../types/types';
+import {fireTitleChange} from '../../../utils/event-util';
+
+const LookupQueryPatterns = {
+  CHANGE_ID: /^\s*i?[0-9a-f]{7,40}\s*$/i,
+  CHANGE_NUM: /^\s*[1-9][0-9]*\s*$/g,
+  COMMIT: /[0-9a-f]{40}/,
+};
+
+const USER_QUERY_PATTERN = /^owner:\s?("[^"]+"|[^ ]+)$/;
+
+const REPO_QUERY_PATTERN = /^project:\s?("[^"]+"|[^ ]+)(\sstatus\s?:(open|"open"))?$/;
+
+const LIMIT_OPERATOR_PATTERN = /\blimit:(\d+)/i;
+
+export interface GrChangeListView {
+  $: {
+    restAPI: RestApiService & Element;
+    prevArrow: HTMLAnchorElement;
+    nextArrow: HTMLAnchorElement;
+  };
+}
+
+@customElement('gr-change-list-view')
+export class GrChangeListView extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the title of the page should change.
+   *
+   * @event title-change
+   */
+
+  @property({type: Object, observer: '_paramsChanged'})
+  params?: AppElementParams;
+
+  @property({type: Boolean, computed: '_computeLoggedIn(account)'})
+  _loggedIn?: boolean;
+
+  @property({type: Object})
+  account: AccountDetailInfo | null = null;
+
+  @property({type: Object, notify: true})
+  viewState: ChangeListViewState = {};
+
+  @property({type: Object})
+  preferences?: PreferencesInput;
+
+  @property({type: Number})
+  _changesPerPage?: number;
+
+  @property({type: String})
+  _query = '';
+
+  @property({type: Number})
+  _offset?: number;
+
+  @property({type: Array, observer: '_changesChanged'})
+  _changes?: ChangeInfo[];
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: String})
+  _userId: AccountId | EmailAddress | null = null;
+
+  @property({type: String})
+  _repo: string | null = null;
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('next-page', () => this._handleNextPage());
+    this.addEventListener('previous-page', () => this._handlePreviousPage());
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadPreferences();
+  }
+
+  _paramsChanged(value: AppElementParams) {
+    if (value.view !== GerritView.SEARCH) return;
+
+    this._loading = true;
+    this._query = value.query;
+    const offset = Number(value.offset);
+    this._offset = isNaN(offset) ? 0 : offset;
+    if (
+      this.viewState.query !== this._query ||
+      this.viewState.offset !== this._offset
+    ) {
+      this.set('viewState.selectedChangeIndex', 0);
+      this.set('viewState.query', this._query);
+      this.set('viewState.offset', this._offset);
+    }
+
+    // NOTE: This method may be called before attachment. Fire title-change
+    // in an async so that attachment to the DOM can take place first.
+    this.async(() => fireTitleChange(this, this._query));
+
+    this.$.restAPI
+      .getPreferences()
+      .then(prefs => {
+        if (!prefs) {
+          throw new Error('getPreferences returned undefined');
+        }
+        this._changesPerPage = prefs.changes_per_page;
+        return this._getChanges();
+      })
+      .then(changes => {
+        changes = changes || [];
+        if (this._query && changes.length === 1) {
+          let query: keyof typeof LookupQueryPatterns;
+          for (query in LookupQueryPatterns) {
+            if (
+              hasOwnProperty(LookupQueryPatterns, query) &&
+              this._query.match(LookupQueryPatterns[query])
+            ) {
+              // "Back"/"Forward" buttons work correctly only with
+              // opt_redirect options
+              GerritNav.navigateToChange(
+                changes[0],
+                undefined,
+                undefined,
+                undefined,
+                true
+              );
+              return;
+            }
+          }
+        }
+        this._changes = changes;
+        this._loading = false;
+      });
+  }
+
+  _loadPreferences() {
+    return this.$.restAPI.getLoggedIn().then(loggedIn => {
+      if (loggedIn) {
+        this.$.restAPI.getPreferences().then(preferences => {
+          this.preferences = preferences;
+        });
+      } else {
+        this.preferences = {};
+      }
+    });
+  }
+
+  _getChanges() {
+    return this.$.restAPI.getChanges(
+      this._changesPerPage,
+      this._query,
+      this._offset
+    );
+  }
+
+  _limitFor(query: string, defaultLimit: number) {
+    const match = query.match(LIMIT_OPERATOR_PATTERN);
+    if (!match) {
+      return defaultLimit;
+    }
+    return Number(match[1]);
+  }
+
+  _computeNavLink(
+    query: string,
+    offset: number | undefined,
+    direction: number,
+    changesPerPage: number
+  ) {
+    offset = offset ?? 0;
+    const limit = this._limitFor(query, changesPerPage);
+    const newOffset = Math.max(0, offset + limit * direction);
+    return GerritNav.getUrlForSearchQuery(query, newOffset);
+  }
+
+  _computePrevArrowClass(offset?: number) {
+    return offset === 0 ? 'hide' : '';
+  }
+
+  _computeNextArrowClass(changes?: ChangeInfo[]) {
+    const more = changes?.length && changes[changes.length - 1]._more_changes;
+    return more ? '' : 'hide';
+  }
+
+  _computeNavClass(loading?: boolean) {
+    return loading || !this._changes || !this._changes.length ? 'hide' : '';
+  }
+
+  _handleNextPage() {
+    if (this.$.nextArrow.hidden || !this._changesPerPage) return;
+    page.show(
+      this._computeNavLink(this._query, this._offset, 1, this._changesPerPage)
+    );
+  }
+
+  _handlePreviousPage() {
+    if (this.$.prevArrow.hidden || !this._changesPerPage) return;
+    page.show(
+      this._computeNavLink(this._query, this._offset, -1, this._changesPerPage)
+    );
+  }
+
+  _changesChanged(changes?: ChangeInfo[]) {
+    this._userId = null;
+    this._repo = null;
+    if (!changes || !changes.length) {
+      return;
+    }
+    if (USER_QUERY_PATTERN.test(this._query)) {
+      const owner = changes[0].owner;
+      const userId = owner._account_id ? owner._account_id : owner.email;
+      if (userId) {
+        this._userId = userId;
+        return;
+      }
+    }
+    if (REPO_QUERY_PATTERN.test(this._query)) {
+      this._repo = changes[0].project;
+    }
+  }
+
+  _computeHeaderClass(id?: string) {
+    return id ? '' : 'hide';
+  }
+
+  _computePage(offset?: number, changesPerPage?: number) {
+    if (offset === undefined || changesPerPage === undefined) return;
+    return offset / changesPerPage + 1;
+  }
+
+  _computeLoggedIn(account?: AccountDetailInfo) {
+    return !!(account && Object.keys(account).length > 0);
+  }
+
+  _handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
+    this.$.restAPI.saveChangeStarred(e.detail.change._number, e.detail.starred);
+  }
+
+  _handleToggleReviewed(e: CustomEvent<ChangeListToggleReviewedDetail>) {
+    this.$.restAPI.saveChangeReviewed(
+      e.detail.change._number,
+      e.detail.reviewed
+    );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-view': GrChangeListView;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js
index 4c52ce9..af3acd8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js
@@ -109,6 +109,7 @@
 
   test('_handleNextPage', () => {
     const showStub = sinon.stub(page, 'show');
+    element._changesPerPage = 10;
     element.$.nextArrow.hidden = true;
     element._handleNextPage();
     assert.isFalse(showStub.called);
@@ -119,6 +120,7 @@
 
   test('_handlePreviousPage', () => {
     const showStub = sinon.stub(page, 'show');
+    element._changesPerPage = 10;
     element.$.prevArrow.hidden = true;
     element._handlePreviousPage();
     assert.isFalse(showStub.called);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index a05ccf4..3acdaf9 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -31,16 +31,17 @@
 import {
   KeyboardShortcutMixin,
   Shortcut,
-  CustomKeyboardEvent,
   Modifier,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {
   GerritNav,
   DashboardSection,
+  YOUR_TURN,
+  CLOSED,
 } from '../../core/gr-navigation/gr-navigation';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {changeIsOpen} from '../../../utils/change-util';
+import {changeIsOpen, isOwner} from '../../../utils/change-util';
 import {customElement, property, observe} from '@polymer/decorators';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
@@ -50,6 +51,11 @@
   ServerInfo,
   PreferencesInput,
 } from '../../../types/common';
+import {
+  hasAttention,
+  isAttentionSetEnabled,
+} from '../../../utils/attention-set-util';
+import {CustomKeyboardEvent} from '../../../types/events';
 
 const NUMBER_FIXED_COLUMNS = 3;
 const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
@@ -57,6 +63,8 @@
 const MAX_SHORTCUT_CHARS = 5;
 
 export interface ChangeListSection {
+  name?: string;
+  query?: string;
   results: ChangeInfo[];
 }
 export interface GrChangeList {
@@ -229,9 +237,35 @@
     }
   }
 
-  _computeColspan(changeTableColumns: string[], labelNames: string[]) {
-    if (!changeTableColumns || !labelNames) return;
-    return changeTableColumns.length + labelNames.length + NUMBER_FIXED_COLUMNS;
+  /**
+   * This methods allows us to customize the columns per section.
+   *
+   * @param visibleColumns are the columns according to configs and user prefs
+   */
+  _computeColumns(
+    section?: ChangeListSection,
+    visibleColumns?: string[]
+  ): string[] {
+    if (!section || !visibleColumns) return [];
+    const cols = [...visibleColumns];
+    const updatedIndex = cols.indexOf('Updated');
+    if (section.name === YOUR_TURN.name && updatedIndex !== -1) {
+      cols[updatedIndex] = 'Waiting';
+    }
+    if (section.name === CLOSED.name && updatedIndex !== -1) {
+      cols[updatedIndex] = 'Submitted';
+    }
+    return cols;
+  }
+
+  _computeColspan(
+    section?: ChangeListSection,
+    visibleColumns?: string[],
+    labelNames?: string[]
+  ) {
+    const cols = this._computeColumns(section, visibleColumns);
+    if (!cols || !labelNames) return 1;
+    return cols.length + labelNames.length + NUMBER_FIXED_COLUMNS;
   }
 
   _computeLabelNames(sections: ChangeListSection[]) {
@@ -327,10 +361,8 @@
     showReviewedState: boolean,
     config?: ServerInfo
   ) {
-    const isAttentionSetEnabled =
-      !!config && !!config.change && config.change.enable_attention_set;
     return (
-      !isAttentionSetEnabled &&
+      !isAttentionSetEnabled(config) &&
       showReviewedState &&
       !change.reviewed &&
       !change.work_in_progress &&
@@ -339,17 +371,19 @@
     );
   }
 
-  _computeItemHighlight(account?: AccountInfo, change?: ChangeInfo) {
-    // Do not show the assignee highlight if the change is not open.
-    if (
-      !change ||
-      !change.assignee ||
-      !account ||
-      CLOSED_STATUS.indexOf(change.status) !== -1
-    ) {
-      return false;
-    }
-    return account._account_id === change.assignee._account_id;
+  _computeItemHighlight(
+    account?: AccountInfo,
+    change?: ChangeInfo,
+    config?: ServerInfo,
+    sectionName?: string
+  ) {
+    if (!change || !account) return false;
+    if (CLOSED_STATUS.indexOf(change.status) !== -1) return false;
+    return isAttentionSetEnabled(config)
+      ? hasAttention(config, account, change) &&
+          !isOwner(change, account) &&
+          sectionName === YOUR_TURN.name
+      : account._account_id === change.assignee?._account_id;
   }
 
   _nextChange(e: CustomKeyboardEvent) {
@@ -492,7 +526,7 @@
 
   _getSpecialEmptySlot(section: DashboardSection) {
     if (section.isOutgoing) return 'empty-outgoing';
-    if (section.name === 'Your Turn') return 'empty-your-turn';
+    if (section.name === YOUR_TURN.name) return 'empty-your-turn';
     return '';
   }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
index f223fbf..06957d9 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
@@ -61,17 +61,19 @@
             ></td>
             <td
               class="cell"
-              colspan$="[[_computeColspan(changeTableColumns, labelNames)]]"
+              colspan$="[[_computeColspan(changeSection, visibleChangeTableColumns, labelNames)]]"
             >
-              <a
-                href$="[[_sectionHref(changeSection.query)]]"
-                class="section-title"
-              >
-                <span class="section-name">[[changeSection.name]]</span>
-                <span class="section-count-label"
-                  >[[changeSection.countLabel]]</span
+              <h2>
+                <a
+                  href$="[[_sectionHref(changeSection.query)]]"
+                  class="section-title"
                 >
-              </a>
+                  <span class="section-name">[[changeSection.name]]</span>
+                  <span class="section-count-label"
+                    >[[changeSection.countLabel]]</span
+                  >
+                </a>
+              </h2>
             </td>
           </tr>
         </tbody>
@@ -83,7 +85,7 @@
             <td aria-hidden="true" class="star" hidden></td>
             <td
               class="cell"
-              colspan$="[[_computeColspan(changeTableColumns, labelNames)]]"
+              colspan$="[[_computeColspan(changeSection, visibleChangeTableColumns, labelNames)]]"
             >
               <template
                 is="dom-if"
@@ -110,11 +112,12 @@
               hidden=""
             ></td>
             <td class="number" hidden$="[[!showNumber]]" hidden="">#</td>
-            <template is="dom-repeat" items="[[changeTableColumns]]" as="item">
-              <td
-                class$="[[_lowerCase(item)]]"
-                hidden$="[[isColumnHidden(item, visibleChangeTableColumns)]]"
-              >
+            <template
+              is="dom-repeat"
+              items="[[_computeColumns(changeSection, visibleChangeTableColumns)]]"
+              as="item"
+            >
+              <td class$="[[_lowerCase(item)]]">
                 [[item]]
               </td>
             </template>
@@ -139,12 +142,12 @@
           <gr-change-list-item
             account="[[account]]"
             selected$="[[_computeItemSelected(sectionIndex, index, selectedIndex)]]"
-            highlight$="[[_computeItemHighlight(account, change)]]"
+            highlight$="[[_computeItemHighlight(account, change, _config, changeSection.name)]]"
             needs-review$="[[_computeItemNeedsReview(account, change, showReviewedState, _config)]]"
             change="[[change]]"
             config="[[_config]]"
             section-name="[[changeSection.name]]"
-            visible-change-table-columns="[[visibleChangeTableColumns]]"
+            visible-change-table-columns="[[_computeColumns(changeSection, visibleChangeTableColumns)]]"
             show-number="[[showNumber]]"
             show-star="[[showStar]]"
             tabindex$="[[_computeTabIndex(sectionIndex, index, selectedIndex)]]"
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
index 68ab5e5..494d05a 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
@@ -21,6 +21,7 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
 import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+import {YOUR_TURN} from '../../core/gr-navigation/gr-navigation.js';
 
 const basicFixture = fixtureFromElement('gr-change-list');
 
@@ -145,7 +146,7 @@
     const changeTableColumns = [];
     const labelNames = [];
     assert.equal(tdItemCount, element._computeColspan(
-        changeTableColumns, labelNames));
+        {}, changeTableColumns, labelNames));
   });
 
   test('keyboard shortcuts', done => {
@@ -303,7 +304,7 @@
     });
 
     test('shown on empty outgoing sections', () => {
-      const section = {results: [], name: 'Your Turn'};
+      const section = {results: [], name: YOUR_TURN.name};
       assert.isTrue(element._isEmpty(section));
       assert.equal(element._getSpecialEmptySlot(section), 'empty-your-turn');
     });
@@ -416,11 +417,9 @@
       for (const column of element.changeTableColumns) {
         const elementClass = '.' + column.toLowerCase();
         if (column === 'Repo') {
-          assert.isTrue(element.shadowRoot
-              .querySelector(elementClass).hidden);
+          assert.isNotOk(element.shadowRoot.querySelector(elementClass));
         } else {
-          assert.isFalse(element.shadowRoot
-              .querySelector(elementClass).hidden);
+          assert.isOk(element.shadowRoot.querySelector(elementClass));
         }
       }
     });
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
index 27866ee..e53f68b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
@@ -26,6 +26,11 @@
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {RepoName, BranchName} from '../../../types/common';
 
+export interface CreateDestinationConfirmDetail {
+  repo?: RepoName;
+  branch?: BranchName;
+}
+
 /**
  * Fired when a destination has been picked. Event details contain the repo
  * name and the branch name.
@@ -70,7 +75,10 @@
 
   _pickerConfirm(e: Event) {
     this.$.createOverlay.close();
-    const detail = {repo: this._repo, branch: this._branch};
+    const detail: CreateDestinationConfirmDetail = {
+      repo: this._repo,
+      branch: this._branch,
+    };
     // e is a 'confirm' event from gr-dialog. We want to fire a more detailed
     // 'confirm' event here, so let's stop propagation of the bare event.
     e.preventDefault();
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
deleted file mode 100644
index 0658e16..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
+++ /dev/null
@@ -1,352 +0,0 @@
-/**
- * @license
- * 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.
- */
-import '../../../styles/shared-styles.js';
-import '../gr-change-list/gr-change-list.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-dialog/gr-dialog.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-create-commands-dialog/gr-create-commands-dialog.js';
-import '../gr-create-change-help/gr-create-change-help.js';
-import '../gr-create-destination-dialog/gr-create-destination-dialog.js';
-import '../gr-user-header/gr-user-header.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-dashboard-view_html.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {appContext} from '../../../services/app-context.js';
-import {changeIsOpen} from '../../../utils/change-util.js';
-
-const PROJECT_PLACEHOLDER_PATTERN = /\$\{project\}/g;
-
-/**
- * @extends PolymerElement
- */
-class GrDashboardView extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-dashboard-view'; }
-  /**
-   * Fired when the title of the page should change.
-   *
-   * @event title-change
-   */
-
-  static get properties() {
-    return {
-      account: {
-        type: Object,
-        value: null,
-      },
-      preferences: Object,
-      /** @type {{ selectedChangeIndex: number }} */
-      viewState: Object,
-
-      /** @type {{ project: string, user: string }} */
-      params: {
-        type: Object,
-      },
-
-      createChangeTap: {
-        type: Function,
-        value() {
-          return e => this._createChangeTap(e);
-        },
-      },
-
-      _results: Array,
-
-      /**
-       * For showing a "loading..." string during ajax requests.
-       */
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-
-      _showDraftsBanner: {
-        type: Boolean,
-        value: false,
-      },
-
-      _showNewUserHelp: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  constructor() {
-    super();
-    this.reporting = appContext.reportingService;
-  }
-
-  static get observers() {
-    return [
-      '_paramsChanged(params.*)',
-    ];
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._loadPreferences();
-    this.addEventListener('reload', e => {
-      e.stopPropagation();
-      this._reload();
-    });
-  }
-
-  _loadPreferences() {
-    return this.$.restAPI.getLoggedIn().then(loggedIn => {
-      if (loggedIn) {
-        this.$.restAPI.getPreferences().then(preferences => {
-          this.preferences = preferences;
-        });
-      } else {
-        this.preferences = {};
-      }
-    });
-  }
-
-  _getProjectDashboard(project, dashboard) {
-    const errFn = response => {
-      this.dispatchEvent(new CustomEvent('page-error', {
-        detail: {response},
-        composed: true, bubbles: true,
-      }));
-    };
-    return this.$.restAPI.getDashboard(
-        project, dashboard, errFn).then(response => {
-      if (!response) {
-        return;
-      }
-      return {
-        title: response.title,
-        sections: response.sections.map(section => {
-          const suffix = response.foreach ? ' ' + response.foreach : '';
-          return {
-            name: section.name,
-            query: (section.query + suffix).replace(
-                PROJECT_PLACEHOLDER_PATTERN, project),
-          };
-        }),
-      };
-    });
-  }
-
-  _computeTitle(user) {
-    if (!user || user === 'self') {
-      return 'My Reviews';
-    }
-    return 'Dashboard for ' + user;
-  }
-
-  _isViewActive(params) {
-    return params.view === GerritNav.View.DASHBOARD;
-  }
-
-  _paramsChanged(paramsChangeRecord) {
-    const params = paramsChangeRecord.base;
-
-    if (!this._isViewActive(params)) {
-      return Promise.resolve();
-    }
-
-    return this._reload();
-  }
-
-  /**
-   * Reloads the element.
-   *
-   * @return {Promise<!Object>}
-   */
-  _reload() {
-    this._loading = true;
-    const {project, dashboard, title, user, sections} = this.params;
-    const dashboardPromise = project ?
-      this._getProjectDashboard(project, dashboard) :
-      this.$.restAPI.getConfig().then(
-          config => Promise.resolve(GerritNav.getUserDashboard(
-              user,
-              sections,
-              title || this._computeTitle(user),
-              config
-          ))
-      );
-
-    const checkForNewUser = !project && user === 'self';
-    return dashboardPromise
-        .then(res => {
-          if (res && res.title) {
-            this.dispatchEvent(new CustomEvent('title-change', {
-              detail: {title: res.title},
-              composed: true, bubbles: true,
-            }));
-          }
-          return this._fetchDashboardChanges(res, checkForNewUser);
-        })
-        .then(() => {
-          this._maybeShowDraftsBanner();
-          this.reporting.dashboardDisplayed();
-        })
-        .catch(err => {
-          this.dispatchEvent(new CustomEvent('title-change', {
-            detail: {
-              title: title || this._computeTitle(user),
-            },
-            composed: true, bubbles: true,
-          }));
-          console.warn(err);
-        })
-        .then(() => { this._loading = false; });
-  }
-
-  /**
-   * Fetches the changes for each dashboard section and sets this._results
-   * with the response.
-   *
-   * @param {!Object} res
-   * @param {boolean} checkForNewUser
-   * @return {Promise}
-   */
-  _fetchDashboardChanges(res, checkForNewUser) {
-    if (!res) { return Promise.resolve(); }
-
-    let queries;
-
-    if (window.PRELOADED_QUERIES
-      && window.PRELOADED_QUERIES.dashboardQuery) {
-      queries = window.PRELOADED_QUERIES.dashboardQuery;
-      // we use preloaded query from index only on first page load
-      window.PRELOADED_QUERIES.dashboardQuery = undefined;
-    } else {
-      queries = res.sections
-          .map(section => (section.suffixForDashboard ?
-            section.query + ' ' + section.suffixForDashboard :
-            section.query));
-
-      if (checkForNewUser) {
-        queries.push('owner:self limit:1');
-      }
-    }
-
-    return this.$.restAPI.getChanges(null, queries)
-        .then(changes => {
-          if (checkForNewUser) {
-            // Last set of results is not meant for dashboard display.
-            const lastResultSet = changes.pop();
-            this._showNewUserHelp = lastResultSet.length == 0;
-          }
-          this._results = changes.map((results, i) => {
-            return {
-              name: res.sections[i].name,
-              countLabel: this._computeSectionCountLabel(results),
-              query: res.sections[i].query,
-              results,
-              isOutgoing: res.sections[i].isOutgoing,
-            };
-          }).filter((section, i) => i < res.sections.length && (
-            !res.sections[i].hideIfEmpty ||
-              section.results.length));
-        });
-  }
-
-  _computeSectionCountLabel(changes) {
-    if (!changes || !changes.length || changes.length == 0) {
-      return '';
-    }
-    const more = changes[changes.length - 1]._more_changes;
-    const numChanges = changes.length;
-    const andMore = more ? ' and more' : '';
-    return `(${numChanges}${andMore})`;
-  }
-
-  _computeUserHeaderClass(params) {
-    if (!params || !!params.project || !params.user ||
-        params.user === 'self') {
-      return 'hide';
-    }
-    return '';
-  }
-
-  _handleToggleStar(e) {
-    this.$.restAPI.saveChangeStarred(e.detail.change._number,
-        e.detail.starred);
-  }
-
-  _handleToggleReviewed(e) {
-    this.$.restAPI.saveChangeReviewed(e.detail.change._number,
-        e.detail.reviewed);
-  }
-
-  /**
-   * Banner is shown if a user is on their own dashboard and they have draft
-   * comments on closed changes.
-   */
-  _maybeShowDraftsBanner() {
-    this._showDraftsBanner = false;
-    if (!(this.params.user === 'self')) { return; }
-
-    const draftSection = this._results
-        .find(section => section.query === 'has:draft');
-    if (!draftSection || !draftSection.results.length) { return; }
-
-    const closedChanges = draftSection.results
-        .filter(change => !changeIsOpen(change));
-    if (!closedChanges.length) { return; }
-
-    this._showDraftsBanner = true;
-  }
-
-  _computeBannerClass(show) {
-    return show ? '' : 'hide';
-  }
-
-  _handleOpenDeleteDialog() {
-    this.$.confirmDeleteOverlay.open();
-  }
-
-  _handleConfirmDelete() {
-    this.$.confirmDeleteDialog.disabled = true;
-    return this.$.restAPI.deleteDraftComments('-is:open').then(() => {
-      this._closeConfirmDeleteOverlay();
-      this._reload();
-    });
-  }
-
-  _closeConfirmDeleteOverlay() {
-    this.$.confirmDeleteOverlay.close();
-  }
-
-  _computeDraftsLink() {
-    return GerritNav.getUrlForSearchQuery('has:draft -is:open');
-  }
-
-  _createChangeTap(e) {
-    this.$.destinationDialog.open();
-  }
-
-  _handleDestinationConfirm(e) {
-    this.$.commandsDialog.branch = e.detail.branch;
-    this.$.commandsDialog.open();
-  }
-}
-
-customElements.define(GrDashboardView.is, GrDashboardView);
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
new file mode 100644
index 0000000..ce3a811
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -0,0 +1,431 @@
+/**
+ * @license
+ * 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.
+ */
+import '../../../styles/shared-styles';
+import '../gr-change-list/gr-change-list';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-create-commands-dialog/gr-create-commands-dialog';
+import '../gr-create-change-help/gr-create-change-help';
+import '../gr-create-destination-dialog/gr-create-destination-dialog';
+import '../gr-user-header/gr-user-header';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-dashboard-view_html';
+import {
+  GerritNav,
+  GerritView,
+  UserDashboard,
+  YOUR_TURN,
+} from '../../core/gr-navigation/gr-navigation';
+import {appContext} from '../../../services/app-context';
+import {changeIsOpen} from '../../../utils/change-util';
+import {parseDate} from '../../../utils/date-util';
+import {customElement, observe, property} from '@polymer/decorators';
+import {
+  AccountDetailInfo,
+  ChangeInfo,
+  DashboardId,
+  ElementPropertyDeepChange,
+  PreferencesInput,
+  RepoName,
+} from '../../../types/common';
+import {AppElementDashboardParams, AppElementParams} from '../../gr-app-types';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {GrCreateCommandsDialog} from '../gr-create-commands-dialog/gr-create-commands-dialog';
+import {
+  CreateDestinationConfirmDetail,
+  GrCreateDestinationDialog,
+} from '../gr-create-destination-dialog/gr-create-destination-dialog';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {ChangeListToggleReviewedDetail} from '../gr-change-list-item/gr-change-list-item';
+import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
+import {DashboardViewState} from '../../../types/types';
+import {firePageError, fireTitleChange} from '../../../utils/event-util';
+
+const PROJECT_PLACEHOLDER_PATTERN = /\$\{project\}/g;
+
+export interface GrDashboardView {
+  $: {
+    restAPI: RestApiService & Element;
+    confirmDeleteDialog: GrDialog;
+    commandsDialog: GrCreateCommandsDialog;
+    destinationDialog: GrCreateDestinationDialog;
+    confirmDeleteOverlay: GrOverlay;
+  };
+}
+
+interface DashboardChange {
+  name: string;
+  countLabel: string;
+  query: string;
+  results: ChangeInfo[];
+  isOutgoing?: boolean;
+}
+
+@customElement('gr-dashboard-view')
+export class GrDashboardView extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the title of the page should change.
+   *
+   * @event title-change
+   */
+
+  @property({type: Object})
+  account: AccountDetailInfo | null = null;
+
+  @property({type: Object})
+  preferences?: PreferencesInput;
+
+  @property({type: Object})
+  viewState?: DashboardViewState;
+
+  @property({type: Object})
+  params?: AppElementParams;
+
+  @property({type: Array})
+  _results?: DashboardChange[];
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: Boolean})
+  _showDraftsBanner = false;
+
+  @property({type: Boolean})
+  _showNewUserHelp = false;
+
+  private reporting = appContext.reportingService;
+
+  constructor() {
+    super();
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadPreferences();
+    this.addEventListener('reload', e => {
+      e.stopPropagation();
+      this._reload();
+    });
+  }
+
+  _loadPreferences() {
+    return this.$.restAPI.getLoggedIn().then(loggedIn => {
+      if (loggedIn) {
+        this.$.restAPI.getPreferences().then(preferences => {
+          this.preferences = preferences;
+        });
+      } else {
+        this.preferences = {};
+      }
+    });
+  }
+
+  _getProjectDashboard(
+    project: RepoName,
+    dashboard: DashboardId
+  ): Promise<UserDashboard | undefined> {
+    const errFn = (response?: Response | null) => {
+      firePageError(this, response);
+    };
+    return this.$.restAPI
+      .getDashboard(project, dashboard, errFn)
+      .then(response => {
+        if (!response) {
+          return;
+        }
+        return {
+          title: response.title,
+          sections: response.sections.map(section => {
+            const suffix = response.foreach ? ' ' + response.foreach : '';
+            return {
+              name: section.name,
+              query: (section.query + suffix).replace(
+                PROJECT_PLACEHOLDER_PATTERN,
+                project
+              ),
+            };
+          }),
+        };
+      });
+  }
+
+  _computeTitle(user?: string) {
+    if (!user || user === 'self') {
+      return 'My Reviews';
+    }
+    return 'Dashboard for ' + user;
+  }
+
+  _isViewActive(params: AppElementParams): params is AppElementDashboardParams {
+    return params.view === GerritView.DASHBOARD;
+  }
+
+  @observe('params.*')
+  _paramsChanged(
+    paramsChangeRecord: ElementPropertyDeepChange<GrDashboardView, 'params'>
+  ) {
+    const params = paramsChangeRecord.base;
+
+    return this._reload(params);
+  }
+
+  /**
+   * Reloads the element.
+   */
+  _reload(params?: AppElementParams) {
+    if (!params || !this._isViewActive(params)) {
+      return Promise.resolve();
+    }
+    this._loading = true;
+    const {project, dashboard, title, user, sections} = params;
+    const dashboardPromise: Promise<UserDashboard | undefined> = project
+      ? this._getProjectDashboard(project, dashboard)
+      : this.$.restAPI
+          .getConfig()
+          .then(config =>
+            Promise.resolve(
+              GerritNav.getUserDashboard(
+                user,
+                sections,
+                title || this._computeTitle(user),
+                config
+              )
+            )
+          );
+
+    const checkForNewUser = !project && user === 'self';
+    return dashboardPromise
+      .then(res => {
+        if (res && res.title) {
+          fireTitleChange(this, res.title);
+        }
+        return this._fetchDashboardChanges(res, checkForNewUser);
+      })
+      .then(() => {
+        this._maybeShowDraftsBanner(params);
+        this.reporting.dashboardDisplayed();
+      })
+      .catch(err => {
+        fireTitleChange(this, title || this._computeTitle(user));
+        console.warn(err);
+      })
+      .then(() => {
+        this._loading = false;
+      });
+  }
+
+  /**
+   * Fetches the changes for each dashboard section and sets this._results
+   * with the response.
+   */
+  _fetchDashboardChanges(
+    res: UserDashboard | undefined,
+    checkForNewUser: boolean
+  ): Promise<void> {
+    if (!res) {
+      return Promise.resolve();
+    }
+
+    let queries: string[];
+
+    if (window.PRELOADED_QUERIES && window.PRELOADED_QUERIES.dashboardQuery) {
+      queries = window.PRELOADED_QUERIES.dashboardQuery;
+      // we use preloaded query from index only on first page load
+      window.PRELOADED_QUERIES.dashboardQuery = undefined;
+    } else {
+      queries = res.sections.map(section =>
+        section.suffixForDashboard
+          ? section.query + ' ' + section.suffixForDashboard
+          : section.query
+      );
+
+      if (checkForNewUser) {
+        queries.push('owner:self limit:1');
+      }
+    }
+
+    return this.$.restAPI.getChanges(undefined, queries).then(changes => {
+      if (!changes) {
+        throw new Error('getChanges returns undefined');
+      }
+      if (checkForNewUser) {
+        // Last set of results is not meant for dashboard display.
+        const lastResultSet = changes.pop();
+        this._showNewUserHelp = lastResultSet!.length === 0;
+      }
+      this._results = changes
+        .map((results, i) => {
+          return {
+            name: res.sections[i].name,
+            countLabel: this._computeSectionCountLabel(results),
+            query: res.sections[i].query,
+            results: this._maybeSortResults(res.sections[i].name, results),
+            isOutgoing: res.sections[i].isOutgoing,
+          };
+        })
+        .filter(
+          (section, i) =>
+            i < res.sections.length &&
+            (!res.sections[i].hideIfEmpty || section.results.length)
+        );
+    });
+  }
+
+  /**
+   * Usually we really want to stick to the sorting that the backend provides,
+   * but for the "Your Turn" section it is important to put the changes at the
+   * top where the current user is a reviewer. Owned changes are less important.
+   * And then we want to emphasize the changes where the waiting time is larger.
+   */
+  _maybeSortResults(name: string, results: ChangeInfo[]) {
+    const userId = this.account && this.account._account_id;
+    const sortedResults = [...results];
+    if (name === YOUR_TURN.name && userId) {
+      sortedResults.sort((c1, c2) => {
+        const c1Owner = c1.owner._account_id === userId;
+        const c2Owner = c2.owner._account_id === userId;
+        if (c1Owner !== c2Owner) return c1Owner ? 1 : -1;
+        // Should never happen, because the change is in the 'Your Turn'
+        // section, so the userId should be found in the attention set of both.
+        if (!c1.attention_set || !c1.attention_set[userId]) return 0;
+        if (!c2.attention_set || !c2.attention_set[userId]) return 0;
+        const c1Update = c1.attention_set[userId].last_update;
+        const c2Update = c2.attention_set[userId].last_update;
+        // Should never happen that an attention set entry has no update.
+        if (!c1Update || !c2Update) return c1Update ? 1 : -1;
+        return parseDate(c1Update).valueOf() - parseDate(c2Update).valueOf();
+      });
+    }
+    return sortedResults;
+  }
+
+  _computeSectionCountLabel(changes: ChangeInfo[]) {
+    if (!changes || !changes.length || changes.length === 0) {
+      return '';
+    }
+    const more = changes[changes.length - 1]._more_changes;
+    const numChanges = changes.length;
+    const andMore = more ? ' and more' : '';
+    return `(${numChanges}${andMore})`;
+  }
+
+  _computeUserHeaderClass(params: AppElementParams) {
+    if (
+      !params ||
+      params.view !== GerritView.DASHBOARD ||
+      !!params.project ||
+      !params.user ||
+      params.user === 'self'
+    ) {
+      return 'hide';
+    }
+    return '';
+  }
+
+  _handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
+    this.$.restAPI.saveChangeStarred(e.detail.change._number, e.detail.starred);
+  }
+
+  _handleToggleReviewed(e: CustomEvent<ChangeListToggleReviewedDetail>) {
+    this.$.restAPI.saveChangeReviewed(
+      e.detail.change._number,
+      e.detail.reviewed
+    );
+  }
+
+  /**
+   * Banner is shown if a user is on their own dashboard and they have draft
+   * comments on closed changes.
+   */
+  _maybeShowDraftsBanner(params: AppElementDashboardParams) {
+    this._showDraftsBanner = false;
+    if (!(params.user === 'self')) {
+      return;
+    }
+
+    if (!this._results) {
+      throw new Error('this._results must be set. restAPI returned undefined');
+    }
+
+    const draftSection = this._results.find(
+      section => section.query === 'has:draft'
+    );
+    if (!draftSection || !draftSection.results.length) {
+      return;
+    }
+
+    const closedChanges = draftSection.results.filter(
+      change => !changeIsOpen(change)
+    );
+    if (!closedChanges.length) {
+      return;
+    }
+
+    this._showDraftsBanner = true;
+  }
+
+  _computeBannerClass(show: boolean) {
+    return show ? '' : 'hide';
+  }
+
+  _handleOpenDeleteDialog() {
+    this.$.confirmDeleteOverlay.open();
+  }
+
+  _handleConfirmDelete() {
+    this.$.confirmDeleteDialog.disabled = true;
+    return this.$.restAPI.deleteDraftComments('-is:open').then(() => {
+      this._closeConfirmDeleteOverlay();
+      this._reload(this.params);
+    });
+  }
+
+  _closeConfirmDeleteOverlay() {
+    this.$.confirmDeleteOverlay.close();
+  }
+
+  _computeDraftsLink() {
+    return GerritNav.getUrlForSearchQuery('has:draft -is:open');
+  }
+
+  _handleCreateChangeTap() {
+    this.$.destinationDialog.open();
+  }
+
+  _handleDestinationConfirm(e: CustomEvent<CreateDestinationConfirmDetail>) {
+    this.$.commandsDialog.branch = e.detail.branch;
+    this.$.commandsDialog.open();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-dashboard-view': GrDashboardView;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
index f8a0167..6dae176 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
@@ -78,6 +78,7 @@
       user-id="[[params.user]]"
       class$="[[_computeUserHeaderClass(params)]]"
     ></gr-user-header>
+    <h1 class="assistive-tech-only">Dashboard</h1>
     <gr-change-list
       show-star=""
       show-reviewed-state=""
@@ -91,7 +92,7 @@
       <div id="emptyOutgoing" slot="empty-outgoing">
         <template is="dom-if" if="[[_showNewUserHelp]]">
           <gr-create-change-help
-            on-create-tap="createChangeTap"
+            on-create-tap="_handleCreateChangeTap"
           ></gr-create-change-help>
         </template>
         <template is="dom-if" if="[[!_showNewUserHelp]]">
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
index 063fbb2..44f203d 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
@@ -18,7 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-dashboard-view.js';
 import {isHidden} from '../../../test/test-utils.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation.js';
 import {changeIsOpen} from '../../../utils/change-util.js';
 import {ChangeStatus} from '../../../constants/constants.js';
 
@@ -54,32 +54,40 @@
   suite('drafts banner functionality', () => {
     suite('_maybeShowDraftsBanner', () => {
       test('not dashboard/self', () => {
-        element.params = {user: 'notself'};
-        element._maybeShowDraftsBanner();
+        element._maybeShowDraftsBanner({
+          view: GerritView.DASHBOARD,
+          user: 'notself',
+        });
         assert.isFalse(element._showDraftsBanner);
       });
 
       test('no drafts at all', () => {
-        element.params = {user: 'self'};
         element._results = [];
-        element._maybeShowDraftsBanner();
+        element._maybeShowDraftsBanner({
+          view: GerritView.DASHBOARD,
+          user: 'self',
+        });
         assert.isFalse(element._showDraftsBanner);
       });
 
       test('no drafts on open changes', () => {
-        element.params = {user: 'self'};
         const openChange = {status: ChangeStatus.NEW};
         element._results = [{query: 'has:draft', results: [openChange]}];
-        element._maybeShowDraftsBanner();
+        element._maybeShowDraftsBanner({
+          view: GerritView.DASHBOARD,
+          user: 'self',
+        });
         assert.isFalse(element._showDraftsBanner);
       });
 
       test('no drafts on not open changes', () => {
-        element.params = {user: 'self'};
         const notOpenChange = {status: '_'};
         element._results = [{query: 'has:draft', results: [notOpenChange]}];
         assert.isFalse(changeIsOpen(element._results[0].results[0]));
-        element._maybeShowDraftsBanner();
+        element._maybeShowDraftsBanner({
+          view: GerritView.DASHBOARD,
+          user: 'self',
+        });
         assert.isTrue(element._showDraftsBanner);
       });
     });
@@ -194,7 +202,8 @@
       };
       return paramsChangedPromise.then(() => {
         assert.isTrue(
-            getChangesStub.calledWith(null, ['1', '2', 'owner:self limit:1']));
+            getChangesStub.calledWith(undefined,
+                ['1', '2', 'owner:self limit:1']));
       });
     });
 
@@ -208,7 +217,7 @@
         user: 'user',
       };
       return paramsChangedPromise.then(() => {
-        assert.isTrue(getChangesStub.calledWith(null, ['1']));
+        assert.isTrue(getChangesStub.calledWith(undefined, ['1']));
       });
     });
   });
@@ -224,7 +233,7 @@
     return paramsChangedPromise.then(() => {
       assert.isTrue(getChangesStub.calledOnce);
       assert.deepEqual(
-          getChangesStub.firstCall.args, [null, ['1', '2 suffix']]);
+          getChangesStub.firstCall.args, [undefined, ['1', '2 suffix']]);
     });
   });
 
@@ -329,10 +338,23 @@
     assert.equal(element._computeUserHeaderClass(undefined), 'hide');
     assert.equal(element._computeUserHeaderClass({}), 'hide');
     assert.equal(element._computeUserHeaderClass({user: 'self'}), 'hide');
-    assert.equal(element._computeUserHeaderClass({user: 'user'}), '');
+    assert.equal(element._computeUserHeaderClass({user: 'user'}), 'hide');
+    assert.equal(
+        element._computeUserHeaderClass({
+          view: GerritView.DASHBOARD,
+          user: 'user',
+        }),
+        '');
     assert.equal(
         element._computeUserHeaderClass({project: 'p', user: 'user'}),
         'hide');
+    assert.equal(
+        element._computeUserHeaderClass({
+          view: GerritView.DASHBOARD,
+          project: 'p',
+          user: 'user',
+        }),
+        'hide');
   });
 
   test('404 page', done => {
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.ts b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.ts
index 9fd27c6..d1221d15 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.ts
@@ -26,8 +26,8 @@
   <div class="info">
     <h1 class="heading-1">
       [[repo]]
-      <hr />
     </h1>
+    <hr />
     <div><span>Detail:</span> <a href$="[[_repoUrl]]">Repo settings</a></div>
   </div>
   <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
index 25369b8..055c82c 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
@@ -30,6 +30,7 @@
 import {customElement, property} from '@polymer/decorators';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {AccountDetailInfo, AccountId} from '../../../types/common';
+import {getDisplayName} from '../../../utils/display-name-util';
 
 export interface GrUserHeader {
   $: {
@@ -80,6 +81,11 @@
     return accountDetails ? accountDetails[name] : '';
   }
 
+  _computeHeading(accountDetails: AccountDetailInfo | null) {
+    if (!accountDetails) return '';
+    return getDisplayName(undefined, accountDetails);
+  }
+
   _computeStatusClass(status: string) {
     return status ? '' : 'hide';
   }
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts
index 136835d..002a4ba 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts
@@ -34,7 +34,7 @@
   ></gr-avatar>
   <div class="info">
     <h1 class="heading-1">
-      [[_computeDetail(_accountDetails, 'name')]]
+      [[_computeHeading(_accountDetails)]]
     </h1>
     <hr />
     <div class$="status [[_computeStatusClass(_status)]]">
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
deleted file mode 100644
index fba4b53..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
+++ /dev/null
@@ -1,1724 +0,0 @@
-/**
- * @license
- * 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.
- */
-import '../../admin/gr-create-change-dialog/gr-create-change-dialog.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-dialog/gr-dialog.js';
-import '../../shared/gr-dropdown/gr-dropdown.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js';
-import '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js';
-import '../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js';
-import '../gr-confirm-move-dialog/gr-confirm-move-dialog.js';
-import '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js';
-import '../gr-confirm-revert-dialog/gr-confirm-revert-dialog.js';
-import '../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.js';
-import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog.js';
-import '../../../styles/shared-styles.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-change-actions_html.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {appContext} from '../../../services/app-context.js';
-import {
-  fetchChangeUpdates,
-  patchNumEquals,
-} from '../../../utils/patch-set-util.js';
-import {
-  changeIsOpen,
-  ListChangesOption,
-  listChangesOptionsToHex,
-} from '../../../utils/change-util.js';
-import {NotifyType} from '../../../constants/constants.js';
-import {TargetElement, EventType} from '../../plugins/gr-plugin-types.js';
-
-const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
-const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
-const ERR_REVISION_ACTIONS = 'Couldn’t load revision actions.';
-/**
- * @enum {string}
- */
-const LabelStatus = {
-  /**
-   * This label provides what is necessary for submission.
-   */
-  OK: 'OK',
-  /**
-   * This label prevents the change from being submitted.
-   */
-  REJECT: 'REJECT',
-  /**
-   * The label may be set, but it's neither necessary for submission
-   * nor does it block submission if set.
-   */
-  MAY: 'MAY',
-  /**
-   * The label is required for submission, but has not been satisfied.
-   */
-  NEED: 'NEED',
-  /**
-   * The label is required for submission, but is impossible to complete.
-   * The likely cause is access has not been granted correctly by the
-   * project owner or site administrator.
-   */
-  IMPOSSIBLE: 'IMPOSSIBLE',
-  OPTIONAL: 'OPTIONAL',
-};
-
-const ChangeActions = {
-  ABANDON: 'abandon',
-  DELETE: '/',
-  DELETE_EDIT: 'deleteEdit',
-  EDIT: 'edit',
-  FOLLOW_UP: 'followup',
-  IGNORE: 'ignore',
-  MOVE: 'move',
-  PRIVATE: 'private',
-  PRIVATE_DELETE: 'private.delete',
-  PUBLISH_EDIT: 'publishEdit',
-  REBASE: 'rebase',
-  REBASE_EDIT: 'rebaseEdit',
-  READY: 'ready',
-  RESTORE: 'restore',
-  REVERT: 'revert',
-  REVERT_SUBMISSION: 'revert_submission',
-  REVIEWED: 'reviewed',
-  STOP_EDIT: 'stopEdit',
-  SUBMIT: 'submit',
-  UNIGNORE: 'unignore',
-  UNREVIEWED: 'unreviewed',
-  WIP: 'wip',
-};
-
-const RevisionActions = {
-  CHERRYPICK: 'cherrypick',
-  REBASE: 'rebase',
-  SUBMIT: 'submit',
-  DOWNLOAD: 'download',
-};
-
-const ActionLoadingLabels = {
-  abandon: 'Abandoning...',
-  cherrypick: 'Cherry-picking...',
-  delete: 'Deleting...',
-  move: 'Moving..',
-  rebase: 'Rebasing...',
-  restore: 'Restoring...',
-  revert: 'Reverting...',
-  revert_submission: 'Reverting Submission...',
-  submit: 'Submitting...',
-};
-
-const ActionType = {
-  CHANGE: 'change',
-  REVISION: 'revision',
-};
-
-const ADDITIONAL_ACTION_KEY_PREFIX = '__additionalAction_';
-
-const QUICK_APPROVE_ACTION = {
-  __key: 'review',
-  __type: 'change',
-  enabled: true,
-  key: 'review',
-  label: 'Quick approve',
-  method: 'POST',
-};
-
-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',
-};
-
-const REBASE_EDIT = {
-  enabled: true,
-  label: 'Rebase edit',
-  title: 'Rebase change edit',
-  __key: 'rebaseEdit',
-  __primary: false,
-  __type: 'change',
-  method: 'POST',
-};
-
-const PUBLISH_EDIT = {
-  enabled: true,
-  label: 'Publish edit',
-  title: 'Publish change edit',
-  __key: 'publishEdit',
-  __primary: false,
-  __type: 'change',
-  method: 'POST',
-};
-
-const DELETE_EDIT = {
-  enabled: true,
-  label: 'Delete edit',
-  title: 'Delete change edit',
-  __key: 'deleteEdit',
-  __primary: false,
-  __type: 'change',
-  method: 'DELETE',
-};
-
-const EDIT = {
-  enabled: true,
-  label: 'Edit',
-  title: 'Edit this change',
-  __key: 'edit',
-  __primary: false,
-  __type: 'change',
-};
-
-const STOP_EDIT = {
-  enabled: true,
-  label: 'Stop editing',
-  title: 'Stop editing this change',
-  __key: 'stopEdit',
-  __primary: false,
-  __type: 'change',
-};
-
-// Set of keys that have icons. As more icons are added to gr-icons.html, this
-// set should be expanded.
-const ACTIONS_WITH_ICONS = new Set([
-  ChangeActions.ABANDON,
-  ChangeActions.DELETE_EDIT,
-  ChangeActions.EDIT,
-  ChangeActions.PUBLISH_EDIT,
-  ChangeActions.READY,
-  ChangeActions.REBASE_EDIT,
-  ChangeActions.RESTORE,
-  ChangeActions.REVERT,
-  ChangeActions.REVERT_SUBMISSION,
-  ChangeActions.STOP_EDIT,
-  QUICK_APPROVE_ACTION.key,
-  RevisionActions.REBASE,
-  RevisionActions.SUBMIT,
-]);
-
-const AWAIT_CHANGE_ATTEMPTS = 5;
-const AWAIT_CHANGE_TIMEOUT_MS = 1000;
-
-const REVERT_TYPES = {
-  REVERT_SINGLE_CHANGE: 1,
-  REVERT_SUBMISSION: 2,
-};
-
-/* Revert submission is skipped as the normal revert dialog will now show
-the user a choice between reverting single change or an entire submission.
-Hence, a second button is not needed.
-*/
-const SKIP_ACTION_KEYS = [ChangeActions.REVERT_SUBMISSION];
-
-const SKIP_ACTION_KEYS_ATTENTION_SET = [
-  ChangeActions.REVIEWED,
-  ChangeActions.UNREVIEWED,
-];
-
-/**
- * @extends PolymerElement
- */
-class GrChangeActions extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-change-actions'; }
-  /**
-   * Fired when the change should be reloaded.
-   *
-   * @event reload
-   */
-
-  /**
-   * Fired when an action is tapped.
-   *
-   * @event custom-tap - naming pattern: <action key>-tap
-   */
-
-  /**
-   * Fires to show an alert when a send is attempted on the non-latest patch.
-   *
-   * @event show-alert
-   */
-
-  /**
-   * Fires when a change action fails.
-   *
-   * @event show-error
-   */
-
-  constructor() {
-    super();
-    this.ActionType = ActionType;
-    this.ChangeActions = ChangeActions;
-    this.RevisionActions = RevisionActions;
-    this.reporting = appContext.reportingService;
-  }
-
-  static get properties() {
-    return {
-    /**
-     * @type {{
-     *    _number: number,
-     *    branch: string,
-     *    id: string,
-     *    project: string,
-     *    subject: string,
-     *  }}
-     */
-      change: Object,
-      actions: {
-        type: Object,
-        value() { return {}; },
-      },
-      primaryActionKeys: {
-        type: Array,
-        value() {
-          return [
-            ChangeActions.READY,
-            RevisionActions.SUBMIT,
-          ];
-        },
-      },
-      disableEdit: {
-        type: Boolean,
-        value: false,
-      },
-      _hasKnownChainState: {
-        type: Boolean,
-        value: false,
-      },
-      _hideQuickApproveAction: {
-        type: Boolean,
-        value: false,
-      },
-      changeNum: String,
-      changeStatus: String,
-      commitNum: String,
-      hasParent: {
-        type: Boolean,
-        observer: '_computeChainState',
-      },
-      latestPatchNum: String,
-      commitMessage: {
-        type: String,
-        value: '',
-      },
-      /** @type {?} */
-      revisionActions: {
-        type: Object,
-        notify: true,
-        value() { return {}; },
-      },
-      // If property binds directly to [[revisionActions.submit]] it is not
-      // updated when revisionActions doesn't contain submit action.
-      /** @type {?} */
-      _revisionSubmitAction: {
-        type: Object,
-        computed: '_getSubmitAction(revisionActions)',
-      },
-      // If property binds directly to [[revisionActions.rebase]] it is not
-      // updated when revisionActions doesn't contain rebase action.
-      /** @type {?} */
-      _revisionRebaseAction: {
-        type: Object,
-        computed: '_getRebaseAction(revisionActions)',
-      },
-      privateByDefault: String,
-
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _actionLoadingMessage: {
-        type: String,
-        value: '',
-      },
-      _allActionValues: {
-        type: Array,
-        computed: '_computeAllActions(actions.*, revisionActions.*,' +
-          'primaryActionKeys.*, _additionalActions.*, change, ' +
-          '_config, _actionPriorityOverrides.*)',
-      },
-      _topLevelActions: {
-        type: Array,
-        computed: '_computeTopLevelActions(_allActionValues.*, ' +
-          '_hiddenActions.*, _overflowActions.*)',
-        observer: '_filterPrimaryActions',
-      },
-      _topLevelPrimaryActions: Array,
-      _topLevelSecondaryActions: Array,
-      _menuActions: {
-        type: Array,
-        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.CHERRYPICK,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.MOVE,
-            },
-            {
-              type: ActionType.REVISION,
-              key: RevisionActions.DOWNLOAD,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.IGNORE,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.UNIGNORE,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.REVIEWED,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.UNREVIEWED,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.PRIVATE,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.PRIVATE_DELETE,
-            },
-            {
-              type: ActionType.CHANGE,
-              key: ChangeActions.FOLLOW_UP,
-            },
-          ];
-          return value;
-        },
-      },
-      _actionPriorityOverrides: {
-        type: Array,
-        value() { return []; },
-      },
-      _additionalActions: {
-        type: Array,
-        value() { return []; },
-      },
-      _hiddenActions: {
-        type: Array,
-        value() { return []; },
-      },
-      _disabledMenuActions: {
-        type: Array,
-        value() { return []; },
-      },
-      // editPatchsetLoaded == "does the current selected patch range have
-      // 'edit' as one of either basePatchNum or patchNum".
-      editPatchsetLoaded: {
-        type: Boolean,
-        value: false,
-      },
-      // editMode == "is edit mode enabled in the file list".
-      editMode: {
-        type: Boolean,
-        value: false,
-      },
-      editBasedOnCurrentPatchSet: {
-        type: Boolean,
-        value: true,
-      },
-      _config: Object,
-    };
-  }
-
-  static get observers() {
-    return [
-      '_actionsChanged(actions.*, revisionActions.*, _additionalActions.*)',
-      '_changeChanged(change)',
-      '_editStatusChanged(editMode, editPatchsetLoaded, ' +
-        'editBasedOnCurrentPatchSet, disableEdit, actions.*, change.*)',
-    ];
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('fullscreen-overlay-opened',
-        () => this._handleHideBackgroundContent());
-    this.addEventListener('fullscreen-overlay-closed',
-        () => this._handleShowBackgroundContent());
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this.$.jsAPI.addElement(TargetElement.CHANGE_ACTIONS, this);
-    this.$.restAPI.getConfig().then(config => {
-      this._config = config;
-    });
-    this._handleLoadingComplete();
-  }
-
-  _getSubmitAction(revisionActions) {
-    return this._getRevisionAction(revisionActions, 'submit', null);
-  }
-
-  _getRebaseAction(revisionActions) {
-    return this._getRevisionAction(revisionActions, 'rebase', null);
-  }
-
-  _getRevisionAction(revisionActions, actionName, emptyActionValue) {
-    if (!revisionActions) {
-      return undefined;
-    }
-    if (revisionActions[actionName] === undefined) {
-      // Return null to fire an event when reveisionActions was loaded
-      // but doesn't contain actionName. undefined doesn't fire an event
-      return emptyActionValue;
-    }
-    return revisionActions[actionName];
-  }
-
-  reload() {
-    if (!this.changeNum || !this.latestPatchNum) {
-      return Promise.resolve();
-    }
-
-    this._loading = true;
-    return this._getRevisionActions()
-        .then(revisionActions => {
-          if (!revisionActions) { return; }
-
-          this.revisionActions = revisionActions;
-          this._sendShowRevisionActions({
-            change: this.change,
-            revisionActions,
-          });
-          this._handleLoadingComplete();
-        })
-        .catch(err => {
-          this.dispatchEvent(new CustomEvent('show-alert', {
-            detail: {message: ERR_REVISION_ACTIONS},
-            composed: true, bubbles: true,
-          }));
-          this._loading = false;
-          throw err;
-        });
-  }
-
-  _handleLoadingComplete() {
-    getPluginLoader().awaitPluginsLoaded()
-        .then(() => this._loading = false);
-  }
-
-  _sendShowRevisionActions(detail) {
-    this.$.jsAPI.handleEvent(
-        EventType.SHOW_REVISION_ACTIONS,
-        detail
-    );
-  }
-
-  _changeChanged() {
-    this.reload();
-  }
-
-  addActionButton(type, label) {
-    if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
-      throw Error(`Invalid action type: ${type}`);
-    }
-    const action = {
-      enabled: true,
-      label,
-      __type: type,
-      __key: ADDITIONAL_ACTION_KEY_PREFIX +
-          Math.random().toString(36)
-              .substr(2),
-    };
-    this.push('_additionalActions', action);
-    return action.__key;
-  }
-
-  removeActionButton(key) {
-    const idx = this._indexOfActionButtonWithKey(key);
-    if (idx === -1) {
-      return;
-    }
-    this.splice('_additionalActions', idx, 1);
-  }
-
-  setActionButtonProp(key, prop, value) {
-    this.set([
-      '_additionalActions',
-      this._indexOfActionButtonWithKey(key),
-      prop,
-    ], value);
-  }
-
-  setActionOverflow(type, key, overflow) {
-    if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
-      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 => 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}`);
-    }
-
-    const idx = this._hiddenActions.indexOf(key);
-    if (hidden && idx === -1) {
-      this.push('_hiddenActions', key);
-    } else if (!hidden && idx !== -1) {
-      this.splice('_hiddenActions', idx, 1);
-    }
-  }
-
-  getActionDetails(action) {
-    if (this.revisionActions[action]) {
-      return this.revisionActions[action];
-    } else if (this.actions[action]) {
-      return this.actions[action];
-    }
-  }
-
-  _indexOfActionButtonWithKey(key) {
-    for (let i = 0; i < this._additionalActions.length; i++) {
-      if (this._additionalActions[i].__key === key) {
-        return i;
-      }
-    }
-    return -1;
-  }
-
-  _getRevisionActions() {
-    return this.$.restAPI.getChangeRevisionActions(this.changeNum,
-        this.latestPatchNum);
-  }
-
-  _shouldHideActions(actions, loading) {
-    return loading || !actions || !actions.base || !actions.base.length;
-  }
-
-  _keyCount(changeRecord) {
-    return Object.keys((changeRecord && changeRecord.base) || {}).length;
-  }
-
-  _actionsChanged(actionsChangeRecord, revisionActionsChangeRecord,
-      additionalActionsChangeRecord) {
-    // Polymer 2: check for undefined
-    if ([
-      actionsChangeRecord,
-      revisionActionsChangeRecord,
-      additionalActionsChangeRecord,
-    ].includes(undefined)) {
-      return;
-    }
-
-    const additionalActions = (additionalActionsChangeRecord &&
-        additionalActionsChangeRecord.base) || [];
-    this.hidden = this._keyCount(actionsChangeRecord) === 0 &&
-        this._keyCount(revisionActionsChangeRecord) === 0 &&
-            additionalActions.length === 0;
-    this._actionLoadingMessage = '';
-    this._disabledMenuActions = [];
-
-    const revisionActions = revisionActionsChangeRecord.base || {};
-    if (Object.keys(revisionActions).length !== 0) {
-      if (!revisionActions.download) {
-        this.set('revisionActions.download', DOWNLOAD_ACTION);
-      }
-    }
-  }
-
-  /**
-   * @param {string=} actionName
-   */
-  _deleteAndNotify(actionName) {
-    if (this.actions && this.actions[actionName]) {
-      delete this.actions[actionName];
-      // We assign a fake value of 'false' to support Polymer 2
-      // see https://github.com/Polymer/polymer/issues/2631
-      this.notifyPath('actions.' + actionName, false);
-    }
-  }
-
-  _editStatusChanged(editMode, editPatchsetLoaded,
-      editBasedOnCurrentPatchSet, disableEdit) {
-    // Polymer 2: check for undefined
-    if ([
-      editMode,
-      editBasedOnCurrentPatchSet,
-      disableEdit,
-    ].includes(undefined)) {
-      return;
-    }
-
-    if (disableEdit) {
-      this._deleteAndNotify('publishEdit');
-      this._deleteAndNotify('rebaseEdit');
-      this._deleteAndNotify('deleteEdit');
-      this._deleteAndNotify('stopEdit');
-      this._deleteAndNotify('edit');
-      return;
-    }
-    if (this.actions && editPatchsetLoaded) {
-      // Only show actions that mutate an edit if an actual edit patch set
-      // is loaded.
-      if (changeIsOpen(this.change)) {
-        if (editBasedOnCurrentPatchSet) {
-          if (!this.actions.publishEdit) {
-            this.set('actions.publishEdit', PUBLISH_EDIT);
-          }
-          this._deleteAndNotify('rebaseEdit');
-        } else {
-          if (!this.actions.rebaseEdit) {
-            this.set('actions.rebaseEdit', REBASE_EDIT);
-          }
-          this._deleteAndNotify('publishEdit');
-        }
-      }
-      if (!this.actions.deleteEdit) {
-        this.set('actions.deleteEdit', DELETE_EDIT);
-      }
-    } else {
-      this._deleteAndNotify('publishEdit');
-      this._deleteAndNotify('rebaseEdit');
-      this._deleteAndNotify('deleteEdit');
-    }
-
-    if (this.actions && changeIsOpen(this.change)) {
-      // Only show edit button if there is no edit patchset loaded and the
-      // file list is not in edit mode.
-      if (editPatchsetLoaded || editMode) {
-        this._deleteAndNotify('edit');
-      } else {
-        if (!this.actions.edit) { this.set('actions.edit', EDIT); }
-      }
-      // Only show STOP_EDIT if edit mode is enabled, but no edit patch set
-      // is loaded.
-      if (editMode && !editPatchsetLoaded) {
-        if (!this.actions.stopEdit) {
-          this.set('actions.stopEdit', STOP_EDIT);
-        }
-      } else {
-        this._deleteAndNotify('stopEdit');
-      }
-    } else {
-      // Remove edit button.
-      this._deleteAndNotify('edit');
-    }
-  }
-
-  _getValuesFor(obj) {
-    return Object.keys(obj).map(key => obj[key]);
-  }
-
-  _getLabelStatus(label) {
-    if (label.approved) {
-      return LabelStatus.OK;
-    } else if (label.rejected) {
-      return LabelStatus.REJECT;
-    } else if (label.optional) {
-      return LabelStatus.OPTIONAL;
-    } else {
-      return LabelStatus.NEED;
-    }
-  }
-
-  /**
-   * Get highest score for last missing permitted label for current change.
-   * Returns null if no labels permitted or more than one label missing.
-   *
-   * @return {{label: string, score: string}|null}
-   */
-  _getTopMissingApproval() {
-    if (!this.change ||
-        !this.change.labels ||
-        !this.change.permitted_labels) {
-      return null;
-    }
-    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;
-      }
-      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
-          // approve, return null;
-          return null;
-        }
-        result = label;
-      } else if (status === LabelStatus.REJECT ||
-          status === LabelStatus.IMPOSSIBLE) {
-        return null;
-      }
-    }
-    if (result) {
-      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,
-        };
-      }
-    }
-    return null;
-  }
-
-  hideQuickApproveAction() {
-    this._topLevelSecondaryActions =
-      this._topLevelSecondaryActions
-          .filter(sa => sa.key !== QUICK_APPROVE_ACTION.key);
-    this._hideQuickApproveAction = true;
-  }
-
-  _getQuickApproveAction() {
-    if (this._hideQuickApproveAction) {
-      return null;
-    }
-    const approval = this._getTopMissingApproval();
-    if (!approval) {
-      return null;
-    }
-    const action = {...QUICK_APPROVE_ACTION};
-    action.label = approval.label + approval.score;
-    const review = {
-      drafts: 'PUBLISH_ALL_REVISIONS',
-      labels: {},
-    };
-    review.labels[approval.label] = approval.score;
-    action.payload = review;
-    return action;
-  }
-
-  _getActionValues(actionsChangeRecord, primariesChangeRecord,
-      additionalActionsChangeRecord, type) {
-    if (!actionsChangeRecord || !primariesChangeRecord) { return []; }
-
-    const actions = actionsChangeRecord.base || {};
-    const primaryActionKeys = primariesChangeRecord.base || [];
-    const result = [];
-    const values = this._getValuesFor(
-        type === ActionType.CHANGE ? ChangeActions : RevisionActions);
-    const pluginActions = [];
-    Object.keys(actions).forEach(a => {
-      actions[a].__key = a;
-      actions[a].__type = type;
-      actions[a].__primary = primaryActionKeys.includes(a);
-      // Plugin actions always contain ~ in the key.
-      if (a.indexOf('~') !== -1) {
-        this._populateActionUrl(actions[a]);
-        pluginActions.push(actions[a]);
-        // Add server-side provided plugin actions to overflow menu.
-        this._overflowActions.push({
-          type,
-          key: a,
-        });
-        return;
-      } else if (!values.includes(a)) {
-        return;
-      }
-      actions[a].label = this._getActionLabel(actions[a]);
-
-      // Triggers a re-render by ensuring object inequality.
-      result.push({...actions[a]});
-    });
-
-    let additionalActions = (additionalActionsChangeRecord &&
-    additionalActionsChangeRecord.base) || [];
-    additionalActions = additionalActions
-        .filter(a => a.__type === type)
-        .map(a => {
-          a.__primary = primaryActionKeys.includes(a.__key);
-          // Triggers a re-render by ensuring object inequality.
-          return {...a};
-        });
-    return result.concat(additionalActions).concat(pluginActions);
-  }
-
-  _populateActionUrl(action) {
-    const patchNum =
-          action.__type === ActionType.REVISION ? this.latestPatchNum : null;
-    this.$.restAPI.getChangeActionURL(
-        this.changeNum, patchNum, '/' + action.__key)
-        .then(url => action.__url = url);
-  }
-
-  /**
-   * Given a change action, return a display label that uses the appropriate
-   * casing or includes explanatory details.
-   */
-  _getActionLabel(action) {
-    if (action.label === 'Delete') {
-      // This label is common within change and revision actions. Make it more
-      // explicit to the user.
-      return 'Delete change';
-    } else if (action.label === 'WIP') {
-      return 'Mark as work in progress';
-    }
-    // Otherwise, just map the name to sentence case.
-    return this._toSentenceCase(action.label);
-  }
-
-  /**
-   * Capitalize the first letter and lowecase all others.
-   *
-   * @param {string} s
-   * @return {string}
-   */
-  _toSentenceCase(s) {
-    if (!s.length) { return ''; }
-    return s[0].toUpperCase() + s.slice(1).toLowerCase();
-  }
-
-  _computeLoadingLabel(action) {
-    return ActionLoadingLabels[action] || 'Working...';
-  }
-
-  _canSubmitChange() {
-    return this.$.jsAPI.canSubmitChange(this.change,
-        this._getRevision(this.change, this.latestPatchNum));
-  }
-
-  _getRevision(change, patchNum) {
-    for (const rev of Object.values(change.revisions)) {
-      if (patchNumEquals(rev._number, patchNum)) {
-        return rev;
-      }
-    }
-    return null;
-  }
-
-  showRevertDialog() {
-    // The search is still broken if there is a " in the topic.
-    const query = `submissionid: "${this.change.submission_id}"`;
-    /* A chromium plugin expects that the modifyRevertMsg hook will only
-    be called after the revert button is pressed, hence we populate the
-    revert dialog after revert button is pressed. */
-    this.$.restAPI.getChanges('', query)
-        .then(changes => {
-          this.$.confirmRevertDialog.populate(this.change,
-              this.commitMessage, changes);
-          this._showActionDialog(this.$.confirmRevertDialog);
-        });
-  }
-
-  showRevertSubmissionDialog() {
-    const query = 'submissionid:' + this.change.submission_id;
-    this.$.restAPI.getChanges('', query)
-        .then(changes => {
-          this.$.confirmRevertSubmissionDialog.
-              _populateRevertSubmissionMessage(this.change, changes);
-          this._showActionDialog(this.$.confirmRevertSubmissionDialog);
-        });
-  }
-
-  _handleActionTap(e) {
-    e.preventDefault();
-    let el = dom(e).localTarget;
-    while (el.tagName.toLowerCase() !== 'gr-button') {
-      if (!el.parentElement) { return; }
-      el = el.parentElement;
-    }
-
-    const key = el.getAttribute('data-action-key');
-    if (key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
-        key.indexOf('~') !== -1) {
-      this.dispatchEvent(new CustomEvent(`${key}-tap`, {
-        detail: {node: el},
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    const type = el.getAttribute('data-action-type');
-    this._handleAction(type, key);
-  }
-
-  _handleOverflowItemTap(e) {
-    e.preventDefault();
-    const el = dom(e).localTarget;
-    const key = e.detail.action.__key;
-    if (key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
-        key.indexOf('~') !== -1) {
-      this.dispatchEvent(new CustomEvent(`${key}-tap`, {
-        detail: {node: el},
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    this._handleAction(e.detail.action.__type, e.detail.action.__key);
-  }
-
-  _handleAction(type, key) {
-    this.reporting.reportInteraction(`${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);
-    }
-  }
-
-  _handleChangeAction(key) {
-    let action;
-    switch (key) {
-      case ChangeActions.REVERT:
-        this.showRevertDialog();
-        break;
-      case ChangeActions.REVERT_SUBMISSION:
-        this.showRevertSubmissionDialog();
-        break;
-      case ChangeActions.ABANDON:
-        this._showActionDialog(this.$.confirmAbandonDialog);
-        break;
-      case QUICK_APPROVE_ACTION.key:
-        action = this._allActionValues.find(o => o.key === key);
-        this._fireAction(
-            this._prependSlash(key), action, true, action.payload);
-        break;
-      case ChangeActions.EDIT:
-        this._handleEditTap();
-        break;
-      case ChangeActions.STOP_EDIT:
-        this._handleStopEditTap();
-        break;
-      case ChangeActions.DELETE:
-        this._handleDeleteTap();
-        break;
-      case ChangeActions.DELETE_EDIT:
-        this._handleDeleteEditTap();
-        break;
-      case ChangeActions.FOLLOW_UP:
-        this._handleFollowUpTap();
-        break;
-      case ChangeActions.WIP:
-        this._handleWipTap();
-        break;
-      case ChangeActions.MOVE:
-        this._handleMoveTap();
-        break;
-      case ChangeActions.PUBLISH_EDIT:
-        this._handlePublishEditTap();
-        break;
-      case ChangeActions.REBASE_EDIT:
-        this._handleRebaseEditTap();
-        break;
-      default:
-        this._fireAction(this._prependSlash(key), this.actions[key], false);
-    }
-  }
-
-  _handleRevisionAction(key) {
-    switch (key) {
-      case RevisionActions.REBASE:
-        this._showActionDialog(this.$.confirmRebase);
-        this.$.confirmRebase.fetchRecentChanges();
-        break;
-      case RevisionActions.CHERRYPICK:
-        this._handleCherrypickTap();
-        break;
-      case RevisionActions.DOWNLOAD:
-        this._handleDownloadTap();
-        break;
-      case RevisionActions.SUBMIT:
-        if (!this._canSubmitChange()) { return; }
-        this._showActionDialog(this.$.confirmSubmitDialog);
-        break;
-      default:
-        this._fireAction(this._prependSlash(key),
-            this.revisionActions[key], true);
-    }
-  }
-
-  _prependSlash(key) {
-    return key === '/' ? key : `/${key}`;
-  }
-
-  /**
-   * _hasKnownChainState set to true true if hasParent is defined (can be
-   * either true or false). set to false otherwise.
-   */
-  _computeChainState(hasParent) {
-    this._hasKnownChainState = true;
-  }
-
-  _calculateDisabled(action, hasKnownChainState) {
-    if (action.__key === 'rebase') {
-      // Rebase button is only disabled when change has no parent(s).
-      return hasKnownChainState === false;
-    }
-    return !action.enabled;
-  }
-
-  _handleConfirmDialogCancel() {
-    this._hideAllDialogs();
-  }
-
-  _hideAllDialogs() {
-    const dialogEls =
-        this.root.querySelectorAll('.confirmDialog');
-    for (const dialogEl of dialogEls) { dialogEl.hidden = true; }
-    this.$.overlay.close();
-  }
-
-  _handleRebaseConfirm(e) {
-    const el = this.$.confirmRebase;
-    const payload = {base: e.detail.base};
-    this.$.overlay.close();
-    el.hidden = true;
-    this._fireAction('/rebase', this.revisionActions.rebase, true, payload);
-  }
-
-  _handleCherrypickConfirm() {
-    this._handleCherryPickRestApi(false);
-  }
-
-  _handleCherrypickConflictConfirm() {
-    this._handleCherryPickRestApi(true);
-  }
-
-  _handleCherryPickRestApi(conflicts) {
-    const el = this.$.confirmCherrypick;
-    if (!el.branch) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {message: ERR_BRANCH_EMPTY},
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    if (!el.message) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {message: ERR_COMMIT_EMPTY},
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    this.$.overlay.close();
-    el.hidden = true;
-    this._fireAction(
-        '/cherrypick',
-        this.revisionActions.cherrypick,
-        true,
-        {
-          destination: el.branch,
-          base: el.baseCommit ? el.baseCommit : null,
-          message: el.message,
-          allow_conflicts: conflicts,
-        }
-    );
-  }
-
-  _handleMoveConfirm() {
-    const el = this.$.confirmMove;
-    if (!el.branch) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {message: ERR_BRANCH_EMPTY},
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    this.$.overlay.close();
-    el.hidden = true;
-    this._fireAction(
-        '/move',
-        this.actions.move,
-        false,
-        {
-          destination_branch: el.branch,
-          message: el.message,
-        }
-    );
-  }
-
-  _handleRevertDialogConfirm(e) {
-    const revertType = e.detail.revertType;
-    const message = e.detail.message;
-    const el = this.$.confirmRevertDialog;
-    this.$.overlay.close();
-    el.hidden = true;
-    switch (revertType) {
-      case REVERT_TYPES.REVERT_SINGLE_CHANGE:
-        this._fireAction('/revert', this.actions.revert, false,
-            {message});
-        break;
-      case REVERT_TYPES.REVERT_SUBMISSION:
-        this._fireAction('/revert_submission', this.actions.revert_submission,
-            false, {message});
-        break;
-      default:
-        console.error('invalid revert type');
-    }
-  }
-
-  _handleRevertSubmissionDialogConfirm() {
-    const el = this.$.confirmRevertSubmissionDialog;
-    this.$.overlay.close();
-    el.hidden = true;
-    this._fireAction('/revert_submission', this.actions.revert_submission,
-        false, {message: el.message});
-  }
-
-  _handleAbandonDialogConfirm() {
-    const el = this.$.confirmAbandonDialog;
-    this.$.overlay.close();
-    el.hidden = true;
-    this._fireAction('/abandon', this.actions.abandon, false,
-        {message: el.message});
-  }
-
-  _handleCreateFollowUpChange() {
-    this.$.createFollowUpChange.handleCreateChange();
-    this._handleCloseCreateFollowUpChange();
-  }
-
-  _handleCloseCreateFollowUpChange() {
-    this.$.overlay.close();
-  }
-
-  _handleDeleteConfirm() {
-    this._fireAction('/', this.actions[ChangeActions.DELETE], false);
-  }
-
-  _handleDeleteEditConfirm() {
-    this._hideAllDialogs();
-
-    this._fireAction('/edit', this.actions.deleteEdit, false);
-  }
-
-  _handleSubmitConfirm() {
-    if (!this._canSubmitChange()) { return; }
-    this._hideAllDialogs();
-    this._fireAction('/submit', this.revisionActions.submit, true);
-  }
-
-  _getActionOverflowIndex(type, key) {
-    return this._overflowActions
-        .findIndex(action => action.type === type && action.key === key);
-  }
-
-  _setLoadingOnButtonWithKey(type, key) {
-    this._actionLoadingMessage = this._computeLoadingLabel(key);
-    let buttonKey = key;
-    // TODO(dhruvsri): clean this up later
-    // If key is revert-submission, then button key should be 'revert'
-    if (buttonKey === ChangeActions.REVERT_SUBMISSION) {
-      // Revert submission button no longer exists
-      buttonKey = ChangeActions.REVERT;
-    }
-
-    // If the action appears in the overflow menu.
-    if (this._getActionOverflowIndex(type, buttonKey) !== -1) {
-      this.push('_disabledMenuActions', buttonKey === '/' ? 'delete' :
-        buttonKey);
-      return () => {
-        this._actionLoadingMessage = '';
-        this._disabledMenuActions = [];
-      };
-    }
-
-    // Otherwise it's a top-level action.
-    const buttonEl = this.shadowRoot
-        .querySelector(`[data-action-key="${buttonKey}"]`);
-    buttonEl.setAttribute('loading', true);
-    buttonEl.disabled = true;
-    return () => {
-      this._actionLoadingMessage = '';
-      buttonEl.removeAttribute('loading');
-      buttonEl.disabled = false;
-    };
-  }
-
-  /**
-   * @param {string} endpoint
-   * @param {!Object|undefined} action
-   * @param {boolean} revAction
-   * @param {!Object|string=} opt_payload
-   */
-  _fireAction(endpoint, action, revAction, opt_payload) {
-    const cleanupFn =
-        this._setLoadingOnButtonWithKey(action.__type, action.__key);
-
-    this._send(action.method, opt_payload, endpoint, revAction, cleanupFn,
-        action).then(res => this._handleResponse(action, res));
-  }
-
-  _showActionDialog(dialog) {
-    this._hideAllDialogs();
-
-    dialog.hidden = false;
-    this.$.overlay.open().then(() => {
-      if (dialog.resetFocus) {
-        dialog.resetFocus();
-      }
-    });
-  }
-
-  // TODO(rmistry): Redo this after
-  // https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved.
-  _setLabelValuesOnRevert(newChangeId) {
-    const labels = this.$.jsAPI.getLabelValuesPostRevert(this.change);
-    if (!labels) { return Promise.resolve(); }
-    return this.$.restAPI.saveChangeReview(newChangeId, 'current', {labels});
-  }
-
-  _handleResponse(action, response) {
-    if (!response) { return; }
-    return this.$.restAPI.getResponseObject(response).then(obj => {
-      switch (action.__key) {
-        case ChangeActions.REVERT:
-          this._waitForChangeReachable(obj._number)
-              .then(() => this._setLabelValuesOnRevert(obj._number))
-              .then(() => {
-                GerritNav.navigateToChange(obj);
-              });
-          break;
-        case RevisionActions.CHERRYPICK:
-          this._waitForChangeReachable(obj._number).then(() => {
-            GerritNav.navigateToChange(obj);
-          });
-          break;
-        case ChangeActions.DELETE:
-          if (action.__type === ActionType.CHANGE) {
-            GerritNav.navigateToRelativeUrl(GerritNav.getUrlForRoot());
-          }
-          break;
-        case ChangeActions.WIP:
-        case ChangeActions.DELETE_EDIT:
-        case ChangeActions.PUBLISH_EDIT:
-        case ChangeActions.REBASE_EDIT:
-        case ChangeActions.REBASE:
-        case ChangeActions.SUBMIT:
-          this.dispatchEvent(new CustomEvent('reload',
-              {
-                detail: {clearPatchset: true},
-                bubbles: false,
-                composed: true,
-              }));
-          break;
-        case ChangeActions.REVERT_SUBMISSION:
-          if (!obj.revert_changes || !obj.revert_changes.length) return;
-          /* If there is only 1 change then gerrit will automatically
-             redirect to that change */
-          GerritNav.navigateToSearchQuery('topic: ' +
-              obj.revert_changes[0].topic);
-          break;
-        default:
-          this.dispatchEvent(new CustomEvent('reload',
-              {
-                detail: {action: action.__key, clearPatchset: true},
-                bubbles: false,
-                composed: true,
-              }));
-          break;
-      }
-    });
-  }
-
-  _handleShowRevertSubmissionChangesConfirm() {
-    this._hideAllDialogs();
-  }
-
-  _handleResponseError(action, response, body) {
-    if (action && action.__key === RevisionActions.CHERRYPICK) {
-      if (response && response.status === 409 &&
-          body && !body.allow_conflicts) {
-        return this._showActionDialog(
-            this.$.confirmCherrypickConflict);
-      }
-    }
-    return response.text().then(errText => {
-      this.dispatchEvent(new CustomEvent('show-error', {
-        detail: {message: `Could not perform action: ${errText}`},
-        composed: true, bubbles: true,
-      }));
-      if (!errText.startsWith('Change is already up to date')) {
-        throw Error(errText);
-      }
-    });
-  }
-
-  /**
-   * @param {string} method
-   * @param {string|!Object|undefined} payload
-   * @param {string} actionEndpoint
-   * @param {boolean} revisionAction
-   * @param {?Function} cleanupFn
-   * @param {!Object|undefined} action
-   */
-  _send(method, payload, actionEndpoint, revisionAction, cleanupFn, action) {
-    const handleError = response => {
-      cleanupFn.call(this);
-      this._handleResponseError(action, response, payload);
-    };
-    return fetchChangeUpdates(this.change, this.$.restAPI)
-        .then(result => {
-          if (!result.isLatest) {
-            this.dispatchEvent(new CustomEvent('show-alert', {
-              detail: {
-                message: 'Cannot set label: a newer patch has been ' +
-                  'uploaded to this change.',
-                action: 'Reload',
-                callback: () => {
-                  this.dispatchEvent(new CustomEvent('reload',
-                      {
-                        detail: {clearPatchset: true},
-                        bubbles: false,
-                        composed: true,
-                      }));
-                },
-              },
-              composed: true, bubbles: true,
-            }));
-
-            // Because this is not a network error, call the cleanup function
-            // but not the error handler.
-            cleanupFn();
-
-            return Promise.resolve();
-          }
-          const patchNum = revisionAction ? this.latestPatchNum : null;
-          return this.$.restAPI.executeChangeAction(this.changeNum, method,
-              actionEndpoint, patchNum, payload, handleError)
-              .then(response => {
-                cleanupFn.call(this);
-                return response;
-              });
-        });
-  }
-
-  _handleAbandonTap() {
-    this._showActionDialog(this.$.confirmAbandonDialog);
-  }
-
-  _handleCherrypickTap() {
-    this.$.confirmCherrypick.branch = '';
-    const query = `topic: "${this.change.topic}"`;
-    const options =
-      listChangesOptionsToHex(ListChangesOption.MESSAGES,
-          ListChangesOption.ALL_REVISIONS);
-    this.$.restAPI.getChanges('', query, undefined, options)
-        .then(changes => {
-          this.$.confirmCherrypick.updateChanges(changes);
-          this._showActionDialog(this.$.confirmCherrypick);
-        });
-  }
-
-  _handleMoveTap() {
-    this.$.confirmMove.branch = '';
-    this.$.confirmMove.message = '';
-    this._showActionDialog(this.$.confirmMove);
-  }
-
-  _handleDownloadTap() {
-    this.dispatchEvent(new CustomEvent('download-tap', {
-      composed: true, bubbles: false,
-    }));
-  }
-
-  _handleDeleteTap() {
-    this._showActionDialog(this.$.confirmDeleteDialog);
-  }
-
-  _handleDeleteEditTap() {
-    this._showActionDialog(this.$.confirmDeleteEditDialog);
-  }
-
-  _handleFollowUpTap() {
-    this._showActionDialog(this.$.createFollowUpDialog);
-  }
-
-  _handleWipTap() {
-    this._fireAction('/wip', this.actions.wip, false);
-  }
-
-  _handlePublishEditTap() {
-    // Type of payload is PublishChangeEditInput.
-    const payload = {notify: NotifyType.NONE};
-    this._fireAction('/edit:publish', this.actions.publishEdit, false, payload);
-  }
-
-  _handleRebaseEditTap() {
-    this._fireAction('/edit:rebase', this.actions.rebaseEdit, false);
-  }
-
-  _handleHideBackgroundContent() {
-    this.$.mainContent.classList.add('overlayOpen');
-  }
-
-  _handleShowBackgroundContent() {
-    this.$.mainContent.classList.remove('overlayOpen');
-  }
-
-  /**
-   * Merge sources of change actions into a single ordered array of action
-   * values.
-   *
-   * @param {!Array} changeActionsRecord
-   * @param {!Array} revisionActionsRecord
-   * @param {!Array} primariesRecord
-   * @param {!Array} additionalActionsRecord
-   * @param {!Object} change The change object.
-   * @param {!Object} config server configuration info
-   * @return {!Array}
-   */
-  _computeAllActions(changeActionsRecord, revisionActionsRecord,
-      primariesRecord, additionalActionsRecord, change, config) {
-    // Polymer 2: check for undefined
-    if ([
-      changeActionsRecord,
-      revisionActionsRecord,
-      primariesRecord,
-      additionalActionsRecord,
-      change,
-    ].includes(undefined)) {
-      return [];
-    }
-
-    const revisionActionValues = this._getActionValues(revisionActionsRecord,
-        primariesRecord, additionalActionsRecord, ActionType.REVISION);
-    const changeActionValues = this._getActionValues(changeActionsRecord,
-        primariesRecord, additionalActionsRecord, ActionType.CHANGE);
-    const quickApprove = this._getQuickApproveAction();
-    if (quickApprove) {
-      changeActionValues.unshift(quickApprove);
-    }
-
-    return revisionActionValues
-        .concat(changeActionValues)
-        .sort((a, b) => this._actionComparator(a, b))
-        .map(action => {
-          if (ACTIONS_WITH_ICONS.has(action.__key)) {
-            action.icon = action.__key;
-          }
-          // TODO(brohlfs): Temporary hack until change 269573 is live in all
-          // backends.
-          if (action.__key === ChangeActions.READY) {
-            action.label = 'Mark as Active';
-          }
-          // End of hack
-          return action;
-        })
-        .filter(action => !this._shouldSkipAction(action, config));
-  }
-
-  _getActionPriority(action) {
-    if (action.__type && action.__key) {
-      const overrideAction = this._actionPriorityOverrides
-          .find(i => 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(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;
-    }
-  }
-
-  _shouldSkipAction(action, config) {
-    const skipActionKeys = [...SKIP_ACTION_KEYS];
-    const isAttentionSetEnabled = !!config && !!config.change
-        && config.change.enable_attention_set;
-    if (isAttentionSetEnabled) {
-      skipActionKeys.push(...SKIP_ACTION_KEYS_ATTENTION_SET);
-    }
-    return skipActionKeys.includes(action.__key);
-  }
-
-  _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));
-    });
-  }
-
-  _filterPrimaryActions(_topLevelActions) {
-    this._topLevelPrimaryActions = _topLevelActions.filter(action =>
-      action.__primary);
-    this._topLevelSecondaryActions = _topLevelActions.filter(action =>
-      !action.__primary);
-  }
-
-  _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,
-        tooltip: action.title,
-      };
-    });
-  }
-
-  _computeRebaseOnCurrent(revisionRebaseAction) {
-    if (revisionRebaseAction) {
-      return !!revisionRebaseAction.enabled;
-    }
-    return null;
-  }
-
-  /**
-   * Occasionally, a change created by a change action is not yet knwon to the
-   * API for a brief time. Wait for the given change number to be recognized.
-   *
-   * Returns a promise that resolves with true if a request is recognized, or
-   * false if the change was never recognized after all attempts.
-   *
-   * @param  {number} changeNum
-   * @return {Promise<boolean>}
-   */
-  _waitForChangeReachable(changeNum) {
-    let attempsRemaining = AWAIT_CHANGE_ATTEMPTS;
-    return new Promise(resolve => {
-      const check = () => {
-        attempsRemaining--;
-        // Pass a no-op error handler to avoid the "not found" error toast.
-        this.$.restAPI.getChange(changeNum, () => {}).then(response => {
-          // If the response is 404, the response will be undefined.
-          if (response) {
-            resolve(true);
-            return;
-          }
-
-          if (attempsRemaining) {
-            this.async(check, AWAIT_CHANGE_TIMEOUT_MS);
-          } else {
-            resolve(false);
-          }
-        });
-      };
-      check();
-    });
-  }
-
-  _handleEditTap() {
-    this.dispatchEvent(new CustomEvent('edit-tap', {bubbles: false}));
-  }
-
-  _handleStopEditTap() {
-    this.dispatchEvent(new CustomEvent('stop-edit-tap', {bubbles: false}));
-  }
-
-  _computeHasTooltip(title) {
-    return !!title;
-  }
-
-  _computeHasIcon(action) {
-    return action.icon ? '' : 'hidden';
-  }
-}
-
-customElements.define(GrChangeActions.is, GrChangeActions);
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
new file mode 100644
index 0000000..51ce9b4
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -0,0 +1,2093 @@
+/**
+ * @license
+ * 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.
+ */
+import '../../admin/gr-create-change-dialog/gr-create-change-dialog';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-dropdown/gr-dropdown';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
+import '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog';
+import '../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog';
+import '../gr-confirm-move-dialog/gr-confirm-move-dialog';
+import '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog';
+import '../gr-confirm-revert-dialog/gr-confirm-revert-dialog';
+import '../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog';
+import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
+import '../../../styles/shared-styles';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-change-actions_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {appContext} from '../../../services/app-context';
+import {
+  fetchChangeUpdates,
+  patchNumEquals,
+} from '../../../utils/patch-set-util';
+import {
+  changeIsOpen,
+  ListChangesOption,
+  listChangesOptionsToHex,
+} from '../../../utils/change-util';
+import {
+  ChangeStatus,
+  DraftsAction,
+  HttpMethod,
+  NotifyType,
+} from '../../../constants/constants';
+import {
+  EventType as PluginEventType,
+  TargetElement,
+} from '../../plugins/gr-plugin-types';
+import {customElement, observe, property} from '@polymer/decorators';
+import {GrJsApiInterface} from '../../shared/gr-js-api-interface/gr-js-api-interface-element';
+import {
+  ActionPriority,
+  ActionType,
+  ErrorCallback,
+  RestApiService,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  ActionInfo,
+  ActionNameToActionInfoMap,
+  BranchName,
+  ChangeInfo,
+  ChangeViewChangeInfo,
+  CherryPickInput,
+  CommitId,
+  InheritedBooleanInfo,
+  isDetailedLabelInfo,
+  isQuickLabelInfo,
+  LabelInfo,
+  NumericChangeId,
+  PatchSetNum,
+  PropertyType,
+  RequestPayload,
+  RevertSubmissionInfo,
+  ReviewInput,
+  ServerInfo,
+} from '../../../types/common';
+import {GrConfirmAbandonDialog} from '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {GrCreateChangeDialog} from '../../admin/gr-create-change-dialog/gr-create-change-dialog';
+import {GrConfirmSubmitDialog} from '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
+import {GrConfirmRevertSubmissionDialog} from '../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog';
+import {
+  ConfirmRevertEventDetail,
+  GrConfirmRevertDialog,
+  RevertType,
+} from '../gr-confirm-revert-dialog/gr-confirm-revert-dialog';
+import {GrConfirmMoveDialog} from '../gr-confirm-move-dialog/gr-confirm-move-dialog';
+import {GrConfirmCherrypickDialog} from '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog';
+import {GrConfirmCherrypickConflictDialog} from '../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog';
+import {
+  ConfirmRebaseEventDetail,
+  GrConfirmRebaseDialog,
+} from '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {
+  ChangeActions,
+  GrChangeActionsElement,
+  PrimaryActionKey,
+  RevisionActions,
+  UIActionInfo,
+} from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
+import {fireAlert} from '../../../utils/event-util';
+import {CODE_REVIEW} from '../../../utils/label-util';
+
+const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
+const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
+const ERR_REVISION_ACTIONS = 'Couldn’t load revision actions.';
+
+enum LabelStatus {
+  /**
+   * This label provides what is necessary for submission.
+   */
+  OK = 'OK',
+  /**
+   * This label prevents the change from being submitted.
+   */
+  REJECT = 'REJECT',
+  /**
+   * The label may be set, but it's neither necessary for submission
+   * nor does it block submission if set.
+   */
+  MAY = 'MAY',
+  /**
+   * The label is required for submission, but has not been satisfied.
+   */
+  NEED = 'NEED',
+  /**
+   * The label is required for submission, but is impossible to complete.
+   * The likely cause is access has not been granted correctly by the
+   * project owner or site administrator.
+   */
+  IMPOSSIBLE = 'IMPOSSIBLE',
+  OPTIONAL = 'OPTIONAL',
+}
+
+const ActionLoadingLabels: {[actionKey: string]: string} = {
+  abandon: 'Abandoning...',
+  cherrypick: 'Cherry-picking...',
+  delete: 'Deleting...',
+  move: 'Moving..',
+  rebase: 'Rebasing...',
+  restore: 'Restoring...',
+  revert: 'Reverting...',
+  revert_submission: 'Reverting Submission...',
+  submit: 'Submitting...',
+};
+
+const ADDITIONAL_ACTION_KEY_PREFIX = '__additionalAction_';
+
+interface QuickApproveUIActionInfo extends UIActionInfo {
+  key: string;
+  payload?: RequestPayload;
+}
+
+const QUICK_APPROVE_ACTION: QuickApproveUIActionInfo = {
+  __key: 'review',
+  __type: ActionType.CHANGE,
+  enabled: true,
+  key: 'review',
+  label: 'Quick approve',
+  method: HttpMethod.POST,
+};
+
+function isQuckApproveAction(
+  action: UIActionInfo
+): action is QuickApproveUIActionInfo {
+  return (action as QuickApproveUIActionInfo).key === QUICK_APPROVE_ACTION.key;
+}
+
+const DOWNLOAD_ACTION: UIActionInfo = {
+  enabled: true,
+  label: 'Download patch',
+  title: 'Open download dialog',
+  __key: 'download',
+  __primary: false,
+  __type: ActionType.REVISION,
+};
+
+const REBASE_EDIT: UIActionInfo = {
+  enabled: true,
+  label: 'Rebase edit',
+  title: 'Rebase change edit',
+  __key: 'rebaseEdit',
+  __primary: false,
+  __type: ActionType.CHANGE,
+  method: HttpMethod.POST,
+};
+
+const PUBLISH_EDIT: UIActionInfo = {
+  enabled: true,
+  label: 'Publish edit',
+  title: 'Publish change edit',
+  __key: 'publishEdit',
+  __primary: false,
+  __type: ActionType.CHANGE,
+  method: HttpMethod.POST,
+};
+
+const DELETE_EDIT: UIActionInfo = {
+  enabled: true,
+  label: 'Delete edit',
+  title: 'Delete change edit',
+  __key: 'deleteEdit',
+  __primary: false,
+  __type: ActionType.CHANGE,
+  method: HttpMethod.DELETE,
+};
+
+const EDIT: UIActionInfo = {
+  enabled: true,
+  label: 'Edit',
+  title: 'Edit this change',
+  __key: 'edit',
+  __primary: false,
+  __type: ActionType.CHANGE,
+};
+
+const STOP_EDIT: UIActionInfo = {
+  enabled: true,
+  label: 'Stop editing',
+  title: 'Stop editing this change',
+  __key: 'stopEdit',
+  __primary: false,
+  __type: ActionType.CHANGE,
+};
+
+// Set of keys that have icons. As more icons are added to gr-icons.html, this
+// set should be expanded.
+const ACTIONS_WITH_ICONS = new Set([
+  ChangeActions.ABANDON,
+  ChangeActions.DELETE_EDIT,
+  ChangeActions.EDIT,
+  ChangeActions.PUBLISH_EDIT,
+  ChangeActions.READY,
+  ChangeActions.REBASE_EDIT,
+  ChangeActions.RESTORE,
+  ChangeActions.REVERT,
+  ChangeActions.REVERT_SUBMISSION,
+  ChangeActions.STOP_EDIT,
+  QUICK_APPROVE_ACTION.key,
+  RevisionActions.REBASE,
+  RevisionActions.SUBMIT,
+]);
+
+const EDIT_ACTIONS: Set<string> = new Set([
+  ChangeActions.DELETE_EDIT,
+  ChangeActions.EDIT,
+  ChangeActions.PUBLISH_EDIT,
+  ChangeActions.REBASE_EDIT,
+  ChangeActions.STOP_EDIT,
+]);
+
+const AWAIT_CHANGE_ATTEMPTS = 5;
+const AWAIT_CHANGE_TIMEOUT_MS = 1000;
+
+/* Revert submission is skipped as the normal revert dialog will now show
+the user a choice between reverting single change or an entire submission.
+Hence, a second button is not needed.
+*/
+const SKIP_ACTION_KEYS = [ChangeActions.REVERT_SUBMISSION];
+
+const SKIP_ACTION_KEYS_ATTENTION_SET = [
+  ChangeActions.REVIEWED,
+  ChangeActions.UNREVIEWED,
+];
+
+function assertUIActionInfo(action?: ActionInfo): UIActionInfo {
+  // TODO(TS): Remove this function. The gr-change-actions adds properties
+  // to existing ActionInfo objects instead of creating a new objects. This
+  // function checks, that 'action' has all property required by UIActionInfo.
+  // In the future, we should avoid updates of an existing ActionInfos and
+  // instead create a new object to make code cleaner. However, at the current
+  // state this is unsafe, because other code can expect these properties to be
+  // set in ActionInfo.
+  if (!action) {
+    throw new Error('action is undefined');
+  }
+  const result = action as UIActionInfo;
+  if (result.__key === undefined || result.__type === undefined) {
+    throw new Error('action is not an UIActionInfo');
+  }
+  return result;
+}
+
+interface MenuAction {
+  name: string;
+  id: string;
+  action: UIActionInfo;
+  tooltip?: string;
+}
+
+interface OverflowAction {
+  type: ActionType;
+  key: string;
+  overflow?: boolean;
+}
+
+interface ActionPriorityOverride {
+  type: ActionType.CHANGE | ActionType.REVISION;
+  key: string;
+  priority: ActionPriority;
+}
+
+interface ChangeActionDialog extends HTMLElement {
+  resetFocus?(): void;
+}
+
+export interface GrChangeActions {
+  $: {
+    jsAPI: GrJsApiInterface;
+    restAPI: RestApiService & Element;
+    mainContent: Element;
+    overlay: GrOverlay;
+    confirmRebase: GrConfirmRebaseDialog;
+    confirmCherrypick: GrConfirmCherrypickDialog;
+    confirmCherrypickConflict: GrConfirmCherrypickConflictDialog;
+    confirmMove: GrConfirmMoveDialog;
+    confirmRevertDialog: GrConfirmRevertDialog;
+    confirmRevertSubmissionDialog: GrConfirmRevertSubmissionDialog;
+    confirmAbandonDialog: GrConfirmAbandonDialog;
+    confirmSubmitDialog: GrConfirmSubmitDialog;
+    createFollowUpDialog: GrDialog;
+    createFollowUpChange: GrCreateChangeDialog;
+    confirmDeleteDialog: GrDialog;
+    confirmDeleteEditDialog: GrDialog;
+  };
+}
+
+@customElement('gr-change-actions')
+export class GrChangeActions
+  extends GestureEventListeners(LegacyElementMixin(PolymerElement))
+  implements GrChangeActionsElement {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the change should be reloaded.
+   *
+   * @event reload
+   */
+
+  /**
+   * Fired when an action is tapped.
+   *
+   * @event custom-tap - naming pattern: <action key>-tap
+   */
+
+  /**
+   * Fires to show an alert when a send is attempted on the non-latest patch.
+   *
+   * @event show-alert
+   */
+
+  /**
+   * Fires when a change action fails.
+   *
+   * @event show-error
+   */
+
+  // TODO(TS): Ensure that ActionType, ChangeActions and RevisionActions
+  // properties are replaced with enums everywhere and remove them from
+  // the GrChangeActions class
+  ActionType = ActionType;
+
+  ChangeActions = ChangeActions;
+
+  RevisionActions = RevisionActions;
+
+  reporting = appContext.reportingService;
+
+  @property({type: Object})
+  change?: ChangeViewChangeInfo;
+
+  @property({type: Object})
+  actions: ActionNameToActionInfoMap = {};
+
+  @property({type: Array})
+  primaryActionKeys: PrimaryActionKey[] = [
+    ChangeActions.READY,
+    RevisionActions.SUBMIT,
+  ];
+
+  @property({type: Boolean})
+  disableEdit = false;
+
+  @property({type: Boolean})
+  _hasKnownChainState = false;
+
+  @property({type: Boolean})
+  _hideQuickApproveAction = false;
+
+  @property({type: String})
+  changeNum?: NumericChangeId;
+
+  @property({type: String})
+  changeStatus?: ChangeStatus;
+
+  @property({type: String})
+  commitNum?: CommitId;
+
+  @property({type: Boolean, observer: '_computeChainState'})
+  hasParent?: boolean;
+
+  @property({type: String})
+  latestPatchNum?: PatchSetNum;
+
+  @property({type: String})
+  commitMessage = '';
+
+  @property({type: Object, notify: true})
+  revisionActions: ActionNameToActionInfoMap = {};
+
+  @property({type: Object, computed: '_getSubmitAction(revisionActions)'})
+  _revisionSubmitAction?: ActionInfo | null;
+
+  @property({type: Object, computed: '_getRebaseAction(revisionActions)'})
+  _revisionRebaseAction?: ActionInfo | null;
+
+  @property({type: String})
+  privateByDefault?: InheritedBooleanInfo;
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: String})
+  _actionLoadingMessage = '';
+
+  @property({
+    type: Array,
+    computed:
+      '_computeAllActions(actions.*, revisionActions.*,' +
+      'primaryActionKeys.*, _additionalActions.*, change, ' +
+      '_config, _actionPriorityOverrides.*)',
+  })
+  _allActionValues: UIActionInfo[] = []; // _computeAllActions always returns an array
+
+  @property({
+    type: Array,
+    computed:
+      '_computeTopLevelActions(_allActionValues.*, ' +
+      '_hiddenActions.*, editMode, _overflowActions.*)',
+    observer: '_filterPrimaryActions',
+  })
+  _topLevelActions?: UIActionInfo[];
+
+  @property({type: Array})
+  _topLevelPrimaryActions?: UIActionInfo[];
+
+  @property({type: Array})
+  _topLevelSecondaryActions?: UIActionInfo[];
+
+  @property({
+    type: Array,
+    computed:
+      '_computeMenuActions(_allActionValues.*, ' +
+      '_hiddenActions.*, _overflowActions.*)',
+  })
+  _menuActions?: MenuAction[];
+
+  @property({type: Array})
+  _overflowActions: OverflowAction[] = [
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.WIP,
+    },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.DELETE,
+    },
+    {
+      type: ActionType.REVISION,
+      key: RevisionActions.CHERRYPICK,
+    },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.MOVE,
+    },
+    {
+      type: ActionType.REVISION,
+      key: RevisionActions.DOWNLOAD,
+    },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.IGNORE,
+    },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.UNIGNORE,
+    },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.REVIEWED,
+    },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.UNREVIEWED,
+    },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.PRIVATE,
+    },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.PRIVATE_DELETE,
+    },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.FOLLOW_UP,
+    },
+  ];
+
+  @property({type: Array})
+  _actionPriorityOverrides: ActionPriorityOverride[] = [];
+
+  @property({type: Array})
+  _additionalActions: UIActionInfo[] = [];
+
+  @property({type: Array})
+  _hiddenActions: string[] = [];
+
+  @property({type: Array})
+  _disabledMenuActions: string[] = [];
+
+  @property({type: Boolean})
+  editPatchsetLoaded = false;
+
+  @property({type: Boolean})
+  editMode = false;
+
+  @property({type: Boolean})
+  editBasedOnCurrentPatchSet = true;
+
+  @property({type: Object})
+  _config?: ServerInfo;
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('fullscreen-overlay-opened', () =>
+      this._handleHideBackgroundContent()
+    );
+    this.addEventListener('fullscreen-overlay-closed', () =>
+      this._handleShowBackgroundContent()
+    );
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    this.$.jsAPI.addElement(TargetElement.CHANGE_ACTIONS, this);
+    this.$.restAPI.getConfig().then(config => {
+      this._config = config;
+    });
+    this._handleLoadingComplete();
+  }
+
+  _getSubmitAction(revisionActions: ActionNameToActionInfoMap) {
+    return this._getRevisionAction(revisionActions, 'submit');
+  }
+
+  _getRebaseAction(revisionActions: ActionNameToActionInfoMap) {
+    return this._getRevisionAction(revisionActions, 'rebase');
+  }
+
+  _getRevisionAction(
+    revisionActions: ActionNameToActionInfoMap,
+    actionName: string
+  ) {
+    if (!revisionActions) {
+      return undefined;
+    }
+    if (revisionActions[actionName] === undefined) {
+      // Return null to fire an event when reveisionActions was loaded
+      // but doesn't contain actionName. undefined doesn't fire an event
+      return null;
+    }
+    return revisionActions[actionName];
+  }
+
+  reload() {
+    if (!this.changeNum || !this.latestPatchNum || !this.change) {
+      return Promise.resolve();
+    }
+    const change = this.change;
+
+    this._loading = true;
+    return this.$.restAPI
+      .getChangeRevisionActions(this.changeNum, this.latestPatchNum)
+      .then(revisionActions => {
+        if (!revisionActions) {
+          return;
+        }
+
+        this.revisionActions = revisionActions;
+        this._sendShowRevisionActions({
+          change,
+          revisionActions,
+        });
+        this._handleLoadingComplete();
+      })
+      .catch(err => {
+        fireAlert(this, ERR_REVISION_ACTIONS);
+        this._loading = false;
+        throw err;
+      });
+  }
+
+  _handleLoadingComplete() {
+    getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => (this._loading = false));
+  }
+
+  _sendShowRevisionActions(detail: {
+    change: ChangeInfo;
+    revisionActions: ActionNameToActionInfoMap;
+  }) {
+    this.$.jsAPI.handleEvent(PluginEventType.SHOW_REVISION_ACTIONS, detail);
+  }
+
+  @observe('change')
+  _changeChanged() {
+    this.reload();
+  }
+
+  addActionButton(type: ActionType, label: string) {
+    if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+      throw Error(`Invalid action type: ${type}`);
+    }
+    const action: UIActionInfo = {
+      enabled: true,
+      label,
+      __type: type,
+      __key:
+        ADDITIONAL_ACTION_KEY_PREFIX + Math.random().toString(36).substr(2),
+    };
+    this.push('_additionalActions', action);
+    return action.__key;
+  }
+
+  removeActionButton(key: string) {
+    const idx = this._indexOfActionButtonWithKey(key);
+    if (idx === -1) {
+      return;
+    }
+    this.splice('_additionalActions', idx, 1);
+  }
+
+  setActionButtonProp<T extends keyof UIActionInfo>(
+    key: string,
+    prop: T,
+    value: UIActionInfo[T]
+  ) {
+    this.set(
+      ['_additionalActions', this._indexOfActionButtonWithKey(key), prop],
+      value
+    );
+  }
+
+  setActionOverflow(type: ActionType, key: string, overflow: boolean) {
+    if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+      throw Error(`Invalid action type given: ${type}`);
+    }
+    const index = this._getActionOverflowIndex(type, key);
+    const action: OverflowAction = {
+      type,
+      key,
+      overflow,
+    };
+    if (!overflow && index !== -1) {
+      this.splice('_overflowActions', index, 1);
+    } else if (overflow) {
+      this.push('_overflowActions', action);
+    }
+  }
+
+  setActionPriority(
+    type: ActionType.CHANGE | ActionType.REVISION,
+    key: string,
+    priority: ActionPriority
+  ) {
+    if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+      throw Error(`Invalid action type given: ${type}`);
+    }
+    const index = this._actionPriorityOverrides.findIndex(
+      action => action.type === type && action.key === key
+    );
+    const action: ActionPriorityOverride = {
+      type,
+      key,
+      priority,
+    };
+    if (index !== -1) {
+      this.set('_actionPriorityOverrides', index, action);
+    } else {
+      this.push('_actionPriorityOverrides', action);
+    }
+  }
+
+  setActionHidden(
+    type: ActionType.CHANGE | ActionType.REVISION,
+    key: string,
+    hidden: boolean
+  ) {
+    if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+      throw Error(`Invalid action type given: ${type}`);
+    }
+
+    const idx = this._hiddenActions.indexOf(key);
+    if (hidden && idx === -1) {
+      this.push('_hiddenActions', key);
+    } else if (!hidden && idx !== -1) {
+      this.splice('_hiddenActions', idx, 1);
+    }
+  }
+
+  getActionDetails(actionName: string) {
+    if (this.revisionActions[actionName]) {
+      return this.revisionActions[actionName];
+    } else if (this.actions[actionName]) {
+      return this.actions[actionName];
+    } else {
+      return undefined;
+    }
+  }
+
+  _indexOfActionButtonWithKey(key: string) {
+    for (let i = 0; i < this._additionalActions.length; i++) {
+      if (this._additionalActions[i].__key === key) {
+        return i;
+      }
+    }
+    return -1;
+  }
+
+  _shouldHideActions(
+    actions?: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
+    loading?: boolean
+  ) {
+    return loading || !actions || !actions.base || !actions.base.length;
+  }
+
+  _keyCount(
+    changeRecord?: PolymerDeepPropertyChange<
+      ActionNameToActionInfoMap,
+      ActionNameToActionInfoMap
+    >
+  ) {
+    return Object.keys(changeRecord?.base || {}).length;
+  }
+
+  @observe('actions.*', 'revisionActions.*', '_additionalActions.*')
+  _actionsChanged(
+    actionsChangeRecord?: PolymerDeepPropertyChange<
+      ActionNameToActionInfoMap,
+      ActionNameToActionInfoMap
+    >,
+    revisionActionsChangeRecord?: PolymerDeepPropertyChange<
+      ActionNameToActionInfoMap,
+      ActionNameToActionInfoMap
+    >,
+    additionalActionsChangeRecord?: PolymerDeepPropertyChange<
+      UIActionInfo[],
+      UIActionInfo[]
+    >
+  ) {
+    // Polymer 2: check for undefined
+    if (
+      actionsChangeRecord === undefined ||
+      revisionActionsChangeRecord === undefined ||
+      additionalActionsChangeRecord === undefined
+    ) {
+      return;
+    }
+
+    const additionalActions =
+      (additionalActionsChangeRecord && additionalActionsChangeRecord.base) ||
+      [];
+    this.hidden =
+      this._keyCount(actionsChangeRecord) === 0 &&
+      this._keyCount(revisionActionsChangeRecord) === 0 &&
+      additionalActions.length === 0;
+    this._actionLoadingMessage = '';
+    this._actionLoadingMessage = '';
+    this._disabledMenuActions = [];
+
+    const revisionActions = revisionActionsChangeRecord.base || {};
+    if (Object.keys(revisionActions).length !== 0) {
+      if (!revisionActions.download) {
+        this.set('revisionActions.download', DOWNLOAD_ACTION);
+      }
+    }
+  }
+
+  _deleteAndNotify(actionName: string) {
+    if (this.actions && this.actions[actionName]) {
+      delete this.actions[actionName];
+      // We assign a fake value of 'false' to support Polymer 2
+      // see https://github.com/Polymer/polymer/issues/2631
+      this.notifyPath('actions.' + actionName, false);
+    }
+  }
+
+  @observe(
+    'editMode',
+    'editPatchsetLoaded',
+    'editBasedOnCurrentPatchSet',
+    'disableEdit',
+    'actions.*',
+    'change.*'
+  )
+  _editStatusChanged(
+    editMode: boolean,
+    editPatchsetLoaded: boolean,
+    editBasedOnCurrentPatchSet: boolean,
+    disableEdit: boolean,
+    actionsChangeRecord?: PolymerDeepPropertyChange<
+      ActionNameToActionInfoMap,
+      ActionNameToActionInfoMap
+    >,
+    changeChangeRecord?: PolymerDeepPropertyChange<ChangeInfo, ChangeInfo>
+  ) {
+    if (actionsChangeRecord === undefined || changeChangeRecord === undefined) {
+      return;
+    }
+    if (disableEdit) {
+      this._deleteAndNotify('publishEdit');
+      this._deleteAndNotify('rebaseEdit');
+      this._deleteAndNotify('deleteEdit');
+      this._deleteAndNotify('stopEdit');
+      this._deleteAndNotify('edit');
+      return;
+    }
+    const actions = actionsChangeRecord.base;
+    const change = changeChangeRecord.base;
+    if (actions && editPatchsetLoaded) {
+      // Only show actions that mutate an edit if an actual edit patch set
+      // is loaded.
+      if (changeIsOpen(change)) {
+        if (editBasedOnCurrentPatchSet) {
+          if (!actions.publishEdit) {
+            this.set('actions.publishEdit', PUBLISH_EDIT);
+          }
+          this._deleteAndNotify('rebaseEdit');
+        } else {
+          if (!actions.rebaseEdit) {
+            this.set('actions.rebaseEdit', REBASE_EDIT);
+          }
+          this._deleteAndNotify('publishEdit');
+        }
+      }
+      if (!actions.deleteEdit) {
+        this.set('actions.deleteEdit', DELETE_EDIT);
+      }
+    } else {
+      this._deleteAndNotify('publishEdit');
+      this._deleteAndNotify('rebaseEdit');
+      this._deleteAndNotify('deleteEdit');
+    }
+
+    if (actions && changeIsOpen(change)) {
+      // Only show edit button if there is no edit patchset loaded and the
+      // file list is not in edit mode.
+      if (editPatchsetLoaded || editMode) {
+        this._deleteAndNotify('edit');
+      } else {
+        if (!actions.edit) {
+          this.set('actions.edit', EDIT);
+        }
+      }
+      // Only show STOP_EDIT if edit mode is enabled, but no edit patch set
+      // is loaded.
+      if (editMode && !editPatchsetLoaded) {
+        if (!actions.stopEdit) {
+          this.set('actions.stopEdit', STOP_EDIT);
+        }
+      } else {
+        this._deleteAndNotify('stopEdit');
+      }
+    } else {
+      // Remove edit button.
+      this._deleteAndNotify('edit');
+    }
+  }
+
+  _getValuesFor<T>(obj: {[key: string]: T}): T[] {
+    return Object.keys(obj).map(key => obj[key]);
+  }
+
+  _getLabelStatus(label: LabelInfo): LabelStatus {
+    if (isQuickLabelInfo(label)) {
+      if (label.approved) {
+        return LabelStatus.OK;
+      } else if (label.rejected) {
+        return LabelStatus.REJECT;
+      }
+    }
+    if (label.optional) {
+      return LabelStatus.OPTIONAL;
+    } else {
+      return LabelStatus.NEED;
+    }
+  }
+
+  /**
+   * Get highest score for last missing permitted label for current change.
+   * Returns null if no labels permitted or more than one label missing.
+   */
+  _getTopMissingApproval() {
+    if (!this.change || !this.change.labels || !this.change.permitted_labels) {
+      return null;
+    }
+    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;
+      }
+      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
+          // approve, return null;
+          return null;
+        }
+        result = label;
+      } else if (
+        status === LabelStatus.REJECT ||
+        status === LabelStatus.IMPOSSIBLE
+      ) {
+        return null;
+      }
+    }
+    // Allow the user to use quick approve to vote the max score on code review
+    // even if it is already granted.
+    if (
+      !result &&
+      this.change.labels[CODE_REVIEW] &&
+      this._getLabelStatus(this.change.labels[CODE_REVIEW]) === LabelStatus.OK
+    ) {
+      result = CODE_REVIEW;
+    }
+
+    if (result) {
+      const score = this.change.permitted_labels[result].slice(-1)[0];
+      const labelInfo = this.change.labels[result];
+      if (!isDetailedLabelInfo(labelInfo)) {
+        return null;
+      }
+      const maxScore = Object.keys(labelInfo.values).slice(-1)[0];
+      if (score === maxScore) {
+        // Allow quick approve only for maximal score.
+        return {
+          label: result,
+          score,
+        };
+      }
+    }
+    return null;
+  }
+
+  hideQuickApproveAction() {
+    if (!this._topLevelSecondaryActions) {
+      throw new Error('_topLevelSecondaryActions must be set');
+    }
+    this._topLevelSecondaryActions = this._topLevelSecondaryActions.filter(
+      sa => !isQuckApproveAction(sa)
+    );
+    this._hideQuickApproveAction = true;
+  }
+
+  _getQuickApproveAction(): QuickApproveUIActionInfo | null {
+    if (this._hideQuickApproveAction) {
+      return null;
+    }
+    const approval = this._getTopMissingApproval();
+    if (!approval) {
+      return null;
+    }
+    const action = {...QUICK_APPROVE_ACTION};
+    action.label = approval.label + approval.score;
+
+    const score = Number(approval.score);
+    if (isNaN(score)) {
+      return null;
+    }
+
+    const review: ReviewInput = {
+      drafts: DraftsAction.PUBLISH_ALL_REVISIONS,
+      labels: {
+        [approval.label]: score,
+      },
+    };
+    action.payload = review;
+    return action;
+  }
+
+  _getActionValues(
+    actionsChangeRecord: PolymerDeepPropertyChange<
+      ActionNameToActionInfoMap,
+      ActionNameToActionInfoMap
+    >,
+    primariesChangeRecord: PolymerDeepPropertyChange<
+      PrimaryActionKey[],
+      PrimaryActionKey[]
+    >,
+    additionalActionsChangeRecord: PolymerDeepPropertyChange<
+      UIActionInfo[],
+      UIActionInfo[]
+    >,
+    type: ActionType
+  ): UIActionInfo[] {
+    if (!actionsChangeRecord || !primariesChangeRecord) {
+      return [];
+    }
+
+    const actions = actionsChangeRecord.base || {};
+    const primaryActionKeys = primariesChangeRecord.base || [];
+    const result: UIActionInfo[] = [];
+    const values: Array<ChangeActions | RevisionActions> =
+      type === ActionType.CHANGE
+        ? this._getValuesFor(ChangeActions)
+        : this._getValuesFor(RevisionActions);
+
+    const pluginActions: UIActionInfo[] = [];
+    Object.keys(actions).forEach(a => {
+      const action: UIActionInfo = actions[a] as UIActionInfo;
+      action.__key = a;
+      action.__type = type;
+      action.__primary = primaryActionKeys.includes(a as PrimaryActionKey);
+      // Plugin actions always contain ~ in the key.
+      if (a.indexOf('~') !== -1) {
+        this._populateActionUrl(action);
+        pluginActions.push(action);
+        // Add server-side provided plugin actions to overflow menu.
+        this._overflowActions.push({
+          type,
+          key: a,
+        });
+        return;
+      } else if (!values.includes(a as PrimaryActionKey)) {
+        return;
+      }
+      action.label = this._getActionLabel(action);
+
+      // Triggers a re-render by ensuring object inequality.
+      result.push({...action});
+    });
+
+    let additionalActions =
+      (additionalActionsChangeRecord && additionalActionsChangeRecord.base) ||
+      [];
+    additionalActions = additionalActions
+      .filter(a => a.__type === type)
+      .map(a => {
+        a.__primary = primaryActionKeys.includes(a.__key as PrimaryActionKey);
+        // Triggers a re-render by ensuring object inequality.
+        return {...a};
+      });
+    return result.concat(additionalActions).concat(pluginActions);
+  }
+
+  _populateActionUrl(action: UIActionInfo) {
+    const patchNum =
+      action.__type === ActionType.REVISION ? this.latestPatchNum : undefined;
+    if (!this.changeNum) {
+      return;
+    }
+    this.$.restAPI
+      .getChangeActionURL(this.changeNum, patchNum, '/' + action.__key)
+      .then(url => (action.__url = url));
+  }
+
+  /**
+   * Given a change action, return a display label that uses the appropriate
+   * casing or includes explanatory details.
+   */
+  _getActionLabel(action: UIActionInfo) {
+    if (action.label === 'Delete') {
+      // This label is common within change and revision actions. Make it more
+      // explicit to the user.
+      return 'Delete change';
+    } else if (action.label === 'WIP') {
+      return 'Mark as work in progress';
+    }
+    // Otherwise, just map the name to sentence case.
+    return this._toSentenceCase(action.label);
+  }
+
+  /**
+   * Capitalize the first letter and lowecase all others.
+   */
+  _toSentenceCase(s: string) {
+    if (!s.length) {
+      return '';
+    }
+    return s[0].toUpperCase() + s.slice(1).toLowerCase();
+  }
+
+  _computeLoadingLabel(action: string) {
+    return ActionLoadingLabels[action] || 'Working...';
+  }
+
+  _canSubmitChange() {
+    if (!this.change) {
+      return false;
+    }
+    return this.$.jsAPI.canSubmitChange(
+      this.change,
+      this._getRevision(this.change, this.latestPatchNum)
+    );
+  }
+
+  _getRevision(change: ChangeViewChangeInfo, patchNum?: PatchSetNum) {
+    for (const rev of Object.values(change.revisions)) {
+      if (patchNumEquals(rev._number, patchNum)) {
+        return rev;
+      }
+    }
+    return null;
+  }
+
+  showRevertDialog() {
+    const change = this.change;
+    if (!change) return;
+    // The search is still broken if there is a " in the topic.
+    const query = `submissionid: "${change.submission_id}"`;
+    /* A chromium plugin expects that the modifyRevertMsg hook will only
+    be called after the revert button is pressed, hence we populate the
+    revert dialog after revert button is pressed. */
+    this.$.restAPI.getChanges(0, query).then(changes => {
+      if (!changes) {
+        console.error('changes is undefined');
+        return;
+      }
+      this.$.confirmRevertDialog.populate(change, this.commitMessage, changes);
+      this._showActionDialog(this.$.confirmRevertDialog);
+    });
+  }
+
+  showRevertSubmissionDialog() {
+    const change = this.change;
+    if (!change) return;
+    const query = `submissionid:${change.submission_id}`;
+    this.$.restAPI.getChanges(0, query).then(changes => {
+      if (!changes) {
+        console.error('changes is undefined');
+        return;
+      }
+      this.$.confirmRevertSubmissionDialog._populateRevertSubmissionMessage(
+        change,
+        changes
+      );
+      this._showActionDialog(this.$.confirmRevertSubmissionDialog);
+    });
+  }
+
+  _handleActionTap(e: MouseEvent) {
+    e.preventDefault();
+    let el = (dom(e) as EventApi).localTarget as Element;
+    while (el.tagName.toLowerCase() !== 'gr-button') {
+      if (!el.parentElement) {
+        return;
+      }
+      el = el.parentElement;
+    }
+
+    const key = el.getAttribute('data-action-key');
+    if (!key) {
+      throw new Error("Button doesn't have data-action-key attribute");
+    }
+    if (
+      key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
+      key.indexOf('~') !== -1
+    ) {
+      this.dispatchEvent(
+        new CustomEvent(`${key}-tap`, {
+          detail: {node: el},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+    const type = el.getAttribute('data-action-type') as ActionType;
+    this._handleAction(type, key);
+  }
+
+  _handleOverflowItemTap(e: CustomEvent<MenuAction>) {
+    e.preventDefault();
+    const el = (dom(e) as EventApi).localTarget as Element;
+    const key = e.detail.action.__key;
+    if (
+      key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
+      key.indexOf('~') !== -1
+    ) {
+      this.dispatchEvent(
+        new CustomEvent(`${key}-tap`, {
+          detail: {node: el},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+    this._handleAction(e.detail.action.__type, e.detail.action.__key);
+  }
+
+  _handleAction(type: ActionType, key: string) {
+    this.reporting.reportInteraction(`${type}-${key}`);
+    switch (type) {
+      case ActionType.REVISION:
+        this._handleRevisionAction(key);
+        break;
+      case ActionType.CHANGE:
+        this._handleChangeAction(key);
+        break;
+      default:
+        this._fireAction(
+          this._prependSlash(key),
+          assertUIActionInfo(this.actions[key]),
+          false
+        );
+    }
+  }
+
+  _handleChangeAction(key: string) {
+    switch (key) {
+      case ChangeActions.REVERT:
+        this.showRevertDialog();
+        break;
+      case ChangeActions.REVERT_SUBMISSION:
+        this.showRevertSubmissionDialog();
+        break;
+      case ChangeActions.ABANDON:
+        this._showActionDialog(this.$.confirmAbandonDialog);
+        break;
+      case QUICK_APPROVE_ACTION.key: {
+        const action = this._allActionValues.find(isQuckApproveAction);
+        if (!action) {
+          return;
+        }
+        this._fireAction(this._prependSlash(key), action, true, action.payload);
+        break;
+      }
+      case ChangeActions.EDIT:
+        this._handleEditTap();
+        break;
+      case ChangeActions.STOP_EDIT:
+        this._handleStopEditTap();
+        break;
+      case ChangeActions.DELETE:
+        this._handleDeleteTap();
+        break;
+      case ChangeActions.DELETE_EDIT:
+        this._handleDeleteEditTap();
+        break;
+      case ChangeActions.FOLLOW_UP:
+        this._handleFollowUpTap();
+        break;
+      case ChangeActions.WIP:
+        this._handleWipTap();
+        break;
+      case ChangeActions.MOVE:
+        this._handleMoveTap();
+        break;
+      case ChangeActions.PUBLISH_EDIT:
+        this._handlePublishEditTap();
+        break;
+      case ChangeActions.REBASE_EDIT:
+        this._handleRebaseEditTap();
+        break;
+      default:
+        this._fireAction(
+          this._prependSlash(key),
+          assertUIActionInfo(this.actions[key]),
+          false
+        );
+    }
+  }
+
+  _handleRevisionAction(key: string) {
+    switch (key) {
+      case RevisionActions.REBASE:
+        this._showActionDialog(this.$.confirmRebase);
+        this.$.confirmRebase.fetchRecentChanges();
+        break;
+      case RevisionActions.CHERRYPICK:
+        this._handleCherrypickTap();
+        break;
+      case RevisionActions.DOWNLOAD:
+        this._handleDownloadTap();
+        break;
+      case RevisionActions.SUBMIT:
+        if (!this._canSubmitChange()) {
+          return;
+        }
+        this._showActionDialog(this.$.confirmSubmitDialog);
+        break;
+      default:
+        this._fireAction(
+          this._prependSlash(key),
+          assertUIActionInfo(this.revisionActions[key]),
+          true
+        );
+    }
+  }
+
+  _prependSlash(key: string) {
+    return key === '/' ? key : `/${key}`;
+  }
+
+  /**
+   * _hasKnownChainState set to true true if hasParent is defined (can be
+   * either true or false). set to false otherwise.
+   */
+  _computeChainState() {
+    this._hasKnownChainState = true;
+  }
+
+  _calculateDisabled(action: UIActionInfo, hasKnownChainState: boolean) {
+    if (action.__key === 'rebase') {
+      // Rebase button is only disabled when change has no parent(s).
+      return hasKnownChainState === false;
+    }
+    return !action.enabled;
+  }
+
+  _handleConfirmDialogCancel() {
+    this._hideAllDialogs();
+  }
+
+  _hideAllDialogs() {
+    const dialogEls = this.root!.querySelectorAll('.confirmDialog');
+    for (const dialogEl of dialogEls) {
+      (dialogEl as HTMLElement).hidden = true;
+    }
+    this.$.overlay.close();
+  }
+
+  _handleRebaseConfirm(e: CustomEvent<ConfirmRebaseEventDetail>) {
+    const el = this.$.confirmRebase;
+    const payload = {base: e.detail.base};
+    this.$.overlay.close();
+    el.hidden = true;
+    this._fireAction(
+      '/rebase',
+      assertUIActionInfo(this.revisionActions.rebase),
+      true,
+      payload
+    );
+  }
+
+  _handleCherrypickConfirm() {
+    this._handleCherryPickRestApi(false);
+  }
+
+  _handleCherrypickConflictConfirm() {
+    this._handleCherryPickRestApi(true);
+  }
+
+  _handleCherryPickRestApi(conflicts: boolean) {
+    const el = this.$.confirmCherrypick;
+    if (!el.branch) {
+      fireAlert(this, ERR_BRANCH_EMPTY);
+      return;
+    }
+    if (!el.message) {
+      fireAlert(this, ERR_COMMIT_EMPTY);
+      return;
+    }
+    this.$.overlay.close();
+    el.hidden = true;
+    this._fireAction(
+      '/cherrypick',
+      assertUIActionInfo(this.revisionActions.cherrypick),
+      true,
+      {
+        destination: el.branch,
+        base: el.baseCommit ? el.baseCommit : null,
+        message: el.message,
+        allow_conflicts: conflicts,
+      }
+    );
+  }
+
+  _handleMoveConfirm() {
+    const el = this.$.confirmMove;
+    if (!el.branch) {
+      fireAlert(this, ERR_BRANCH_EMPTY);
+      return;
+    }
+    this.$.overlay.close();
+    el.hidden = true;
+    this._fireAction('/move', assertUIActionInfo(this.actions.move), false, {
+      destination_branch: el.branch,
+      message: el.message,
+    });
+  }
+
+  _handleRevertDialogConfirm(e: CustomEvent<ConfirmRevertEventDetail>) {
+    const revertType = e.detail.revertType;
+    const message = e.detail.message;
+    const el = this.$.confirmRevertDialog;
+    this.$.overlay.close();
+    el.hidden = true;
+    switch (revertType) {
+      case RevertType.REVERT_SINGLE_CHANGE:
+        this._fireAction(
+          '/revert',
+          assertUIActionInfo(this.actions.revert),
+          false,
+          {message}
+        );
+        break;
+      case RevertType.REVERT_SUBMISSION:
+        this._fireAction(
+          '/revert_submission',
+          assertUIActionInfo(this.actions.revert_submission),
+          false,
+          {message}
+        );
+        break;
+      default:
+        console.error('invalid revert type');
+    }
+  }
+
+  _handleRevertSubmissionDialogConfirm() {
+    const el = this.$.confirmRevertSubmissionDialog;
+    this.$.overlay.close();
+    el.hidden = true;
+    this._fireAction(
+      '/revert_submission',
+      assertUIActionInfo(this.actions.revert_submission),
+      false,
+      {message: el.message}
+    );
+  }
+
+  _handleAbandonDialogConfirm() {
+    const el = this.$.confirmAbandonDialog;
+    this.$.overlay.close();
+    el.hidden = true;
+    this._fireAction(
+      '/abandon',
+      assertUIActionInfo(this.actions.abandon),
+      false,
+      {
+        message: el.message,
+      }
+    );
+  }
+
+  _handleCreateFollowUpChange() {
+    this.$.createFollowUpChange.handleCreateChange();
+    this._handleCloseCreateFollowUpChange();
+  }
+
+  _handleCloseCreateFollowUpChange() {
+    this.$.overlay.close();
+  }
+
+  _handleDeleteConfirm() {
+    this._fireAction(
+      '/',
+      assertUIActionInfo(this.actions[ChangeActions.DELETE]),
+      false
+    );
+  }
+
+  _handleDeleteEditConfirm() {
+    this._hideAllDialogs();
+
+    this._fireAction(
+      '/edit',
+      assertUIActionInfo(this.actions.deleteEdit),
+      false
+    );
+  }
+
+  _handleSubmitConfirm() {
+    if (!this._canSubmitChange()) {
+      return;
+    }
+    this._hideAllDialogs();
+    this._fireAction(
+      '/submit',
+      assertUIActionInfo(this.revisionActions.submit),
+      true
+    );
+  }
+
+  _getActionOverflowIndex(type: string, key: string) {
+    return this._overflowActions.findIndex(
+      action => action.type === type && action.key === key
+    );
+  }
+
+  _setLoadingOnButtonWithKey(type: string, key: string) {
+    this._actionLoadingMessage = this._computeLoadingLabel(key);
+    let buttonKey = key;
+    // TODO(dhruvsri): clean this up later
+    // If key is revert-submission, then button key should be 'revert'
+    if (buttonKey === ChangeActions.REVERT_SUBMISSION) {
+      // Revert submission button no longer exists
+      buttonKey = ChangeActions.REVERT;
+    }
+
+    // If the action appears in the overflow menu.
+    if (this._getActionOverflowIndex(type, buttonKey) !== -1) {
+      this.push(
+        '_disabledMenuActions',
+        buttonKey === '/' ? 'delete' : buttonKey
+      );
+      return () => {
+        this._actionLoadingMessage = '';
+        this._disabledMenuActions = [];
+      };
+    }
+
+    // Otherwise it's a top-level action.
+    const buttonEl = this.shadowRoot!.querySelector(
+      `[data-action-key="${buttonKey}"]`
+    ) as GrButton;
+    if (!buttonEl) {
+      throw new Error(`Can't find button by data-action-key '${buttonKey}'`);
+    }
+    buttonEl.setAttribute('loading', 'true');
+    buttonEl.disabled = true;
+    return () => {
+      this._actionLoadingMessage = '';
+      buttonEl.removeAttribute('loading');
+      buttonEl.disabled = false;
+    };
+  }
+
+  _fireAction(
+    endpoint: string,
+    action: UIActionInfo,
+    revAction: boolean,
+    payload?: RequestPayload
+  ) {
+    const cleanupFn = this._setLoadingOnButtonWithKey(
+      action.__type,
+      action.__key
+    );
+
+    this._send(
+      action.method,
+      payload,
+      endpoint,
+      revAction,
+      cleanupFn,
+      action
+    ).then(res => this._handleResponse(action, res));
+  }
+
+  _showActionDialog(dialog: ChangeActionDialog) {
+    this._hideAllDialogs();
+
+    dialog.hidden = false;
+    this.$.overlay.open().then(() => {
+      if (dialog.resetFocus) {
+        dialog.resetFocus();
+      }
+    });
+  }
+
+  // TODO(rmistry): Redo this after
+  // https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved.
+  _setLabelValuesOnRevert(newChangeId: NumericChangeId) {
+    const labels = this.$.jsAPI.getLabelValuesPostRevert(this.change);
+    if (!labels) {
+      return Promise.resolve(undefined);
+    }
+    return this.$.restAPI.saveChangeReview(newChangeId, 'current', {labels});
+  }
+
+  _handleResponse(action: UIActionInfo, response?: Response) {
+    if (!response) {
+      return;
+    }
+    return this.$.restAPI.getResponseObject(response).then(obj => {
+      switch (action.__key) {
+        case ChangeActions.REVERT: {
+          const revertChangeInfo: ChangeInfo = (obj as unknown) as ChangeInfo;
+          this._waitForChangeReachable(revertChangeInfo._number)
+            .then(() => this._setLabelValuesOnRevert(revertChangeInfo._number))
+            .then(() => {
+              GerritNav.navigateToChange(revertChangeInfo);
+            });
+          break;
+        }
+        case RevisionActions.CHERRYPICK: {
+          const cherrypickChangeInfo: ChangeInfo = (obj as unknown) as ChangeInfo;
+          this._waitForChangeReachable(cherrypickChangeInfo._number).then(
+            () => {
+              GerritNav.navigateToChange(cherrypickChangeInfo);
+            }
+          );
+          break;
+        }
+        case ChangeActions.DELETE:
+          if (action.__type === ActionType.CHANGE) {
+            GerritNav.navigateToRelativeUrl(GerritNav.getUrlForRoot());
+          }
+          break;
+        case ChangeActions.WIP:
+        case ChangeActions.DELETE_EDIT:
+        case ChangeActions.PUBLISH_EDIT:
+        case ChangeActions.REBASE_EDIT:
+        case ChangeActions.REBASE:
+        case ChangeActions.SUBMIT:
+          this.dispatchEvent(
+            new CustomEvent('reload', {
+              detail: {clearPatchset: true},
+              bubbles: false,
+              composed: true,
+            })
+          );
+          break;
+        case ChangeActions.REVERT_SUBMISSION: {
+          const revertSubmistionInfo = (obj as unknown) as RevertSubmissionInfo;
+          if (
+            !revertSubmistionInfo.revert_changes ||
+            !revertSubmistionInfo.revert_changes.length
+          )
+            return;
+          /* If there is only 1 change then gerrit will automatically
+             redirect to that change */
+          GerritNav.navigateToSearchQuery(
+            `topic: ${revertSubmistionInfo.revert_changes[0].topic}`
+          );
+          break;
+        }
+        default:
+          this.dispatchEvent(
+            new CustomEvent('reload', {
+              detail: {action: action.__key, clearPatchset: true},
+              bubbles: false,
+              composed: true,
+            })
+          );
+          break;
+      }
+    });
+  }
+
+  _handleShowRevertSubmissionChangesConfirm() {
+    this._hideAllDialogs();
+  }
+
+  _handleResponseError(
+    action: UIActionInfo,
+    response: Response | undefined | null,
+    body?: RequestPayload
+  ) {
+    if (!response) {
+      return Promise.resolve(() => {
+        this.dispatchEvent(
+          new CustomEvent('show-error', {
+            detail: {message: `Could not perform action '${action.__key}'`},
+            composed: true,
+            bubbles: true,
+          })
+        );
+      });
+    }
+    if (action && action.__key === RevisionActions.CHERRYPICK) {
+      if (
+        response.status === 409 &&
+        body &&
+        !(body as CherryPickInput).allow_conflicts
+      ) {
+        return this._showActionDialog(this.$.confirmCherrypickConflict);
+      }
+    }
+    return response.text().then(errText => {
+      this.dispatchEvent(
+        new CustomEvent('show-error', {
+          detail: {message: `Could not perform action: ${errText}`},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      if (!errText.startsWith('Change is already up to date')) {
+        throw Error(errText);
+      }
+    });
+  }
+
+  _send(
+    method: HttpMethod | undefined,
+    payload: RequestPayload | undefined,
+    actionEndpoint: string,
+    revisionAction: boolean,
+    cleanupFn: () => void,
+    action: UIActionInfo
+  ): Promise<Response | undefined> {
+    const handleError: ErrorCallback = response => {
+      cleanupFn.call(this);
+      this._handleResponseError(action, response, payload);
+    };
+    const change = this.change;
+    const changeNum = this.changeNum;
+    if (!change || !changeNum) {
+      return Promise.reject(
+        new Error('Properties change and changeNum must be set.')
+      );
+    }
+    return fetchChangeUpdates(change, this.$.restAPI).then(result => {
+      if (!result.isLatest) {
+        this.dispatchEvent(
+          new CustomEvent('show-alert', {
+            detail: {
+              message:
+                'Cannot set label: a newer patch has been ' +
+                'uploaded to this change.',
+              action: 'Reload',
+              callback: () => {
+                this.dispatchEvent(
+                  new CustomEvent('reload', {
+                    detail: {clearPatchset: true},
+                    bubbles: false,
+                    composed: true,
+                  })
+                );
+              },
+            },
+            composed: true,
+            bubbles: true,
+          })
+        );
+
+        // Because this is not a network error, call the cleanup function
+        // but not the error handler.
+        cleanupFn();
+
+        return Promise.resolve(undefined);
+      }
+      const patchNum = revisionAction ? this.latestPatchNum : undefined;
+      return this.$.restAPI
+        .executeChangeAction(
+          changeNum,
+          method,
+          actionEndpoint,
+          patchNum,
+          payload,
+          handleError
+        )
+        .then(response => {
+          cleanupFn.call(this);
+          return response;
+        });
+    });
+  }
+
+  _handleAbandonTap() {
+    this._showActionDialog(this.$.confirmAbandonDialog);
+  }
+
+  _handleCherrypickTap() {
+    if (!this.change) {
+      throw new Error('The change property must be set');
+    }
+    this.$.confirmCherrypick.branch = '' as BranchName;
+    const query = `topic: "${this.change.topic}"`;
+    const options = listChangesOptionsToHex(
+      ListChangesOption.MESSAGES,
+      ListChangesOption.ALL_REVISIONS
+    );
+    this.$.restAPI.getChanges(0, query, undefined, options).then(changes => {
+      if (!changes) {
+        console.error('getChanges returns undefined');
+        return;
+      }
+      this.$.confirmCherrypick.updateChanges(changes);
+      this._showActionDialog(this.$.confirmCherrypick);
+    });
+  }
+
+  _handleMoveTap() {
+    this.$.confirmMove.branch = '' as BranchName;
+    this.$.confirmMove.message = '';
+    this._showActionDialog(this.$.confirmMove);
+  }
+
+  _handleDownloadTap() {
+    this.dispatchEvent(
+      new CustomEvent('download-tap', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+
+  _handleDeleteTap() {
+    this._showActionDialog(this.$.confirmDeleteDialog);
+  }
+
+  _handleDeleteEditTap() {
+    this._showActionDialog(this.$.confirmDeleteEditDialog);
+  }
+
+  _handleFollowUpTap() {
+    this._showActionDialog(this.$.createFollowUpDialog);
+  }
+
+  _handleWipTap() {
+    if (!this.actions.wip) {
+      return;
+    }
+    this._fireAction('/wip', assertUIActionInfo(this.actions.wip), false);
+  }
+
+  _handlePublishEditTap() {
+    if (!this.actions.publishEdit) {
+      return;
+    }
+    this._fireAction(
+      '/edit:publish',
+      assertUIActionInfo(this.actions.publishEdit),
+      false,
+      {notify: NotifyType.NONE}
+    );
+  }
+
+  _handleRebaseEditTap() {
+    if (!this.actions.rebaseEdit) {
+      return;
+    }
+    this._fireAction(
+      '/edit:rebase',
+      assertUIActionInfo(this.actions.rebaseEdit),
+      false
+    );
+  }
+
+  _handleHideBackgroundContent() {
+    this.$.mainContent.classList.add('overlayOpen');
+  }
+
+  _handleShowBackgroundContent() {
+    this.$.mainContent.classList.remove('overlayOpen');
+  }
+
+  /**
+   * Merge sources of change actions into a single ordered array of action
+   * values.
+   */
+  _computeAllActions(
+    changeActionsRecord: PolymerDeepPropertyChange<
+      ActionNameToActionInfoMap,
+      ActionNameToActionInfoMap
+    >,
+    revisionActionsRecord: PolymerDeepPropertyChange<
+      ActionNameToActionInfoMap,
+      ActionNameToActionInfoMap
+    >,
+    primariesRecord: PolymerDeepPropertyChange<
+      PrimaryActionKey[],
+      PrimaryActionKey[]
+    >,
+    additionalActionsRecord: PolymerDeepPropertyChange<
+      UIActionInfo[],
+      UIActionInfo[]
+    >,
+    change?: ChangeInfo,
+    config?: ServerInfo
+  ): UIActionInfo[] {
+    // Polymer 2: check for undefined
+    if (
+      [
+        changeActionsRecord,
+        revisionActionsRecord,
+        primariesRecord,
+        additionalActionsRecord,
+        change,
+      ].includes(undefined)
+    ) {
+      return [];
+    }
+
+    const revisionActionValues = this._getActionValues(
+      revisionActionsRecord,
+      primariesRecord,
+      additionalActionsRecord,
+      ActionType.REVISION
+    );
+    const changeActionValues = this._getActionValues(
+      changeActionsRecord,
+      primariesRecord,
+      additionalActionsRecord,
+      ActionType.CHANGE
+    );
+    const quickApprove = this._getQuickApproveAction();
+    if (quickApprove) {
+      changeActionValues.unshift(quickApprove);
+    }
+
+    return revisionActionValues
+      .concat(changeActionValues)
+      .sort((a, b) => this._actionComparator(a, b))
+      .map(action => {
+        if (ACTIONS_WITH_ICONS.has(action.__key)) {
+          action.icon = action.__key;
+        }
+        // TODO(brohlfs): Temporary hack until change 269573 is live in all
+        // backends.
+        if (action.__key === ChangeActions.READY) {
+          action.label = 'Mark as Active';
+        }
+        // End of hack
+        return action;
+      })
+      .filter(action => !this._shouldSkipAction(action, config));
+  }
+
+  _getActionPriority(action: UIActionInfo) {
+    if (action.__type && action.__key) {
+      const overrideAction = this._actionPriorityOverrides.find(
+        i => 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(actionA: UIActionInfo, actionB: UIActionInfo) {
+    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;
+    }
+  }
+
+  _shouldSkipAction(action: UIActionInfo, config?: ServerInfo) {
+    const skipActionKeys: string[] = [...SKIP_ACTION_KEYS];
+    const isAttentionSetEnabled =
+      !!config && !!config.change && config.change.enable_attention_set;
+    if (isAttentionSetEnabled) {
+      skipActionKeys.push(...SKIP_ACTION_KEYS_ATTENTION_SET);
+    }
+    return skipActionKeys.includes(action.__key);
+  }
+
+  _computeTopLevelActions(
+    actionRecord: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
+    hiddenActionsRecord: PolymerDeepPropertyChange<string[], string[]>,
+    editMode: boolean
+  ): UIActionInfo[] {
+    const hiddenActions = hiddenActionsRecord.base || [];
+    return actionRecord.base.filter(a => {
+      if (hiddenActions.includes(a.__key)) return false;
+      if (editMode) return EDIT_ACTIONS.has(a.__key);
+      return this._getActionOverflowIndex(a.__type, a.__key) === -1;
+    });
+  }
+
+  _filterPrimaryActions(_topLevelActions: UIActionInfo[]) {
+    this._topLevelPrimaryActions = _topLevelActions.filter(
+      action => action.__primary
+    );
+    this._topLevelSecondaryActions = _topLevelActions.filter(
+      action => !action.__primary
+    );
+  }
+
+  _computeMenuActions(
+    actionRecord: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
+    hiddenActionsRecord: PolymerDeepPropertyChange<string[], string[]>
+  ): MenuAction[] {
+    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,
+          tooltip: action.title,
+        };
+      });
+  }
+
+  _computeRebaseOnCurrent(
+    revisionRebaseAction: PropertyType<GrChangeActions, '_revisionRebaseAction'>
+  ) {
+    if (revisionRebaseAction) {
+      return !!revisionRebaseAction.enabled;
+    }
+    return null;
+  }
+
+  /**
+   * Occasionally, a change created by a change action is not yet known to the
+   * API for a brief time. Wait for the given change number to be recognized.
+   *
+   * Returns a promise that resolves with true if a request is recognized, or
+   * false if the change was never recognized after all attempts.
+   *
+   */
+  _waitForChangeReachable(changeNum: NumericChangeId) {
+    let attempsRemaining = AWAIT_CHANGE_ATTEMPTS;
+    return new Promise(resolve => {
+      const check = () => {
+        attempsRemaining--;
+        // Pass a no-op error handler to avoid the "not found" error toast.
+        this.$.restAPI
+          .getChange(changeNum, () => {})
+          .then(response => {
+            // If the response is 404, the response will be undefined.
+            if (response) {
+              resolve(true);
+              return;
+            }
+
+            if (attempsRemaining) {
+              this.async(check, AWAIT_CHANGE_TIMEOUT_MS);
+            } else {
+              resolve(false);
+            }
+          });
+      };
+      check();
+    });
+  }
+
+  _handleEditTap() {
+    this.dispatchEvent(new CustomEvent('edit-tap', {bubbles: false}));
+  }
+
+  _handleStopEditTap() {
+    this.dispatchEvent(new CustomEvent('stop-edit-tap', {bubbles: false}));
+  }
+
+  _computeHasTooltip(title?: string) {
+    return !!title;
+  }
+
+  _computeHasIcon(action: UIActionInfo) {
+    return action.icon ? '' : 'hidden';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-actions': GrChangeActions;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
index 091e5a2..1098760 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
@@ -20,7 +20,11 @@
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {generateChange} from '../../../test/test-utils.js';
+import {
+  createChange,
+  createChangeMessages,
+  createRevisions,
+} from '../../../test/test-data-generators.js';
 
 const basicFixture = fixtureFromElement('gr-change-actions');
 
@@ -153,19 +157,17 @@
       });
     });
 
-    test('plugin change actions', done => {
+    test('plugin change actions', async () => {
       sinon.stub(element.$.restAPI, 'getChangeActionURL').returns(
           Promise.resolve('the-url'));
       element.actions = {
         'plugin~action': {},
       };
       assert.isOk(element.actions['plugin~action']);
-      flush(() => {
-        assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
-            element.changeNum, null, '/plugin~action'));
-        assert.equal(element.actions['plugin~action'].__url, 'the-url');
-        done();
-      });
+      await flush();
+      assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
+          element.changeNum, undefined, '/plugin~action'));
+      assert.equal(element.actions['plugin~action'].__url, 'the-url');
     });
 
     test('not supported actions are filtered out', () => {
@@ -813,6 +815,14 @@
       setup(() => {
         fireActionStub = sinon.stub(element, '_fireAction');
         sinon.stub(window, 'alert');
+        element.actions = {
+          move: {
+            method: 'POST',
+            label: 'Move',
+            title: 'Move the change',
+            enabled: true,
+          },
+        };
       });
 
       test('works', () => {
@@ -1614,7 +1624,7 @@
         assert.isTrue(fireActionStub.called);
         assert.isTrue(fireActionStub.calledWith('/review'));
         const payload = fireActionStub.lastCall.args[3];
-        assert.deepEqual(payload.labels, {foo: '+1'});
+        assert.deepEqual(payload.labels, {foo: 1});
       });
 
       test('not added when multiple labels are required', () => {
@@ -1707,6 +1717,31 @@
                 .querySelector('gr-button[data-action-key=\'review\']');
         assert.equal(approveButton.getAttribute('data-label'), 'bar+2');
       });
+
+      test('added when can approve an already-approved code review label',
+          () => {
+            element.change = {
+              current_revision: 'abc1234',
+              labels: {
+                'Code-Review': {
+                  approved: {},
+                  values: {
+                    ' 0': '',
+                    '+1': '',
+                    '+2': '',
+                  },
+                },
+              },
+              permitted_labels: {
+                'Code-Review': [' 0', '+1', '+2'],
+              },
+            };
+            flush();
+            const approveButton =
+              element.shadowRoot
+                  .querySelector('gr-button[data-action-key=\'review\']');
+            assert.isNotNull(approveButton);
+          });
     });
 
     test('adds download revision action', () => {
@@ -1804,10 +1839,11 @@
         element.changeNum = 42;
         element.change._number = 42;
         element.latestPatchNum = 12;
-        element.change = generateChange({
-          revisionsCount: element.latestPatchNum,
-          messagesCount: 1,
-        });
+        element.change = {
+          ...createChange(),
+          revisions: createRevisions(element.latestPatchNum),
+          messages: createChangeMessages(1),
+        };
         payload = {foo: 'bar'};
 
         onShowError = sinon.stub();
@@ -1820,12 +1856,12 @@
         let sendStub;
         setup(() => {
           sinon.stub(element.$.restAPI, 'getChangeDetail')
-              .returns(Promise.resolve(
-                  generateChange({
-                    // element has latest info
-                    revisionsCount: element.latestPatchNum,
-                    messagesCount: 1,
-                  })));
+              .returns(Promise.resolve({
+                ...createChange(),
+                // element has latest info
+                revisions: createRevisions(element.latestPatchNum),
+                messages: createChangeMessages(1),
+              }));
           sendStub = sinon.stub(element.$.restAPI, 'executeChangeAction')
               .returns(Promise.resolve({}));
           getResponseObjectStub = sinon.stub(element.$.restAPI,
@@ -1834,16 +1870,12 @@
               'navigateToChange').returns(Promise.resolve(true));
         });
 
-        test('change action', done => {
-          element
-              ._send('DELETE', payload, '/endpoint', false, cleanup)
-              .then(() => {
-                assert.isFalse(onShowError.called);
-                assert.isTrue(cleanup.calledOnce);
-                assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
-                    null, payload));
-                done();
-              });
+        test('change action', async () => {
+          await element._send('DELETE', payload, '/endpoint', false, cleanup);
+          assert.isFalse(onShowError.called);
+          assert.isTrue(cleanup.calledOnce);
+          assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
+              undefined, payload));
         });
 
         suite('show revert submission dialog', () => {
@@ -1943,12 +1975,12 @@
       suite('failure modes', () => {
         test('non-latest', () => {
           sinon.stub(element.$.restAPI, 'getChangeDetail')
-              .returns(Promise.resolve(
-                  generateChange({
-                    // new patchset was uploaded
-                    revisionsCount: element.latestPatchNum + 1,
-                    messagesCount: 1,
-                  })));
+              .returns(Promise.resolve({
+                ...createChange(),
+                // new patchset was uploaded
+                revisions: createRevisions(element.latestPatchNum + 1),
+                messages: createChangeMessages(1),
+              }));
           const sendStub = sinon.stub(element.$.restAPI,
               'executeChangeAction');
 
@@ -1963,12 +1995,12 @@
 
         test('send fails', () => {
           sinon.stub(element.$.restAPI, 'getChangeDetail')
-              .returns(Promise.resolve(
-                  generateChange({
-                    // element has latest info
-                    revisionsCount: element.latestPatchNum,
-                    messagesCount: 1,
-                  })));
+              .returns(Promise.resolve({
+                ...createChange(),
+                // element has latest info
+                revisions: createRevisions(element.latestPatchNum),
+                messages: createChangeMessages(1),
+              }));
           const sendStub = sinon.stub(element.$.restAPI,
               'executeChangeAction').callsFake(
               (num, method, patchNum, endpoint, payload, onErr) => {
@@ -1990,6 +2022,13 @@
 
     test('_handleAction reports', () => {
       sinon.stub(element, '_fireAction');
+      element.actions = {
+        key: {
+          __key: 'key',
+          __type: 'type',
+        },
+      };
+
       const reportStub = sinon.stub(element.reporting, 'reportInteraction');
       element._handleAction('type', 'key');
       assert.isTrue(reportStub.called);
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
deleted file mode 100644
index b038bf6..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
+++ /dev/null
@@ -1,541 +0,0 @@
-/**
- * @license
- * 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.
- */
-import '../../../styles/shared-styles.js';
-import '../../../styles/gr-change-metadata-shared-styles.js';
-import '../../../styles/gr-change-view-integration-shared-styles.js';
-import '../../../styles/gr-voting-styles.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
-import '../../plugins/gr-external-style/gr-external-style.js';
-import '../../shared/gr-account-chip/gr-account-chip.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-editable-label/gr-editable-label.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-limited-text/gr-limited-text.js';
-import '../../shared/gr-linked-chip/gr-linked-chip.js';
-import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-change-requirements/gr-change-requirements.js';
-import '../gr-commit-info/gr-commit-info.js';
-import '../gr-reviewer-list/gr-reviewer-list.js';
-import '../../shared/gr-account-list/gr-account-list.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-change-metadata_html.js';
-import {GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {ChangeStatus} from '../../../constants/constants.js';
-import {changeIsOpen} from '../../../utils/change-util.js';
-
-const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
-
-const SubmitTypeLabel = {
-  FAST_FORWARD_ONLY: 'Fast Forward Only',
-  MERGE_IF_NECESSARY: 'Merge if Necessary',
-  REBASE_IF_NECESSARY: 'Rebase if Necessary',
-  MERGE_ALWAYS: 'Always Merge',
-  REBASE_ALWAYS: 'Rebase Always',
-  CHERRY_PICK: 'Cherry Pick',
-};
-
-const NOT_CURRENT_MESSAGE = 'Not current - rebase possible';
-
-/**
- * @enum {string}
- */
-const CertificateStatus = {
-  /**
-   * This certificate status is bad.
-   */
-  BAD: 'BAD',
-  /**
-   * This certificate status is OK.
-   */
-  OK: 'OK',
-  /**
-   * This certificate status is TRUSTED.
-   */
-  TRUSTED: 'TRUSTED',
-};
-
-/**
- * @extends PolymerElement
- */
-class GrChangeMetadata extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-change-metadata'; }
-  /**
-   * Fired when the change topic is changed.
-   *
-   * @event topic-changed
-   */
-
-  static get properties() {
-    return {
-    /** @type {?} */
-      change: Object,
-      labels: {
-        type: Object,
-        notify: true,
-      },
-      account: Object,
-      /** @type {?} */
-      revision: Object,
-      commitInfo: Object,
-      _mutable: {
-        type: Boolean,
-        computed: '_computeIsMutable(account)',
-      },
-      /** @type {?} */
-      serverConfig: Object,
-      parentIsCurrent: Boolean,
-      _notCurrentMessage: {
-        type: String,
-        value: NOT_CURRENT_MESSAGE,
-        readOnly: true,
-      },
-      _topicReadOnly: {
-        type: Boolean,
-        computed: '_computeTopicReadOnly(_mutable, change)',
-      },
-      _hashtagReadOnly: {
-        type: Boolean,
-        computed: '_computeHashtagReadOnly(_mutable, change)',
-      },
-      /**
-       * @type {Gerrit.PushCertificateValidation}
-       */
-      _pushCertificateValidation: {
-        type: Object,
-        computed: '_computePushCertificateValidation(serverConfig, change)',
-      },
-      _showRequirements: {
-        type: Boolean,
-        computed: '_computeShowRequirements(change)',
-      },
-
-      _assignee: Array,
-      _isWip: {
-        type: Boolean,
-        computed: '_computeIsWip(change)',
-      },
-      _newHashtag: String,
-
-      _settingTopic: {
-        type: Boolean,
-        value: false,
-      },
-
-      _currentParents: {
-        type: Array,
-        computed: '_computeParents(change, revision)',
-      },
-
-      /** @type {?} */
-      _CHANGE_ROLE: {
-        type: Object,
-        readOnly: true,
-        value: {
-          OWNER: 'owner',
-          UPLOADER: 'uploader',
-          AUTHOR: 'author',
-          COMMITTER: 'committer',
-        },
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_changeChanged(change)',
-      '_labelsChanged(change.labels)',
-      '_assigneeChanged(_assignee.*)',
-    ];
-  }
-
-  _labelsChanged(labels) {
-    this.labels = ({...labels}) || null;
-  }
-
-  _changeChanged(change) {
-    this._assignee = change.assignee ? [change.assignee] : [];
-    this._settingTopic = false;
-  }
-
-  _assigneeChanged(assigneeRecord) {
-    if (!this.change || !this._isAssigneeEnabled(this.serverConfig)) {
-      return;
-    }
-    const assignee = assigneeRecord.base;
-    if (assignee.length) {
-      const acct = assignee[0];
-      if (this.change.assignee &&
-          acct._account_id === this.change.assignee._account_id) { return; }
-      this.set(['change', 'assignee'], acct);
-      this.$.restAPI.setAssignee(this.change._number, acct._account_id);
-    } else {
-      if (!this.change.assignee) { return; }
-      this.set(['change', 'assignee'], undefined);
-      this.$.restAPI.deleteAssignee(this.change._number);
-    }
-  }
-
-  _computeHideStrategy(change) {
-    return !changeIsOpen(change);
-  }
-
-  /**
-   * @param {Object} commitInfo
-   * @return {?Array} If array is empty, returns null instead so
-   * an existential check can be used to hide or show the webLinks
-   * section.
-   */
-  _computeWebLinks(commitInfo, serverConfig) {
-    if (!commitInfo) { return null; }
-    const weblinks = GerritNav.getChangeWeblinks(
-        this.change ? this.change.repo : '',
-        commitInfo.commit,
-        {
-          weblinks: commitInfo.web_links,
-          config: serverConfig,
-        });
-    return weblinks.length ? weblinks : null;
-  }
-
-  _isAssigneeEnabled(serverConfig) {
-    return serverConfig && serverConfig.change
-        && !!serverConfig.change.enable_assignee;
-  }
-
-  _computeStrategy(change) {
-    return SubmitTypeLabel[change.submit_type];
-  }
-
-  _computeLabelNames(labels) {
-    return Object.keys(labels).sort();
-  }
-
-  _handleTopicChanged(e, topic) {
-    const lastTopic = this.change.topic;
-    if (!topic.length) { topic = null; }
-    this._settingTopic = true;
-    const topicChangedForChangeNumber = this.change._number;
-    this.$.restAPI.setChangeTopic(topicChangedForChangeNumber, topic)
-        .then(newTopic => {
-          if (this.change._number !== topicChangedForChangeNumber) {
-            return;
-          }
-          this._settingTopic = false;
-          this.set(['change', 'topic'], newTopic);
-          if (newTopic !== lastTopic) {
-            this.dispatchEvent(new CustomEvent(
-                'topic-changed', {bubbles: true, composed: true}));
-          }
-        });
-  }
-
-  _showAddTopic(changeRecord, settingTopic) {
-    const hasTopic = !!changeRecord &&
-        !!changeRecord.base && !!changeRecord.base.topic;
-    return !hasTopic && !settingTopic;
-  }
-
-  _showTopicChip(changeRecord, settingTopic) {
-    const hasTopic = !!changeRecord &&
-        !!changeRecord.base && !!changeRecord.base.topic;
-    return hasTopic && !settingTopic;
-  }
-
-  _showCherryPickOf(changeRecord) {
-    const hasCherryPickOf = !!changeRecord &&
-        !!changeRecord.base && !!changeRecord.base.cherry_pick_of_change &&
-        !!changeRecord.base.cherry_pick_of_patch_set;
-    return hasCherryPickOf;
-  }
-
-  _handleHashtagChanged(e) {
-    const lastHashtag = this.change.hashtag;
-    if (!this._newHashtag.length) { return; }
-    const newHashtag = this._newHashtag;
-    this._newHashtag = '';
-    this.$.restAPI.setChangeHashtag(
-        this.change._number, {add: [newHashtag]}).then(newHashtag => {
-      this.set(['change', 'hashtags'], newHashtag);
-      if (newHashtag !== lastHashtag) {
-        this.dispatchEvent(
-            new CustomEvent('hashtag-changed', {
-              bubbles: true, composed: true}));
-      }
-    });
-  }
-
-  _computeTopicReadOnly(mutable, change) {
-    return !mutable ||
-        !change ||
-        !change.actions ||
-        !change.actions.topic ||
-        !change.actions.topic.enabled;
-  }
-
-  _computeHashtagReadOnly(mutable, change) {
-    return !mutable ||
-        !change ||
-        !change.actions ||
-        !change.actions.hashtags ||
-        !change.actions.hashtags.enabled;
-  }
-
-  _computeAssigneeReadOnly(mutable, change) {
-    return !mutable ||
-        !change ||
-        !change.actions ||
-        !change.actions.assignee ||
-        !change.actions.assignee.enabled;
-  }
-
-  _computeTopicPlaceholder(_topicReadOnly) {
-    // Action items in Material Design are uppercase -- placeholder label text
-    // is sentence case.
-    return _topicReadOnly ? 'No topic' : 'ADD TOPIC';
-  }
-
-  _computeHashtagPlaceholder(_hashtagReadOnly) {
-    return _hashtagReadOnly ? '' : HASHTAG_ADD_MESSAGE;
-  }
-
-  _computeShowRequirements(change) {
-    if (change.status !== ChangeStatus.NEW) {
-      // TODO(maximeg) change this to display the stored
-      // requirements, once it is implemented server-side.
-      return false;
-    }
-    const hasRequirements = !!change.requirements &&
-        Object.keys(change.requirements).length > 0;
-    const hasLabels = !!change.labels &&
-        Object.keys(change.labels).length > 0;
-    return hasRequirements || hasLabels || !!change.work_in_progress;
-  }
-
-  /**
-   * @return {?Gerrit.PushCertificateValidation} object representing data for
-   *     the push validation.
-   */
-  _computePushCertificateValidation(serverConfig, change) {
-    if (!change || !serverConfig || !serverConfig.receive ||
-        !serverConfig.receive.enable_signed_push) {
-      return null;
-    }
-    const rev = change.revisions[change.current_revision];
-    if (!rev.push_certificate || !rev.push_certificate.key) {
-      return {
-        class: 'help',
-        icon: 'gr-icons:help',
-        message: 'This patch set was created without a push certificate',
-      };
-    }
-
-    const key = rev.push_certificate.key;
-    switch (key.status) {
-      case CertificateStatus.BAD:
-        return {
-          class: 'invalid',
-          icon: 'gr-icons:close',
-          message: this._problems('Push certificate is invalid', key),
-        };
-      case CertificateStatus.OK:
-        return {
-          class: 'notTrusted',
-          icon: 'gr-icons:info',
-          message: this._problems(
-              'Push certificate is valid, but key is not trusted', key),
-        };
-      case CertificateStatus.TRUSTED:
-        return {
-          class: 'trusted',
-          icon: 'gr-icons:check',
-          message: this._problems(
-              'Push certificate is valid and key is trusted', key),
-        };
-      default:
-        throw new Error(`unknown certificate status: ${key.status}`);
-    }
-  }
-
-  _problems(msg, key) {
-    if (!key || !key.problems || key.problems.length === 0) {
-      return msg;
-    }
-
-    return [msg + ':'].concat(key.problems).join('\n');
-  }
-
-  _computeShowRepoBranchTogether(repo, branch) {
-    return !!repo && !!branch && repo.length + branch.length < 40;
-  }
-
-  _computeProjectUrl(project) {
-    return GerritNav.getUrlForProjectChanges(project);
-  }
-
-  _computeBranchUrl(project, branch) {
-    if (!this.change || !this.change.status) return '';
-    return GerritNav.getUrlForBranch(branch, project,
-        this.change.status == ChangeStatus.NEW ? 'open' :
-          this.change.status.toLowerCase());
-  }
-
-  _computeCherryPickOfUrl(change, patchset, project) {
-    return GerritNav.getUrlForChangeById(change, project, patchset);
-  }
-
-  _computeTopicUrl(topic) {
-    return GerritNav.getUrlForTopic(topic);
-  }
-
-  _computeHashtagUrl(hashtag) {
-    return GerritNav.getUrlForHashtag(hashtag);
-  }
-
-  _handleTopicRemoved(e) {
-    const target = dom(e).rootTarget;
-    target.disabled = true;
-    this.$.restAPI.setChangeTopic(this.change._number, null)
-        .then(() => {
-          target.disabled = false;
-          this.set(['change', 'topic'], '');
-          this.dispatchEvent(
-              new CustomEvent('topic-changed',
-                  {bubbles: true, composed: true}));
-        })
-        .catch(err => {
-          target.disabled = false;
-          return;
-        });
-  }
-
-  _handleHashtagRemoved(e) {
-    e.preventDefault();
-    const target = dom(e).rootTarget;
-    target.disabled = true;
-    this.$.restAPI.setChangeHashtag(this.change._number,
-        {remove: [target.text]})
-        .then(newHashtag => {
-          target.disabled = false;
-          this.set(['change', 'hashtags'], newHashtag);
-        })
-        .catch(err => {
-          target.disabled = false;
-          return;
-        });
-  }
-
-  _computeIsWip(change) {
-    return !!change.work_in_progress;
-  }
-
-  _computeShowRoleClass(change, role) {
-    return this._getNonOwnerRole(change, role) ? '' : 'hideDisplay';
-  }
-
-  /**
-   * Get the user with the specified role on the change. Returns null if the
-   * user with that role is the same as the owner.
-   *
-   * @param {!Object} change
-   * @param {string} role One of the values from _CHANGE_ROLE
-   * @return {Object|null} either an account or null.
-   */
-  _getNonOwnerRole(change, role) {
-    if (!change || !change.current_revision ||
-        !change.revisions[change.current_revision]) {
-      return null;
-    }
-
-    const rev = change.revisions[change.current_revision];
-    if (!rev) { return null; }
-
-    if (role === this._CHANGE_ROLE.UPLOADER &&
-        rev.uploader &&
-        change.owner._account_id !== rev.uploader._account_id) {
-      return rev.uploader;
-    }
-
-    if (role === this._CHANGE_ROLE.AUTHOR &&
-        rev.commit && rev.commit.author &&
-        change.owner.email !== rev.commit.author.email) {
-      return rev.commit.author;
-    }
-
-    if (role === this._CHANGE_ROLE.COMMITTER &&
-        rev.commit && rev.commit.committer &&
-        change.owner.email !== rev.commit.committer.email) {
-      return rev.commit.committer;
-    }
-
-    return null;
-  }
-
-  _computeParents(change, revision) {
-    if (!revision || !revision.commit) {
-      if (!change || !change.current_revision) { return []; }
-      revision = change.revisions[change.current_revision];
-      if (!revision || !revision.commit) { return []; }
-    }
-    return revision.commit.parents;
-  }
-
-  _computeParentsLabel(parents) {
-    return parents && parents.length > 1 ? 'Parents' : 'Parent';
-  }
-
-  _computeParentListClass(parents, parentIsCurrent) {
-    // Undefined check for polymer 2
-    if (parents === undefined || parentIsCurrent === undefined) {
-      return '';
-    }
-
-    return [
-      'parentList',
-      parents && parents.length > 1 ? 'merge' : 'nonMerge',
-      parentIsCurrent ? 'current' : 'notCurrent',
-    ].join(' ');
-  }
-
-  _computeIsMutable(account) {
-    return !!Object.keys(account).length;
-  }
-
-  editTopic() {
-    if (this._topicReadOnly || this.change.topic) { return; }
-    // Cannot use `this.$.ID` syntax because the element exists inside of a
-    // dom-if.
-    this.shadowRoot.querySelector('.topicEditableLabel').open();
-  }
-
-  _getReviewerSuggestionsProvider(change) {
-    const provider = GrReviewerSuggestionsProvider.create(this.$.restAPI,
-        change._number, SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY);
-    provider.init();
-    return provider;
-  }
-}
-
-customElements.define(GrChangeMetadata.is, GrChangeMetadata);
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
new file mode 100644
index 0000000..6b04153
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -0,0 +1,741 @@
+/**
+ * @license
+ * 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.
+ */
+import '../../../styles/shared-styles';
+import '../../../styles/gr-change-metadata-shared-styles';
+import '../../../styles/gr-change-view-integration-shared-styles';
+import '../../../styles/gr-voting-styles';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../plugins/gr-external-style/gr-external-style';
+import '../../shared/gr-account-chip/gr-account-chip';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-editable-label/gr-editable-label';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-limited-text/gr-limited-text';
+import '../../shared/gr-linked-chip/gr-linked-chip';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-change-requirements/gr-change-requirements';
+import '../gr-commit-info/gr-commit-info';
+import '../gr-reviewer-list/gr-reviewer-list';
+import '../../shared/gr-account-list/gr-account-list';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-change-metadata_html';
+import {
+  GrReviewerSuggestionsProvider,
+  SUGGESTIONS_PROVIDERS_USERS_TYPES,
+} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {
+  ChangeStatus,
+  GpgKeyInfoStatus,
+  SubmitType,
+} from '../../../constants/constants';
+import {changeIsOpen} from '../../../utils/change-util';
+import {customElement, property, observe} from '@polymer/decorators';
+import {
+  EditRevisionInfo,
+  ParsedChangeInfo,
+} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {
+  AccountDetailInfo,
+  AccountInfo,
+  BranchName,
+  CommitId,
+  CommitInfo,
+  ElementPropertyDeepChange,
+  GpgKeyInfo,
+  Hashtag,
+  LabelNameToInfoMap,
+  NumericChangeId,
+  ParentCommitInfo,
+  PatchSetNum,
+  RepoName,
+  RevisionInfo,
+  ServerInfo,
+  TopicName,
+} from '../../../types/common';
+import {assertNever} from '../../../utils/common-util';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrEditableLabel} from '../../shared/gr-editable-label/gr-editable-label';
+import {GrLinkedChip} from '../../shared/gr-linked-chip/gr-linked-chip';
+import {appContext} from '../../../services/app-context';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {
+  Metadata,
+  isSectionSet,
+  DisplayRules,
+} from '../../../utils/change-metadata-util';
+
+const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
+
+enum ChangeRole {
+  OWNER = 'owner',
+  UPLOADER = 'uploader',
+  AUTHOR = 'author',
+  COMMITTER = 'committer',
+}
+
+export interface CommitInfoWithRequiredCommit extends CommitInfo {
+  // gr-change-view always assigns commit to CommitInfo
+  commit: CommitId;
+}
+
+const SubmitTypeLabel = new Map<SubmitType, string>([
+  [SubmitType.FAST_FORWARD_ONLY, 'Fast Forward Only'],
+  [SubmitType.MERGE_IF_NECESSARY, 'Merge if Necessary'],
+  [SubmitType.REBASE_IF_NECESSARY, 'Rebase if Necessary'],
+  [SubmitType.MERGE_ALWAYS, 'Always Merge'],
+  [SubmitType.REBASE_ALWAYS, 'Rebase Always'],
+  [SubmitType.CHERRY_PICK, 'Cherry Pick'],
+]);
+
+const NOT_CURRENT_MESSAGE = 'Not current - rebase possible';
+
+interface PushCertifacteValidationInfo {
+  class: string;
+  icon: string;
+  message: string;
+}
+
+export interface GrChangeMetadata {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+@customElement('gr-change-metadata')
+export class GrChangeMetadata extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the change topic is changed.
+   *
+   * @event topic-changed
+   */
+
+  @property({type: Object})
+  change?: ParsedChangeInfo;
+
+  @property({type: Object, notify: true})
+  labels?: LabelNameToInfoMap;
+
+  @property({type: Object})
+  account?: AccountDetailInfo;
+
+  @property({type: Object})
+  revision?: RevisionInfo | EditRevisionInfo;
+
+  @property({type: Object})
+  commitInfo?: CommitInfoWithRequiredCommit;
+
+  @property({type: Boolean, computed: '_computeIsMutable(account)'})
+  _mutable = false;
+
+  @property({type: Object})
+  serverConfig?: ServerInfo;
+
+  @property({type: Boolean})
+  parentIsCurrent?: boolean;
+
+  @property({type: String})
+  readonly _notCurrentMessage = NOT_CURRENT_MESSAGE;
+
+  @property({
+    type: Boolean,
+    computed: '_computeTopicReadOnly(_mutable, change)',
+  })
+  _topicReadOnly = true;
+
+  @property({
+    type: Boolean,
+    computed: '_computeHashtagReadOnly(_mutable, change)',
+  })
+  _hashtagReadOnly = true;
+
+  @property({
+    type: Object,
+    computed: '_computePushCertificateValidation(serverConfig, change)',
+  })
+  _pushCertificateValidation: PushCertifacteValidationInfo | null = null;
+
+  @property({type: Boolean, computed: '_computeShowRequirements(change)'})
+  _showRequirements = false;
+
+  @property({type: Array})
+  _assignee?: AccountInfo[];
+
+  @property({type: Boolean, computed: '_computeIsWip(change)'})
+  _isWip = false;
+
+  @property({type: String})
+  _newHashtag?: Hashtag;
+
+  @property({type: Boolean})
+  _settingTopic = false;
+
+  @property({type: Array, computed: '_computeParents(change, revision)'})
+  _currentParents: ParentCommitInfo[] = [];
+
+  @property({type: Object})
+  _CHANGE_ROLE = ChangeRole;
+
+  @property({type: Object})
+  _SECTION = Metadata;
+
+  @property({type: Boolean})
+  _showAllSections = false;
+
+  @property({type: Boolean})
+  _isNewChangeSummaryUiEnabled = false;
+
+  flagsService = appContext.flagsService;
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._isNewChangeSummaryUiEnabled = this.flagsService.isEnabled(
+      KnownExperimentId.NEW_CHANGE_SUMMARY_UI
+    );
+  }
+
+  @observe('change.labels')
+  _labelsChanged(labels?: LabelNameToInfoMap) {
+    this.labels = {...labels} || null;
+  }
+
+  @observe('change')
+  _changeChanged(change?: ParsedChangeInfo) {
+    this._assignee = change?.assignee ? [change.assignee] : [];
+    this._settingTopic = false;
+  }
+
+  @observe('_assignee.*')
+  _assigneeChanged(
+    assigneeRecord: ElementPropertyDeepChange<GrChangeMetadata, '_assignee'>
+  ) {
+    if (!this.change || !this._isAssigneeEnabled(this.serverConfig)) {
+      return;
+    }
+    const assignee = assigneeRecord.base;
+    if (assignee?.length) {
+      const acct = assignee[0];
+      if (
+        !acct._account_id ||
+        (this.change.assignee &&
+          acct._account_id === this.change.assignee._account_id)
+      ) {
+        return;
+      }
+      this.set(['change', 'assignee'], acct);
+      this.$.restAPI.setAssignee(this.change._number, acct._account_id);
+    } else {
+      if (!this.change.assignee) {
+        return;
+      }
+      this.set(['change', 'assignee'], undefined);
+      this.$.restAPI.deleteAssignee(this.change._number);
+    }
+  }
+
+  _computeHideStrategy(change?: ParsedChangeInfo) {
+    return !changeIsOpen(change);
+  }
+
+  /**
+   * @return If array is empty, returns null instead so
+   * an existential check can be used to hide or show the webLinks
+   * section.
+   */
+  _computeWebLinks(
+    commitInfo?: CommitInfoWithRequiredCommit,
+    serverConfig?: ServerInfo
+  ) {
+    if (!commitInfo) {
+      return null;
+    }
+    const weblinks = GerritNav.getChangeWeblinks(
+      this.change ? this.change.project : ('' as RepoName),
+      commitInfo.commit,
+      {
+        weblinks: commitInfo.web_links,
+        config: serverConfig,
+      }
+    );
+    return weblinks.length ? weblinks : null;
+  }
+
+  _isAssigneeEnabled(serverConfig?: ServerInfo) {
+    return (
+      serverConfig &&
+      serverConfig.change &&
+      !!serverConfig.change.enable_assignee
+    );
+  }
+
+  _computeStrategy(change?: ParsedChangeInfo) {
+    if (!change?.submit_type) {
+      return '';
+    }
+
+    return SubmitTypeLabel.get(change.submit_type);
+  }
+
+  _computeLabelNames(labels?: LabelNameToInfoMap) {
+    return labels ? Object.keys(labels).sort() : [];
+  }
+
+  _handleTopicChanged(e: CustomEvent<string>) {
+    if (!this.change) {
+      throw new Error('change must be set');
+    }
+    const lastTopic = this.change.topic;
+    const topic = e.detail.length ? e.detail : null;
+    this._settingTopic = true;
+    const topicChangedForChangeNumber = this.change._number;
+    this.$.restAPI
+      .setChangeTopic(topicChangedForChangeNumber, topic)
+      .then(newTopic => {
+        if (
+          !this.change ||
+          this.change._number !== topicChangedForChangeNumber
+        ) {
+          return;
+        }
+        this._settingTopic = false;
+        this.set(['change', 'topic'], newTopic);
+        if (newTopic !== lastTopic) {
+          this.dispatchEvent(
+            new CustomEvent('topic-changed', {bubbles: true, composed: true})
+          );
+        }
+      });
+  }
+
+  _showAddTopic(
+    changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
+    settingTopic?: boolean
+  ) {
+    const hasTopic =
+      !!changeRecord && !!changeRecord.base && !!changeRecord.base.topic;
+    return !hasTopic && !settingTopic;
+  }
+
+  _showTopicChip(
+    changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
+    settingTopic?: boolean
+  ) {
+    const hasTopic =
+      !!changeRecord && !!changeRecord.base && !!changeRecord.base.topic;
+    return hasTopic && !settingTopic;
+  }
+
+  _showCherryPickOf(
+    changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'>
+  ) {
+    const hasCherryPickOf =
+      !!changeRecord &&
+      !!changeRecord.base &&
+      !!changeRecord.base.cherry_pick_of_change &&
+      !!changeRecord.base.cherry_pick_of_patch_set;
+    return hasCherryPickOf;
+  }
+
+  _handleHashtagChanged() {
+    if (!this.change) {
+      throw new Error('change must be set');
+    }
+    if (!this._newHashtag?.length) {
+      return;
+    }
+    const newHashtag = this._newHashtag;
+    this._newHashtag = '' as Hashtag;
+    this.$.restAPI
+      .setChangeHashtag(this.change._number, {add: [newHashtag]})
+      .then(newHashtag => {
+        this.set(['change', 'hashtags'], newHashtag);
+        this.dispatchEvent(
+          new CustomEvent('hashtag-changed', {
+            bubbles: true,
+            composed: true,
+          })
+        );
+      });
+  }
+
+  _computeTopicReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
+    return (
+      !mutable ||
+      !change ||
+      !change.actions ||
+      !change.actions.topic ||
+      !change.actions.topic.enabled
+    );
+  }
+
+  _computeHashtagReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
+    return (
+      !mutable ||
+      !change ||
+      !change.actions ||
+      !change.actions.hashtags ||
+      !change.actions.hashtags.enabled
+    );
+  }
+
+  _computeAssigneeReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
+    return (
+      !mutable ||
+      !change ||
+      !change.actions ||
+      !change.actions.assignee ||
+      !change.actions.assignee.enabled
+    );
+  }
+
+  _computeTopicPlaceholder(_topicReadOnly?: boolean) {
+    // Action items in Material Design are uppercase -- placeholder label text
+    // is sentence case.
+    return _topicReadOnly ? 'No topic' : 'ADD TOPIC';
+  }
+
+  _computeHashtagPlaceholder(_hashtagReadOnly?: boolean) {
+    return _hashtagReadOnly ? '' : HASHTAG_ADD_MESSAGE;
+  }
+
+  _computeShowRequirements(change?: ParsedChangeInfo) {
+    if (!change) {
+      return false;
+    }
+    if (change.status !== ChangeStatus.NEW) {
+      // TODO(maximeg) change this to display the stored
+      // requirements, once it is implemented server-side.
+      return false;
+    }
+    const hasRequirements =
+      !!change.requirements && Object.keys(change.requirements).length > 0;
+    const hasLabels = !!change.labels && Object.keys(change.labels).length > 0;
+    return hasRequirements || hasLabels || !!change.work_in_progress;
+  }
+
+  /**
+   * @return object representing data for the push validation.
+   */
+  _computePushCertificateValidation(
+    serverConfig?: ServerInfo,
+    change?: ParsedChangeInfo
+  ): PushCertifacteValidationInfo | null {
+    if (
+      !change ||
+      !serverConfig ||
+      !serverConfig.receive ||
+      !serverConfig.receive.enable_signed_push
+    ) {
+      return null;
+    }
+    const rev = change.revisions[change.current_revision];
+    if (!rev.push_certificate || !rev.push_certificate.key) {
+      return {
+        class: 'help',
+        icon: 'gr-icons:help',
+        message: 'This patch set was created without a push certificate',
+      };
+    }
+
+    const key = rev.push_certificate.key;
+    switch (key.status) {
+      case GpgKeyInfoStatus.BAD:
+        return {
+          class: 'invalid',
+          icon: 'gr-icons:close',
+          message: this._problems('Push certificate is invalid', key),
+        };
+      case GpgKeyInfoStatus.OK:
+        return {
+          class: 'notTrusted',
+          icon: 'gr-icons:info',
+          message: this._problems(
+            'Push certificate is valid, but key is not trusted',
+            key
+          ),
+        };
+      case GpgKeyInfoStatus.TRUSTED:
+        return {
+          class: 'trusted',
+          icon: 'gr-icons:check',
+          message: this._problems(
+            'Push certificate is valid and key is trusted',
+            key
+          ),
+        };
+      case undefined:
+        // TODO(TS): Process it correctly
+        throw new Error('deleted certificate');
+      default:
+        assertNever(key.status, `unknown certificate status: ${key.status}`);
+    }
+  }
+
+  _problems(msg: string, key: GpgKeyInfo) {
+    if (!key || !key.problems || key.problems.length === 0) {
+      return msg;
+    }
+
+    return [msg + ':'].concat(key.problems).join('\n');
+  }
+
+  _computeShowRepoBranchTogether(repo?: RepoName, branch?: BranchName) {
+    return !!repo && !!branch && repo.length + branch.length < 40;
+  }
+
+  _computeProjectUrl(project?: RepoName) {
+    if (!project) return '';
+    return GerritNav.getUrlForProjectChanges(project);
+  }
+
+  _computeBranchUrl(project?: RepoName, branch?: BranchName) {
+    if (!project || !branch || !this.change || !this.change.status) return '';
+    return GerritNav.getUrlForBranch(
+      branch,
+      project,
+      this.change.status === ChangeStatus.NEW
+        ? 'open'
+        : this.change.status.toLowerCase()
+    );
+  }
+
+  _computeCherryPickOfUrl(
+    change?: NumericChangeId,
+    patchset?: PatchSetNum,
+    project?: RepoName
+  ) {
+    if (!change || !project) {
+      return '';
+    }
+    return GerritNav.getUrlForChangeById(change, project, patchset);
+  }
+
+  _computeTopicUrl(topic: TopicName) {
+    return GerritNav.getUrlForTopic(topic);
+  }
+
+  _computeHashtagUrl(hashtag: Hashtag) {
+    return GerritNav.getUrlForHashtag(hashtag);
+  }
+
+  _handleTopicRemoved(e: CustomEvent) {
+    if (!this.change) {
+      throw new Error('change must be set');
+    }
+    const target = (dom(e) as EventApi).rootTarget as GrLinkedChip;
+    target.disabled = true;
+    this.$.restAPI
+      .setChangeTopic(this.change._number, null)
+      .then(() => {
+        target.disabled = false;
+        this.set(['change', 'topic'], '');
+        this.dispatchEvent(
+          new CustomEvent('topic-changed', {bubbles: true, composed: true})
+        );
+      })
+      .catch(() => {
+        target.disabled = false;
+        return;
+      });
+  }
+
+  _handleHashtagRemoved(e: CustomEvent) {
+    e.preventDefault();
+    if (!this.change) {
+      throw new Error('change must be set');
+    }
+    const target = (dom(e) as EventApi).rootTarget as GrLinkedChip;
+    target.disabled = true;
+    this.$.restAPI
+      .setChangeHashtag(this.change._number, {remove: [target.text as Hashtag]})
+      .then(newHashtags => {
+        target.disabled = false;
+        this.set(['change', 'hashtags'], newHashtags);
+      })
+      .catch(() => {
+        target.disabled = false;
+        return;
+      });
+  }
+
+  _computeIsWip(change?: ParsedChangeInfo) {
+    return change && !!change.work_in_progress;
+  }
+
+  _computeShowRoleClass(change?: ParsedChangeInfo, role?: ChangeRole) {
+    return this._getNonOwnerRole(change, role) ? '' : 'hideDisplay';
+  }
+
+  _computeDisplayState(
+    showAllSections: boolean,
+    change: ParsedChangeInfo | undefined,
+    section: Metadata
+  ) {
+    if (
+      !this._isNewChangeSummaryUiEnabled ||
+      showAllSections ||
+      DisplayRules.ALWAYS_SHOW.includes(section) ||
+      (DisplayRules.SHOW_IF_SET.includes(section) &&
+        isSectionSet(section, change))
+    ) {
+      return '';
+    }
+    return 'hideDisplay';
+  }
+
+  _computeShowAllLabelText(showAllSections: boolean) {
+    if (showAllSections) {
+      return 'Show less';
+    } else {
+      return 'Show all';
+    }
+  }
+
+  _onShowAllClick() {
+    this._showAllSections = !this._showAllSections;
+  }
+
+  /**
+   * Get the user with the specified role on the change. Returns null if the
+   * user with that role is the same as the owner.
+   */
+  _getNonOwnerRole(change?: ParsedChangeInfo, role?: ChangeRole) {
+    if (
+      !change ||
+      !change.current_revision ||
+      !change.revisions[change.current_revision]
+    ) {
+      return null;
+    }
+
+    const rev = change.revisions[change.current_revision];
+    if (!rev) {
+      return null;
+    }
+
+    if (
+      role === ChangeRole.UPLOADER &&
+      rev.uploader &&
+      change.owner._account_id !== rev.uploader._account_id
+    ) {
+      return rev.uploader;
+    }
+
+    if (
+      role === ChangeRole.AUTHOR &&
+      rev.commit?.author &&
+      change.owner.email !== rev.commit.author.email
+    ) {
+      return rev.commit.author;
+    }
+
+    if (
+      role === ChangeRole.COMMITTER &&
+      rev.commit?.committer &&
+      change.owner.email !== rev.commit.committer.email &&
+      !(
+        rev.uploader?.email && rev.uploader.email === rev.commit.committer.email
+      )
+    ) {
+      return rev.commit.committer;
+    }
+
+    return null;
+  }
+
+  _computeParents(
+    change?: ParsedChangeInfo,
+    revision?: RevisionInfo | EditRevisionInfo
+  ): ParentCommitInfo[] {
+    if (!revision || !revision.commit) {
+      if (!change || !change.current_revision) {
+        return [];
+      }
+      revision = change.revisions[change.current_revision];
+      if (!revision || !revision.commit) {
+        return [];
+      }
+    }
+    return revision.commit.parents;
+  }
+
+  _computeParentsLabel(parents?: ParentCommitInfo[]) {
+    return parents && parents.length > 1 ? 'Parents' : 'Parent';
+  }
+
+  _computeParentListClass(
+    parents?: ParentCommitInfo[],
+    parentIsCurrent?: boolean
+  ) {
+    // Undefined check for polymer 2
+    if (parents === undefined || parentIsCurrent === undefined) {
+      return '';
+    }
+
+    return [
+      'parentList',
+      parents && parents.length > 1 ? 'merge' : 'nonMerge',
+      parentIsCurrent ? 'current' : 'notCurrent',
+    ].join(' ');
+  }
+
+  _computeIsMutable(account?: AccountDetailInfo) {
+    return account && !!Object.keys(account).length;
+  }
+
+  editTopic() {
+    if (this._topicReadOnly || !this.change || this.change.topic) {
+      return;
+    }
+    // Cannot use `this.$.ID` syntax because the element exists inside of a
+    // dom-if.
+    (this.shadowRoot!.querySelector(
+      '.topicEditableLabel'
+    ) as GrEditableLabel).open();
+  }
+
+  _getReviewerSuggestionsProvider(change?: ParsedChangeInfo) {
+    if (!change) {
+      return undefined;
+    }
+    const provider = GrReviewerSuggestionsProvider.create(
+      this.$.restAPI,
+      change._number,
+      SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY
+    );
+    provider.init();
+    return provider;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-metadata': GrChangeMetadata;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
index d79687c..88f7351 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
@@ -94,9 +94,31 @@
       --account-max-length: 120px;
       max-width: 285px;
     }
+    .metadata-title {
+      font-weight: var(--font-weight-bold);
+      color: var(--deemphasized-text-color);
+      padding-left: var(--metadata-horizontal-padding);
+    }
+    .metadata-header {
+      display: flex;
+      justify-content: space-between;
+    }
   </style>
   <gr-external-style id="externalStyle" name="change-metadata">
-    <section>
+    <template is="dom-if" if="[[_isNewChangeSummaryUiEnabled]]">
+      <div class="metadata-header">
+        <h3 class="metadata-title">Change Info</h3>
+        <gr-button
+          class="show-all-button"
+          on-click="_onShowAllClick"
+          no-uppercase=""
+          >[[_computeShowAllLabelText(_showAllSections)]]</gr-button
+        >
+      </div>
+    </template>
+    <section
+      class$="[[_computeDisplayState(_showAllSections, change, _SECTION.UPDATED)]]"
+    >
       <span class="title">Updated</span>
       <span class="value">
         <gr-date-formatter
@@ -105,7 +127,9 @@
         ></gr-date-formatter>
       </span>
     </section>
-    <section>
+    <section
+      class$="[[_computeDisplayState(_showAllSections, change, _SECTION.OWNER)]]"
+    >
       <span class="title">Owner</span>
       <span class="value">
         <gr-account-chip
@@ -156,7 +180,9 @@
       </span>
     </section>
     <template is="dom-if" if="[[_isAssigneeEnabled(serverConfig)]]">
-      <section class="assignee">
+      <section
+        class$="assignee [[_computeDisplayState(_showAllSections, change, _SECTION.ASSIGNEE)]]"
+      >
         <span class="title">Assignee</span>
         <span class="value">
           <gr-account-list
@@ -172,7 +198,9 @@
         </span>
       </section>
     </template>
-    <section>
+    <section
+      class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REVIEWERS)]]"
+    >
       <span class="title">Reviewers</span>
       <span class="value">
         <gr-reviewer-list
@@ -183,7 +211,9 @@
         ></gr-reviewer-list>
       </span>
     </section>
-    <section>
+    <section
+      class$="[[_computeDisplayState(_showAllSections, change, _SECTION.CC)]]"
+    >
       <span class="title">CC</span>
       <span class="value">
         <gr-reviewer-list
@@ -198,7 +228,9 @@
       is="dom-if"
       if="[[_computeShowRepoBranchTogether(change.project, change.branch)]]"
     >
-      <section>
+      <section
+        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REPO_BRANCH)]]"
+      >
         <span class="title">Repo | Branch</span>
         <span class="value">
           <a href$="[[_computeProjectUrl(change.project)]]"
@@ -215,7 +247,9 @@
       is="dom-if"
       if="[[!_computeShowRepoBranchTogether(change.project, change.branch)]]"
     >
-      <section>
+      <section
+        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REPO_BRANCH)]]"
+      >
         <span class="title">Repo</span>
         <span class="value">
           <a href$="[[_computeProjectUrl(change.project)]]">
@@ -226,7 +260,9 @@
           </a>
         </span>
       </section>
-      <section>
+      <section
+        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REPO_BRANCH)]]"
+      >
         <span class="title">Branch</span>
         <span class="value">
           <a href$="[[_computeBranchUrl(change.project, change.branch)]]">
@@ -238,7 +274,9 @@
         </span>
       </section>
     </template>
-    <section>
+    <section
+      class$="[[_computeDisplayState(_showAllSections, change, _SECTION.PARENT)]]"
+    >
       <span class="title">[[_computeParentsLabel(_currentParents)]]</span>
       <span class="value">
         <ol
@@ -262,7 +300,9 @@
         </ol>
       </span>
     </section>
-    <section class="topic">
+    <section
+      class$="topic [[_computeDisplayState(_showAllSections, change, _SECTION.TOPIC)]]"
+    >
       <span class="title">Topic</span>
       <span class="value">
         <template is="dom-if" if="[[_showTopicChip(change.*, _settingTopic)]]">
@@ -288,7 +328,9 @@
       </span>
     </section>
     <template is="dom-if" if="[[_showCherryPickOf(change.*)]]">
-      <section>
+      <section
+        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.CHERRY_PICK_OF)]]"
+      >
         <span class="title">Cherry pick of</span>
         <span class="value">
           <a
@@ -304,14 +346,16 @@
       </section>
     </template>
     <section
-      class="strategy"
+      class$="strategy [[_computeDisplayState(_showAllSections, change, _SECTION.STRATEGY)]]"
       hidden$="[[_computeHideStrategy(change)]]"
       hidden=""
     >
       <span class="title">Strategy</span>
       <span class="value">[[_computeStrategy(change)]]</span>
     </section>
-    <section class="hashtag">
+    <section
+      class$="hashtag [[_computeDisplayState(_showAllSections, change, _SECTION.HASHTAGS)]]"
+    >
       <span class="title">Hashtags</span>
       <span class="value">
         <template is="dom-repeat" items="[[change.hashtags]]">
@@ -338,6 +382,7 @@
       </span>
     </section>
     <div class="separatedSection">
+      <h3 class="assistive-tech-only">Label Scores</h3>
       <gr-change-requirements
         change="{{change}}"
         account="[[account]]"
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js
index bdcd480..5bdf105 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js
@@ -210,13 +210,13 @@
       test('_getNonOwnerRole that it does not return uploader', () => {
         // Set the uploader email to be the same as the owner.
         change.revisions.rev1.uploader._account_id = 1019328;
-        assert.isNull(element._getNonOwnerRole(change,
+        assert.isNotOk(element._getNonOwnerRole(change,
             element._CHANGE_ROLE.UPLOADER));
       });
 
       test('_getNonOwnerRole null for uploader with no current rev', () => {
         delete change.current_revision;
-        assert.isNull(element._getNonOwnerRole(change,
+        assert.isNotOk(element._getNonOwnerRole(change,
             element._CHANGE_ROLE.UPLOADER));
       });
 
@@ -235,33 +235,39 @@
 
     suite('role=committer', () => {
       test('_getNonOwnerRole for committer', () => {
+        change.revisions.rev1.uploader.email = 'ghh@def';
         assert.deepEqual(
             element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER),
             {email: 'ghi@def'});
       });
 
+      test('_getNonOwnerRole is null if committer is same as uploader', () => {
+        assert.isNotOk(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.COMMITTER));
+      });
+
       test('_getNonOwnerRole that it does not return committer', () => {
         // Set the committer email to be the same as the owner.
         change.revisions.rev1.commit.committer.email = 'abc@def';
-        assert.isNull(element._getNonOwnerRole(change,
+        assert.isNotOk(element._getNonOwnerRole(change,
             element._CHANGE_ROLE.COMMITTER));
       });
 
       test('_getNonOwnerRole null for committer with no current rev', () => {
         delete change.current_revision;
-        assert.isNull(element._getNonOwnerRole(change,
+        assert.isNotOk(element._getNonOwnerRole(change,
             element._CHANGE_ROLE.COMMITTER));
       });
 
       test('_getNonOwnerRole null for committer with no commit', () => {
         delete change.revisions.rev1.commit;
-        assert.isNull(element._getNonOwnerRole(change,
+        assert.isNotOk(element._getNonOwnerRole(change,
             element._CHANGE_ROLE.COMMITTER));
       });
 
       test('_getNonOwnerRole null for committer with no committer', () => {
         delete change.revisions.rev1.commit.committer;
-        assert.isNull(element._getNonOwnerRole(change,
+        assert.isNotOk(element._getNonOwnerRole(change,
             element._CHANGE_ROLE.COMMITTER));
       });
     });
@@ -276,25 +282,25 @@
       test('_getNonOwnerRole that it does not return author', () => {
         // Set the author email to be the same as the owner.
         change.revisions.rev1.commit.author.email = 'abc@def';
-        assert.isNull(element._getNonOwnerRole(change,
+        assert.isNotOk(element._getNonOwnerRole(change,
             element._CHANGE_ROLE.AUTHOR));
       });
 
       test('_getNonOwnerRole null for author with no current rev', () => {
         delete change.current_revision;
-        assert.isNull(element._getNonOwnerRole(change,
+        assert.isNotOk(element._getNonOwnerRole(change,
             element._CHANGE_ROLE.AUTHOR));
       });
 
       test('_getNonOwnerRole null for author with no commit', () => {
         delete change.revisions.rev1.commit;
-        assert.isNull(element._getNonOwnerRole(change,
+        assert.isNotOk(element._getNonOwnerRole(change,
             element._CHANGE_ROLE.AUTHOR));
       });
 
       test('_getNonOwnerRole null for author with no author', () => {
         delete change.revisions.rev1.commit.author;
-        assert.isNull(element._getNonOwnerRole(change,
+        assert.isNotOk(element._getNonOwnerRole(change,
             element._CHANGE_ROLE.AUTHOR));
       });
     });
@@ -425,6 +431,7 @@
     element.change = {
       current_revision: '456',
       revisions: {456: revision('111')},
+      owner: {},
     };
     element.revision = revision('222');
     assert.equal(element._currentParents[0].commit, '222');
@@ -686,7 +693,7 @@
       const newTopic = 'the new topic';
       sinon.stub(element.$.restAPI, 'setChangeTopic').returns(
           Promise.resolve(newTopic));
-      element._handleTopicChanged({}, newTopic);
+      element._handleTopicChanged({detail: newTopic});
       const topicChangedSpy = sinon.spy();
       element.addEventListener('topic-changed', topicChangedSpy);
       assert.isTrue(element.$.restAPI.setChangeTopic.calledWith(
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
index 7a99086..cdac00a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
@@ -130,7 +130,7 @@
     this._optionalLabels = [];
     this._requiredLabels = [];
 
-    for (const label in labels) {
+    for (const label of Object.keys(labels || {}).sort()) {
       if (!hasOwnProperty(labels, label)) {
         continue;
       }
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
deleted file mode 100644
index e317cf9..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ /dev/null
@@ -1,2347 +0,0 @@
-/**
- * @license
- * 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.
- */
-import '@polymer/paper-tabs/paper-tabs.js';
-import '../../../styles/shared-styles.js';
-import '../../diff/gr-comment-api/gr-comment-api.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
-import '../../shared/gr-account-link/gr-account-link.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-change-star/gr-change-star.js';
-import '../../shared/gr-change-status/gr-change-status.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-editable-content/gr-editable-content.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import '../../shared/gr-linked-text/gr-linked-text.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
-import '../../shared/revision-info/revision-info.js';
-import '../gr-change-actions/gr-change-actions.js';
-import '../gr-change-metadata/gr-change-metadata.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../gr-commit-info/gr-commit-info.js';
-import '../gr-download-dialog/gr-download-dialog.js';
-import '../gr-file-list-header/gr-file-list-header.js';
-import '../gr-file-list/gr-file-list.js';
-import '../gr-included-in-dialog/gr-included-in-dialog.js';
-import '../gr-messages-list/gr-messages-list.js';
-import '../gr-related-changes-list/gr-related-changes-list.js';
-import '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog.js';
-import '../gr-reply-dialog/gr-reply-dialog.js';
-import '../gr-thread-list/gr-thread-list.js';
-import '../gr-upload-help-dialog/gr-upload-help-dialog.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-change-view_html.js';
-import {KeyboardShortcutMixin, Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {GrEditConstants} from '../../edit/gr-edit-constants.js';
-import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
-import {getComputedStyleValue} from '../../../utils/dom-util.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {RevisionInfo} from '../../shared/revision-info/revision-info.js';
-import {PrimaryTab, SecondaryTab} from '../../../constants/constants.js';
-import {NO_ROBOT_COMMENTS_THREADS_MSG} from '../../../constants/messages.js';
-import {appContext} from '../../../services/app-context.js';
-import {ChangeStatus} from '../../../constants/constants.js';
-import {
-  computeAllPatchSets,
-  computeLatestPatchNum,
-  fetchChangeUpdates,
-  hasEditBasedOnCurrentPatchSet,
-  hasEditPatchsetLoaded,
-  patchNumEquals,
-  SPECIAL_PATCH_SET_NUM,
-} from '../../../utils/patch-set-util.js';
-import {changeStatuses, changeStatusString} from '../../../utils/change-util.js';
-import {EventType} from '../../plugins/gr-plugin-types.js';
-
-const CHANGE_ID_ERROR = {
-  MISMATCH: 'mismatch',
-  MISSING: 'missing',
-};
-const CHANGE_ID_REGEX_PATTERN =
-  /^(Change-Id\:\s|Link:.*\/id\/)(I[0-9a-f]{8,40})/gm;
-
-const MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
-const DEFAULT_NUM_FILES_SHOWN = 200;
-
-const REVIEWERS_REGEX = /^(R|CC)=/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 = '75em';
-
-// 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;
-
-const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
-
-const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
-
-const MSG_PREFIX = '#message-';
-
-const ReloadToastMessage = {
-  NEWER_REVISION: 'A newer patch set has been uploaded',
-  RESTORED: 'This change has been restored',
-  ABANDONED: 'This change has been abandoned',
-  MERGED: 'This change has been merged',
-  NEW_MESSAGE: 'There are new messages on this change',
-};
-
-const DiffViewMode = {
-  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-  UNIFIED: 'UNIFIED_DIFF',
-};
-
-const CHANGE_DATA_TIMING_LABEL = 'ChangeDataLoaded';
-const CHANGE_RELOAD_TIMING_LABEL = 'ChangeReloaded';
-const SEND_REPLY_TIMING_LABEL = 'SendReply';
-// Making the tab names more unique in case a plugin adds one with same name
-const ROBOT_COMMENTS_LIMIT = 10;
-
-// types used in this file
-/**
- * Type for the custom event to switch tab.
- *
- * @typedef {Object} SwitchTabEventDetail
- * @property {?string} tab - name of the tab to set as active, from custom event
- * @property {?boolean} scrollIntoView - scroll into the tab afterwards, from custom event
- * @property {?number} value - index of tab to set as active, from paper-tabs event
- */
-
-/**
- * @extends PolymerElement
- */
-class GrChangeView extends KeyboardShortcutMixin(
-    GestureEventListeners(LegacyElementMixin(PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-change-view'; }
-  /**
-   * Fired when the title of the page should change.
-   *
-   * @event title-change
-   */
-
-  /**
-   * Fired if an error occurs when fetching the change data.
-   *
-   * @event page-error
-   */
-
-  /**
-   * Fired if being logged in is required.
-   *
-   * @event show-auth-required
-   */
-
-  static get properties() {
-    return {
-    /**
-     * URL params passed from the router.
-     */
-      params: {
-        type: Object,
-        observer: '_paramsChanged',
-      },
-      /** @type {?} */
-      viewState: {
-        type: Object,
-        notify: true,
-        value() { return {}; },
-        observer: '_viewStateChanged',
-      },
-      backPage: String,
-      hasParent: Boolean,
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-      disableEdit: {
-        type: Boolean,
-        value: false,
-      },
-      disableDiffPrefs: {
-        type: Boolean,
-        value: false,
-      },
-      _diffPrefsDisabled: {
-        type: Boolean,
-        computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
-      },
-      _commentThreads: Array,
-      // TODO(taoalpha): Consider replacing diffDrafts
-      // with _draftCommentThreads everywhere, currently only
-      // replaced in reply-dialoig
-      _draftCommentThreads: {
-        type: Array,
-      },
-      _robotCommentThreads: {
-        type: Array,
-        computed: '_computeRobotCommentThreads(_commentThreads,'
-          + ' _currentRobotCommentsPatchSet, _showAllRobotComments)',
-      },
-      /** @type {?} */
-      _serverConfig: {
-        type: Object,
-        observer: '_startUpdateCheckTimer',
-      },
-      _diffPrefs: Object,
-      _numFilesShown: {
-        type: Number,
-        value: DEFAULT_NUM_FILES_SHOWN,
-        observer: '_numFilesShownChanged',
-      },
-      _account: {
-        type: Object,
-        value: {},
-      },
-      _prefs: Object,
-      /** @type {?} */
-      _changeComments: Object,
-      _canStartReview: {
-        type: Boolean,
-        computed: '_computeCanStartReview(_change)',
-      },
-      /** @type {?} */
-      _change: {
-        type: Object,
-        observer: '_changeChanged',
-      },
-      _revisionInfo: {
-        type: Object,
-        computed: '_getRevisionInfo(_change)',
-      },
-      /** @type {?} */
-      _commitInfo: Object,
-      _currentRevision: {
-        type: Object,
-        computed: '_computeCurrentRevision(_change.current_revision, ' +
-          '_change.revisions)',
-        observer: '_handleCurrentRevisionUpdate',
-      },
-      _files: Object,
-      _changeNum: String,
-      _diffDrafts: {
-        type: Object,
-        value() { return {}; },
-      },
-      _editingCommitMessage: {
-        type: Boolean,
-        value: false,
-      },
-      _hideEditCommitMessage: {
-        type: Boolean,
-        computed: '_computeHideEditCommitMessage(_loggedIn, ' +
-            '_editingCommitMessage, _change, _editMode, _commitCollapsed, ' +
-            '_commitCollapsible)',
-      },
-      _diffAgainst: String,
-      /** @type {?string} */
-      _latestCommitMessage: {
-        type: String,
-        value: '',
-      },
-      _constants: {
-        type: Object,
-        value: {
-          SecondaryTab,
-          PrimaryTab,
-        },
-      },
-      _messages: {
-        type: Object,
-        value: {
-          NO_ROBOT_COMMENTS_THREADS_MSG,
-        },
-      },
-      _lineHeight: Number,
-      _changeIdCommitMessageError: {
-        type: String,
-        computed:
-        '_computeChangeIdCommitMessageError(_latestCommitMessage, _change)',
-      },
-      /** @type {?} */
-      _patchRange: {
-        type: Object,
-      },
-      _filesExpanded: String,
-      _basePatchNum: String,
-      _selectedRevision: Object,
-      _currentRevisionActions: Object,
-      _allPatchSets: {
-        type: Array,
-        computed: '_computeAllPatchSets(_change, _change.revisions.*)',
-      },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-      },
-      _loading: Boolean,
-      /** @type {?} */
-      _projectConfig: Object,
-      _replyButtonLabel: {
-        type: String,
-        value: 'Reply',
-        computed: '_computeReplyButtonLabel(_diffDrafts.*, _canStartReview)',
-      },
-      _selectedPatchSet: String,
-      _shownFileCount: Number,
-      _initialLoadComplete: {
-        type: Boolean,
-        value: false,
-      },
-      _replyDisabled: {
-        type: Boolean,
-        value: true,
-        computed: '_computeReplyDisabled(_serverConfig)',
-      },
-      _changeStatus: {
-        type: String,
-        computed: '_changeStatusString(_change)',
-      },
-      _changeStatuses: {
-        type: String,
-        computed:
-        '_computeChangeStatusChips(_change, _mergeable, _submitEnabled)',
-      },
-      /** If false, then the "Show more" button was used to expand. */
-      _commitCollapsed: {
-        type: Boolean,
-        value: true,
-      },
-      /** Is the "Show more/less" button visible? */
-      _commitCollapsible: {
-        type: Boolean,
-        computed: '_computeCommitCollapsible(_latestCommitMessage)',
-      },
-      _relatedChangesCollapsed: {
-        type: Boolean,
-        value: true,
-      },
-      /** @type {?number} */
-      _updateCheckTimerHandle: Number,
-      _editMode: {
-        type: Boolean,
-        computed: '_computeEditMode(_patchRange.*, params.*)',
-      },
-      _showRelatedToggle: {
-        type: Boolean,
-        value: false,
-        observer: '_updateToggleContainerClass',
-      },
-      _parentIsCurrent: {
-        type: Boolean,
-        computed: '_isParentCurrent(_currentRevisionActions)',
-      },
-      _submitEnabled: {
-        type: Boolean,
-        computed: '_isSubmitEnabled(_currentRevisionActions)',
-      },
-
-      /** @type {?} */
-      _mergeable: {
-        type: Boolean,
-        value: undefined,
-      },
-      _showFileTabContent: {
-        type: Boolean,
-        value: true,
-      },
-      /** @type {Array<string>} */
-      _dynamicTabHeaderEndpoints: {
-        type: Array,
-      },
-      /** @type {Array<string>} */
-      _dynamicTabContentEndpoints: {
-        type: Array,
-      },
-      // The dynamic content of the plugin added tab
-      _selectedTabPluginEndpoint: {
-        type: String,
-      },
-      // The dynamic heading of the plugin added tab
-      _selectedTabPluginHeader: {
-        type: String,
-      },
-      _robotCommentsPatchSetDropdownItems: {
-        type: Array,
-        value() { return []; },
-        computed: '_computeRobotCommentsPatchSetDropdownItems(_change, ' +
-          '_commentThreads)',
-      },
-      _currentRobotCommentsPatchSet: {
-        type: Number,
-      },
-
-      /**
-       * @type {Array<string>} this is a two-element tuple to always
-       * hold the current active tab for both primary and secondary tabs
-       */
-      _activeTabs: {
-        type: Array,
-        value: [PrimaryTab.FILES, SecondaryTab.CHANGE_LOG],
-      },
-      _showAllRobotComments: {
-        type: Boolean,
-        value: false,
-      },
-      _showRobotCommentsButton: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_labelsChanged(_change.labels.*)',
-      '_paramsAndChangeChanged(params, _change)',
-      '_patchNumChanged(_patchRange.patchNum)',
-    ];
-  }
-
-  keyboardShortcuts() {
-    return {
-      [Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
-      [Shortcut.EMOJI_DROPDOWN]: null, // DOC_ONLY binding
-      [Shortcut.REFRESH_CHANGE]: '_handleRefreshChange',
-      [Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
-      [Shortcut.OPEN_DOWNLOAD_DIALOG]:
-          '_handleOpenDownloadDialogShortcut',
-      [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
-      [Shortcut.TOGGLE_CHANGE_STAR]: '_throttledToggleChangeStar',
-      [Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
-      [Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
-      [Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
-      [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_expandAllDiffs',
-      [Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
-      [Shortcut.EDIT_TOPIC]: '_handleEditTopic',
-      [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
-      [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
-      [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
-      [Shortcut.DIFF_RIGHT_AGAINST_LATEST]:
-        '_handleDiffRightAgainstLatest',
-      [Shortcut.DIFF_BASE_AGAINST_LATEST]:
-        '_handleDiffBaseAgainstLatest',
-    };
-  }
-
-  constructor() {
-    super();
-    this.reporting = appContext.reportingService;
-  }
-
-  connectedCallback() {
-    super.connectedCallback();
-    this._throttledToggleChangeStar = this._throttleWrap(e =>
-      this._handleToggleChangeStar(e));
-  }
-
-  /** @override */
-  created() {
-    super.created();
-
-    this.addEventListener('topic-changed',
-        () => this._handleTopicChanged());
-
-    this.addEventListener(
-        // When an overlay is opened in a mobile viewport, the overlay has a full
-        // screen view. When it has a full screen view, we do not want the
-        // background to be scrollable. This will eliminate background scroll by
-        // hiding most of the contents on the screen upon opening, and showing
-        // again upon closing.
-        'fullscreen-overlay-opened',
-        () => this._handleHideBackgroundContent());
-
-    this.addEventListener('fullscreen-overlay-closed',
-        () => this._handleShowBackgroundContent());
-
-    this.addEventListener('diff-comments-modified',
-        () => this._handleReloadCommentThreads());
-
-    this.addEventListener('open-reply-dialog',
-        e => this._openReplyDialog());
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._getServerConfig().then(config => {
-      this._serverConfig = config;
-    });
-
-    this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
-      if (loggedIn) {
-        this.$.restAPI.getAccount().then(acct => {
-          this._account = acct;
-        });
-      }
-      this._setDiffViewMode();
-    });
-
-    getPluginLoader().awaitPluginsLoaded()
-        .then(() => {
-          this._dynamicTabHeaderEndpoints =
-            getPluginEndpoints().getDynamicEndpoints('change-view-tab-header');
-          this._dynamicTabContentEndpoints =
-            getPluginEndpoints().getDynamicEndpoints('change-view-tab-content');
-          if (this._dynamicTabContentEndpoints.length !==
-          this._dynamicTabHeaderEndpoints.length) {
-            console.warn('Different number of tab headers and tab content.');
-          }
-        })
-        .then(() => this._initActiveTabs(this.params));
-
-    this.addEventListener('comment-save', e => this._handleCommentSave(e));
-    this.addEventListener('comment-refresh', e => this._reloadDrafts(e));
-    this.addEventListener('comment-discard',
-        e => this._handleCommentDiscard(e));
-    this.addEventListener('change-message-deleted',
-        () => this._reload());
-    this.addEventListener('editable-content-save',
-        e => this._handleCommitMessageSave(e));
-    this.addEventListener('editable-content-cancel',
-        e => this._handleCommitMessageCancel(e));
-    this.addEventListener('open-fix-preview',
-        e => this._onOpenFixPreview(e));
-    this.addEventListener('close-fix-preview',
-        e => this._onCloseFixPreview(e));
-    this.listen(window, 'scroll', '_handleScroll');
-    this.listen(document, 'visibilitychange', '_handleVisibilityChange');
-
-    this.addEventListener('show-primary-tab',
-        e => this._setActivePrimaryTab(e));
-    this.addEventListener('show-secondary-tab',
-        e => this._setActiveSecondaryTab(e));
-    this.addEventListener('reload', e => {
-      e.stopPropagation();
-      this._reload(/* opt_isLocationChange= */false,
-          /* opt_clearPatchset= */e.detail && e.detail.clearPatchset);
-    });
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-    this.unlisten(window, 'scroll', '_handleScroll');
-    this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
-
-    if (this._updateCheckTimerHandle) {
-      this._cancelUpdateCheckTimer();
-    }
-  }
-
-  get messagesList() {
-    return this.shadowRoot.querySelector('gr-messages-list');
-  }
-
-  get threadList() {
-    return this.shadowRoot.querySelector('gr-thread-list');
-  }
-
-  _changeStatusString(change) {
-    return changeStatusString(change);
-  }
-
-  /**
-   * @param {boolean=} opt_reset
-   */
-  _setDiffViewMode(opt_reset) {
-    if (!opt_reset && this.viewState.diffViewMode) { return; }
-
-    return this._getPreferences()
-        .then( prefs => {
-          if (!this.viewState.diffMode) {
-            this.set('viewState.diffMode', prefs.default_diff_view);
-          }
-        })
-        .then(() => {
-          if (!this.viewState.diffMode) {
-            this.set('viewState.diffMode', 'SIDE_BY_SIDE');
-          }
-        });
-  }
-
-  _onOpenFixPreview(e) {
-    this.$.applyFixDialog.open(e);
-  }
-
-  _onCloseFixPreview(e) {
-    this._reload();
-  }
-
-  _handleToggleDiffMode(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    if (this.viewState.diffMode === DiffViewMode.SIDE_BY_SIDE) {
-      this.$.fileListHeader.setDiffViewMode(DiffViewMode.UNIFIED);
-    } else {
-      this.$.fileListHeader.setDiffViewMode(DiffViewMode.SIDE_BY_SIDE);
-    }
-  }
-
-  _isTabActive(tab, activeTabs) {
-    return activeTabs.includes(tab);
-  }
-
-  /**
-   * Actual implementation of switching a tab
-   *
-   * @param {!HTMLElement} paperTabs - the parent tabs container
-   * @param {!SwitchTabEventDetail} activeDetails
-   */
-  _setActiveTab(paperTabs, activeDetails) {
-    const {activeTabName, activeTabIndex, scrollIntoView} = activeDetails;
-    const tabs = paperTabs.querySelectorAll('paper-tab');
-    let activeIndex = -1;
-    if (activeTabIndex !== undefined) {
-      activeIndex = activeTabIndex;
-    } else {
-      for (let i = 0; i <= tabs.length; i++) {
-        const tab = tabs[i];
-        if (tab.dataset['name'] === activeTabName) {
-          activeIndex = i;
-          break;
-        }
-      }
-    }
-    if (activeIndex === -1) {
-      console.warn('tab not found with given info', activeDetails);
-      return;
-    }
-    const tabName = tabs[activeIndex].dataset['name'];
-    if (scrollIntoView) {
-      paperTabs.scrollIntoView();
-    }
-    if (paperTabs.selected !== activeIndex) {
-      paperTabs.selected = activeIndex;
-      this.reporting.reportInteraction('show-tab', {tabName});
-    }
-    return tabName;
-  }
-
-  /**
-   * Changes active primary tab.
-   *
-   * @param {CustomEvent<SwitchTabEventDetail>} e
-   */
-  _setActivePrimaryTab(e) {
-    const primaryTabs = this.shadowRoot.querySelector('#primaryTabs');
-    const activeTabName = this._setActiveTab(primaryTabs, {
-      activeTabName: e.detail.tab,
-      activeTabIndex: e.detail.value,
-      scrollIntoView: e.detail.scrollIntoView,
-    });
-    if (activeTabName) {
-      this._activeTabs = [activeTabName, this._activeTabs[1]];
-
-      // update plugin endpoint if its a plugin tab
-      const pluginIndex = (this._dynamicTabHeaderEndpoints || []).indexOf(
-          activeTabName);
-      if (pluginIndex !== -1) {
-        this._selectedTabPluginEndpoint = this._dynamicTabContentEndpoints[
-            pluginIndex];
-        this._selectedTabPluginHeader = this._dynamicTabHeaderEndpoints[
-            pluginIndex];
-      } else {
-        this._selectedTabPluginEndpoint = '';
-        this._selectedTabPluginHeader = '';
-      }
-    }
-  }
-
-  /**
-   * Changes active secondary tab.
-   *
-   * @param {CustomEvent<SwitchTabEventDetail>} e
-   */
-  _setActiveSecondaryTab(e) {
-    const secondaryTabs = this.shadowRoot.querySelector('#secondaryTabs');
-    const activeTabName = this._setActiveTab(secondaryTabs, {
-      activeTabName: e.detail.tab,
-      activeTabIndex: e.detail.value,
-      scrollIntoView: e.detail.scrollIntoView,
-    });
-    if (activeTabName) {
-      this._activeTabs = [this._activeTabs[0], activeTabName];
-    }
-  }
-
-  _handleEditCommitMessage() {
-    this._editingCommitMessage = true;
-    this.$.commitMessageEditor.focusTextarea();
-  }
-
-  _handleCommitMessageSave(e) {
-    // Trim trailing whitespace from each line.
-    const message = e.detail.content.replace(TRAILING_WHITESPACE_REGEX, '');
-
-    this.$.jsAPI.handleCommitMessage(this._change, message);
-
-    this.$.commitMessageEditor.disabled = true;
-    this.$.restAPI.putChangeCommitMessage(
-        this._changeNum, message)
-        .then(resp => {
-          this.$.commitMessageEditor.disabled = false;
-          if (!resp.ok) { return; }
-
-          this._latestCommitMessage = this._prepareCommitMsgForLinkify(
-              message);
-          this._editingCommitMessage = false;
-          this._reloadWindow();
-        })
-        .catch(err => {
-          this.$.commitMessageEditor.disabled = false;
-        });
-  }
-
-  _reloadWindow() {
-    window.location.reload();
-  }
-
-  _handleCommitMessageCancel(e) {
-    this._editingCommitMessage = false;
-  }
-
-  _computeChangeStatusChips(change, mergeable, submitEnabled) {
-    // Polymer 2: check for undefined
-    if ([
-      change,
-      mergeable,
-    ].includes(undefined)) {
-      // To keep consistent with Polymer 1, we are returning undefined
-      // if not all dependencies are defined
-      return undefined;
-    }
-
-    // Show no chips until mergeability is loaded.
-    if (mergeable === null) {
-      return [];
-    }
-
-    const options = {
-      includeDerived: true,
-      mergeable: !!mergeable,
-      submitEnabled: !!submitEnabled,
-    };
-    return changeStatuses(change, options);
-  }
-
-  _computeHideEditCommitMessage(
-      loggedIn, editing, change, editMode, collapsed, collapsible) {
-    if (!loggedIn || editing ||
-        (change && change.status === ChangeStatus.MERGED) ||
-        editMode ||
-        (collapsed && collapsible)) {
-      return true;
-    }
-
-    return false;
-  }
-
-  _robotCommentCountPerPatchSet(threads) {
-    return threads.reduce((robotCommentCountMap, thread) => {
-      const comments = thread.comments;
-      const robotCommentsCount = comments.reduce((acc, comment) =>
-        (comment.robot_id ? acc + 1 : acc), 0);
-      robotCommentCountMap[comments[0].patch_set] =
-          (robotCommentCountMap[comments[0].patch_set] || 0) +
-        robotCommentsCount;
-      return robotCommentCountMap;
-    }, {});
-  }
-
-  _computeText(patch, commentThreads) {
-    const commentCount = this._robotCommentCountPerPatchSet(commentThreads);
-    const commentCnt = commentCount[patch._number] || 0;
-    if (commentCnt === 0) return `Patchset ${patch._number}`;
-    const findingsText = commentCnt === 1 ? 'finding' : 'findings';
-    return `Patchset ${patch._number}`
-            + ` (${commentCnt} ${findingsText})`;
-  }
-
-  _computeRobotCommentsPatchSetDropdownItems(change, commentThreads) {
-    if (!change || !commentThreads || !change.revisions) return [];
-
-    return Object.values(change.revisions)
-        .filter(patch => patch._number !== 'edit')
-        .map(patch => {
-          return {
-            text: this._computeText(patch, commentThreads),
-            value: patch._number,
-          };
-        })
-        .sort((a, b) => b.value - a.value);
-  }
-
-  _handleCurrentRevisionUpdate(currentRevision) {
-    this._currentRobotCommentsPatchSet = currentRevision._number;
-  }
-
-  _handleRobotCommentPatchSetChanged(e) {
-    const patchSet = parseInt(e.detail.value);
-    if (patchSet === this._currentRobotCommentsPatchSet) return;
-    this._currentRobotCommentsPatchSet = patchSet;
-  }
-
-  _computeShowText(showAllRobotComments) {
-    return showAllRobotComments ? 'Show Less' : 'Show more';
-  }
-
-  _toggleShowRobotComments() {
-    this._showAllRobotComments = !this._showAllRobotComments;
-  }
-
-  _computeRobotCommentThreads(commentThreads, currentRobotCommentsPatchSet,
-      showAllRobotComments) {
-    if (!commentThreads || !currentRobotCommentsPatchSet) return [];
-    const threads = commentThreads.filter(thread => {
-      const comments = thread.comments || [];
-      return comments.length && comments[0].robot_id && (comments[0].patch_set
-        === currentRobotCommentsPatchSet);
-    });
-    this._showRobotCommentsButton = threads.length > ROBOT_COMMENTS_LIMIT;
-    return threads.slice(0, showAllRobotComments ? undefined :
-      ROBOT_COMMENTS_LIMIT);
-  }
-
-  _handleReloadCommentThreads() {
-    // Get any new drafts that have been saved in the diff view and show
-    // in the comment thread view.
-    this._reloadDrafts().then(() => {
-      this._commentThreads = this._changeComments.getAllThreadsForChange();
-      flush();
-    });
-  }
-
-  _handleReloadDiffComments(e) {
-    // Keeps the file list counts updated.
-    this._reloadDrafts().then(() => {
-      // Get any new drafts that have been saved in the thread view and show
-      // in the diff view.
-      this.$.fileList.reloadCommentsForThreadWithRootId(e.detail.rootId,
-          e.detail.path);
-      flush();
-    });
-  }
-
-  _computeTotalCommentCounts(unresolvedCount, changeComments) {
-    if (!changeComments) return undefined;
-    const draftCount = changeComments.computeDraftCount();
-    const unresolvedString = GrCountStringFormatter.computeString(
-        unresolvedCount, 'unresolved');
-    const draftString = GrCountStringFormatter.computePluralString(
-        draftCount, 'draft');
-
-    return unresolvedString +
-        // Add a comma and space if both unresolved and draft comments exist.
-        (unresolvedString && draftString ? ', ' : '') +
-        draftString;
-  }
-
-  _handleCommentSave(e) {
-    const draft = e.detail.comment;
-    if (!draft.__draft) { return; }
-
-    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
-    const diffDrafts = {...this._diffDrafts};
-    if (!diffDrafts[draft.path]) {
-      diffDrafts[draft.path] = [draft];
-      this._diffDrafts = diffDrafts;
-      return;
-    }
-    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;
-        return;
-      }
-    }
-    diffDrafts[draft.path].push(draft);
-    diffDrafts[draft.path].sort((c1, c2) =>
-      // No line number means that it’s a file comment. Sort it above the
-      // others.
-      (c1.line || -1) - (c2.line || -1)
-    );
-    this._diffDrafts = diffDrafts;
-  }
-
-  _handleCommentDiscard(e) {
-    const draft = e.detail.comment;
-    if (!draft.__draft) { return; }
-
-    if (!this._diffDrafts[draft.path]) {
-      return;
-    }
-    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;
-      }
-    }
-    if (index === -1) {
-      // It may be a draft that hasn’t been added to _diffDrafts since it was
-      // never saved.
-      return;
-    }
-
-    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
-    const diffDrafts = {...this._diffDrafts};
-    diffDrafts[draft.path].splice(index, 1);
-    if (diffDrafts[draft.path].length === 0) {
-      delete diffDrafts[draft.path];
-    }
-    this._diffDrafts = diffDrafts;
-  }
-
-  _handleReplyTap(e) {
-    e.preventDefault();
-    this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
-  }
-
-  _handleOpenDiffPrefs() {
-    this.$.fileList.openDiffPrefs();
-  }
-
-  _handleOpenIncludedInDialog() {
-    this.$.includedInDialog.loadData().then(() => {
-      flush();
-      this.$.includedInOverlay.refit();
-    });
-    this.$.includedInOverlay.open();
-  }
-
-  _handleIncludedInDialogClose(e) {
-    this.$.includedInOverlay.close();
-  }
-
-  _handleOpenDownloadDialog() {
-    this.$.downloadOverlay.open().then(() => {
-      this.$.downloadOverlay
-          .setFocusStops(this.$.downloadDialog.getFocusStops());
-      this.$.downloadDialog.focus();
-    });
-  }
-
-  _handleDownloadDialogClose(e) {
-    this.$.downloadOverlay.close();
-  }
-
-  _handleOpenUploadHelpDialog(e) {
-    this.$.uploadHelpOverlay.open();
-  }
-
-  _handleCloseUploadHelpDialog(e) {
-    this.$.uploadHelpOverlay.close();
-  }
-
-  _handleMessageReply(e) {
-    const msg = e.detail.message.message;
-    const quoteStr = msg.split('\n').map(
-        line => '> ' + line)
-        .join('\n') + '\n\n';
-    this.$.replyDialog.quote = quoteStr;
-    this._openReplyDialog(this.$.replyDialog.FocusTarget.BODY);
-  }
-
-  _handleHideBackgroundContent() {
-    this.$.mainContent.classList.add('overlayOpen');
-  }
-
-  _handleShowBackgroundContent() {
-    this.$.mainContent.classList.remove('overlayOpen');
-  }
-
-  _handleReplySent(e) {
-    this.addEventListener('change-details-loaded',
-        () => {
-          this.reporting.timeEnd(SEND_REPLY_TIMING_LABEL);
-        }, {once: true});
-    this.$.replyOverlay.close();
-    this._reload();
-  }
-
-  _handleReplyCancel(e) {
-    this.$.replyOverlay.close();
-  }
-
-  _handleReplyAutogrow(e) {
-    // If the textarea resizes, we need to re-fit the overlay.
-    this.debounce('reply-overlay-refit', () => {
-      this.$.replyOverlay.refit();
-    }, REPLY_REFIT_DEBOUNCE_INTERVAL_MS);
-  }
-
-  _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() {
-    this.debounce('scroll', () => {
-      this.viewState.scrollTop = document.body.scrollTop;
-    }, 150);
-  }
-
-  _setShownFiles(e) {
-    this._shownFileCount = e.detail.length;
-  }
-
-  _expandAllDiffs(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    this.$.fileList.expandAllDiffs();
-  }
-
-  _collapseAllDiffs() {
-    this.$.fileList.collapseAllDiffs();
-  }
-
-  _paramsChanged(value) {
-    if (value.view !== GerritNav.View.CHANGE) {
-      this._initialLoadComplete = false;
-      return;
-    }
-
-    if (value.changeNum && value.project) {
-      this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
-    }
-
-    const patchChanged = this._patchRange &&
-        (value.patchNum !== undefined && value.basePatchNum !== undefined) &&
-        (this._patchRange.patchNum !== value.patchNum ||
-        this._patchRange.basePatchNum !== value.basePatchNum);
-    const changeChanged = this._changeNum !== value.changeNum;
-
-    const patchRange = {
-      patchNum: value.patchNum,
-      basePatchNum: value.basePatchNum || 'PARENT',
-    };
-
-    this.$.fileList.collapseAllDiffs();
-    this._patchRange = patchRange;
-
-    // If the change has already been loaded and the parameter change is only
-    // in the patch range, then don't do a full reload.
-    if (!changeChanged && patchChanged) {
-      if (patchRange.patchNum == null) {
-        patchRange.patchNum = computeLatestPatchNum(this._allPatchSets);
-      }
-      this._reloadPatchNumDependentResources().then(() => {
-        this._sendShowChangeEvent();
-      });
-      return;
-    }
-
-    this._initialLoadComplete = false;
-    this._changeNum = value.changeNum;
-    this.$.relatedChanges.clear();
-
-    this._reload(true).then(() => {
-      this._performPostLoadTasks();
-    });
-
-    getPluginLoader().awaitPluginsLoaded()
-        .then(() => {
-          this._initActiveTabs(value);
-        });
-  }
-
-  _initActiveTabs(params = {}) {
-    let primaryTab = PrimaryTab.FILES;
-    if (params.queryMap && params.queryMap.has('tab')) {
-      primaryTab = params.queryMap.get('tab');
-    }
-    this._setActivePrimaryTab({
-      detail: {
-        tab: primaryTab,
-      },
-    });
-    this._setActiveSecondaryTab({
-      detail: {
-        tab: SecondaryTab.CHANGE_LOG,
-      },
-    });
-  }
-
-  _sendShowChangeEvent() {
-    this.$.jsAPI.handleEvent(EventType.SHOW_CHANGE, {
-      change: this._change,
-      patchNum: this._patchRange.patchNum,
-      info: {mergeable: this._mergeable},
-    });
-  }
-
-  _performPostLoadTasks() {
-    this._maybeShowReplyDialog();
-    this._maybeShowRevertDialog();
-    this._maybeShowDownloadDialog();
-
-    this._sendShowChangeEvent();
-
-    this.async(() => {
-      if (this.viewState.scrollTop) {
-        document.documentElement.scrollTop =
-            document.body.scrollTop = this.viewState.scrollTop;
-      } else {
-        this._maybeScrollToMessage(window.location.hash);
-      }
-      this._initialLoadComplete = true;
-    });
-  }
-
-  _paramsAndChangeChanged(value, change) {
-    // Polymer 2: check for undefined
-    if ([value, change].includes(undefined)) {
-      return;
-    }
-
-    // If the change number or patch range is different, then reset the
-    // selected file index.
-    const patchRangeState = this.viewState.patchRange;
-    if (this.viewState.changeNum !== this._changeNum ||
-        !patchRangeState ||
-        patchRangeState.basePatchNum !== this._patchRange.basePatchNum ||
-        patchRangeState.patchNum !== this._patchRange.patchNum) {
-      this._resetFileListViewState();
-    }
-  }
-
-  _viewStateChanged(viewState) {
-    this._numFilesShown = viewState.numFilesShown ?
-      viewState.numFilesShown : DEFAULT_NUM_FILES_SHOWN;
-  }
-
-  _numFilesShownChanged(numFilesShown) {
-    this.viewState.numFilesShown = numFilesShown;
-  }
-
-  _handleMessageAnchorTap(e) {
-    const hash = MSG_PREFIX + e.detail.id;
-    const url = GerritNav.getUrlForChange(this._change,
-        this._patchRange.patchNum, this._patchRange.basePatchNum,
-        this._editMode, hash);
-    history.replaceState(null, '', url);
-  }
-
-  _maybeScrollToMessage(hash) {
-    if (hash.startsWith(MSG_PREFIX)) {
-      this.messagesList.scrollToMessage(hash.substr(MSG_PREFIX.length));
-    }
-  }
-
-  _getLocationSearch() {
-    // Not inlining to make it easier to test.
-    return window.location.search;
-  }
-
-  _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];
-      }
-    }
-    return null;
-  }
-
-  _maybeShowRevertDialog() {
-    getPluginLoader().awaitPluginsLoaded()
-        .then(() => this._getLoggedIn())
-        .then(loggedIn => {
-          if (!loggedIn || !this._change ||
-              this._change.status !== ChangeStatus.MERGED) {
-          // Do not display dialog if not logged-in or the change is not
-          // merged.
-            return;
-          }
-          if (this._getUrlParameter('revert')) {
-            this.$.actions.showRevertDialog();
-          }
-        });
-  }
-
-  _maybeShowReplyDialog() {
-    this._getLoggedIn().then(loggedIn => {
-      if (!loggedIn) { return; }
-
-      if (this.viewState.showReplyDialog) {
-        this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
-        // 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);
-      }
-    });
-  }
-
-  _maybeShowDownloadDialog() {
-    if (this.viewState.showDownloadDialog) {
-      this._handleOpenDownloadDialog();
-      this.set('viewState.showDownloadDialog', false);
-    }
-  }
-
-  _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._setDiffViewMode(true);
-      this.set('_numFilesShown', DEFAULT_NUM_FILES_SHOWN);
-    }
-    this.set('viewState.changeNum', this._changeNum);
-    this.set('viewState.patchRange', this._patchRange);
-  }
-
-  _changeChanged(change) {
-    if (!change || !this._patchRange || !this._allPatchSets) { return; }
-
-    // We get the parent first so we keep the original value for basePatchNum
-    // and not the updated value.
-    const parent = this._getBasePatchNum(change, this._patchRange);
-
-    this.set('_patchRange.patchNum', this._patchRange.patchNum ||
-            computeLatestPatchNum(this._allPatchSets));
-
-    this.set('_patchRange.basePatchNum', parent);
-
-    const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
-    this.dispatchEvent(new CustomEvent('title-change', {
-      detail: {title},
-      composed: true, bubbles: true,
-    }));
-  }
-
-  /**
-   * Gets base patch number, if it is a parent try and decide from
-   * preference whether to default to `auto merge`, `Parent 1` or `PARENT`.
-   *
-   * @param {Object} change
-   * @param {Object} patchRange
-   * @return {number|string}
-   */
-  _getBasePatchNum(change, patchRange) {
-    if (patchRange.basePatchNum &&
-        patchRange.basePatchNum !== 'PARENT') {
-      return patchRange.basePatchNum;
-    }
-
-    const revisionInfo = this._getRevisionInfo(change);
-    if (!revisionInfo) return 'PARENT';
-
-    const parentCounts = revisionInfo.getParentCountMap();
-    // check that there is at least 2 parents otherwise fall back to 1,
-    // which means there is only one parent.
-    const parentCount = parentCounts.hasOwnProperty(1) ?
-      parentCounts[1] : 1;
-
-    const preferFirst = this._prefs &&
-        this._prefs.default_base_for_merges === 'FIRST_PARENT';
-
-    if (parentCount > 1 && preferFirst && !patchRange.patchNum) {
-      return -1;
-    }
-
-    return 'PARENT';
-  }
-
-  _computeChangeUrl(change) {
-    return GerritNav.getUrlForChange(change);
-  }
-
-  _computeShowCommitInfo(changeStatus, current_revision) {
-    return changeStatus === 'Merged' && 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
-    if (!rev.commit.commit) { rev.commit.commit = current_revision; }
-    return rev.commit;
-  }
-
-  _computeChangeIdClass(displayChangeId) {
-    return displayChangeId === CHANGE_ID_ERROR.MISMATCH ? 'warning' : '';
-  }
-
-  _computeTitleAttributeWarning(displayChangeId) {
-    if (displayChangeId === CHANGE_ID_ERROR.MISMATCH) {
-      return 'Change-Id mismatch';
-    } else if (displayChangeId === CHANGE_ID_ERROR.MISSING) {
-      return 'No Change-Id in commit message';
-    }
-  }
-
-  _computeChangeIdCommitMessageError(commitMessage, change) {
-    // Polymer 2: check for undefined
-    if ([commitMessage, change].includes(undefined)) {
-      return undefined;
-    }
-
-    if (!commitMessage) { return CHANGE_ID_ERROR.MISSING; }
-
-    // Find the last match in the commit message:
-    let changeId;
-    let changeIdArr;
-
-    while (changeIdArr = CHANGE_ID_REGEX_PATTERN.exec(commitMessage)) {
-      changeId = changeIdArr[2];
-    }
-
-    if (changeId) {
-      // A change-id is detected in the commit message.
-
-      if (changeId === change.change_id) {
-        // The change-id found matches the real change-id.
-        return null;
-      }
-      // The change-id found does not match the change-id.
-      return CHANGE_ID_ERROR.MISMATCH;
-    }
-    // There is no change-id in the commit message.
-    return CHANGE_ID_ERROR.MISSING;
-  }
-
-  _computeLabelNames(labels) {
-    return Object.keys(labels).sort();
-  }
-
-  _computeLabelValues(labelName, labels) {
-    const result = [];
-    const t = labels[labelName];
-    if (!t) { return result; }
-    const approvals = t.all || [];
-    for (const label of approvals) {
-      if (label.value && label.value != labels[labelName].default_value) {
-        let labelClassName;
-        let labelValPrefix = '';
-        if (label.value > 0) {
-          labelValPrefix = '+';
-          labelClassName = 'approved';
-        } else if (label.value < 0) {
-          labelClassName = 'notApproved';
-        }
-        result.push({
-          value: labelValPrefix + label.value,
-          className: labelClassName,
-          account: label,
-        });
-      }
-    }
-    return result;
-  }
-
-  _computeReplyButtonLabel(changeRecord, canStartReview) {
-    // Polymer 2: check for undefined
-    if ([changeRecord, canStartReview].includes(undefined)) {
-      return 'Reply';
-    }
-
-    const drafts = (changeRecord && changeRecord.base) || {};
-    const draftCount = Object.keys(drafts)
-        .reduce((count, file) => count + drafts[file].length, 0);
-
-    let label = canStartReview ? 'Start Review' : 'Reply';
-    if (draftCount > 0) {
-      label += ' (' + draftCount + ')';
-    }
-    return label;
-  }
-
-  _handleOpenReplyDialog(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) {
-      return;
-    }
-    this._getLoggedIn().then(isLoggedIn => {
-      if (!isLoggedIn) {
-        this.dispatchEvent(new CustomEvent('show-auth-required', {
-          composed: true, bubbles: true,
-        }));
-        return;
-      }
-
-      e.preventDefault();
-      this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
-    });
-  }
-
-  _handleOpenDownloadDialogShortcut(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this._handleOpenDownloadDialog();
-  }
-
-  _handleEditTopic(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this.$.metadata.editTopic();
-  }
-
-  _handleDiffAgainstBase(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    if (patchNumEquals(this._patchRange.basePatchNum,
-        SPECIAL_PATCH_SET_NUM.PARENT)) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {
-          message: 'Base is already selected.',
-        },
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
-  }
-
-  _handleDiffBaseAgainstLeft(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    if (patchNumEquals(this._patchRange.basePatchNum,
-        SPECIAL_PATCH_SET_NUM.PARENT)) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {
-          message: 'Left is already base.',
-        },
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    GerritNav.navigateToChange(this._change, this._patchRange.basePatchNum);
-  }
-
-  _handleDiffAgainstLatest(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {
-          message: 'Latest is already selected.',
-        },
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    GerritNav.navigateToChange(this._change, latestPatchNum,
-        this._patchRange.basePatchNum);
-  }
-
-  _handleDiffRightAgainstLatest(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {
-          message: 'Right is already latest.',
-        },
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    GerritNav.navigateToChange(this._change, latestPatchNum,
-        this._patchRange.patchNum);
-  }
-
-  _handleDiffBaseAgainstLatest(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
-      patchNumEquals(this._patchRange.basePatchNum,
-          SPECIAL_PATCH_SET_NUM.PARENT)) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {
-          message: 'Already diffing base against latest.',
-        },
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    GerritNav.navigateToChange(this._change, latestPatchNum);
-  }
-
-  _handleRefreshChange(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    e.preventDefault();
-    this._reload(/* opt_isLocationChange= */false,
-        /* opt_clearPatchset= */true);
-  }
-
-  _handleToggleChangeStar(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-    e.preventDefault();
-    this.$.changeStar.toggleStar();
-  }
-
-  _handleUpToDashboard(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this._determinePageBack();
-  }
-
-  _handleExpandAllMessages(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this.messagesList.handleExpandCollapse(true);
-  }
-
-  _handleCollapseAllMessages(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this.messagesList.handleExpandCollapse(false);
-  }
-
-  _handleOpenDiffPrefsShortcut(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    if (this._diffPrefsDisabled) { return; }
-
-    e.preventDefault();
-    this.$.fileList.openDiffPrefs();
-  }
-
-  _determinePageBack() {
-    // Default backPage to root if user came to change view page
-    // via an email link, etc.
-    GerritNav.navigateToRelativeUrl(this.backPage ||
-         GerritNav.getUrlForRoot());
-  }
-
-  _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();
-          return;
-        }
-      }
-    }
-  }
-
-  _labelsChanged(changeRecord) {
-    if (!changeRecord) { return; }
-    if (changeRecord.value && changeRecord.value.indexSplices) {
-      this._handleLabelRemoved(changeRecord.value.indexSplices,
-          changeRecord.path);
-    }
-    this.$.jsAPI.handleEvent(EventType.LABEL_CHANGE, {
-      change: this._change,
-    });
-  }
-
-  /**
-   * @param {string=} opt_section
-   */
-  _openReplyDialog(opt_section) {
-    this.$.replyOverlay.open().finally(() => {
-      // the following code should be executed no matter open succeed or not
-      this._resetReplyOverlayFocusStops();
-      this.$.replyDialog.open(opt_section);
-      flush();
-      this.$.replyOverlay.center();
-    });
-  }
-
-  _handleGetChangeDetailError(response) {
-    this.dispatchEvent(new CustomEvent('page-error', {
-      detail: {response},
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
-  }
-
-  _getServerConfig() {
-    return this.$.restAPI.getConfig();
-  }
-
-  _getProjectConfig() {
-    if (!this._change) return;
-    return this.$.restAPI.getProjectConfig(this._change.project).then(
-        config => {
-          this._projectConfig = config;
-        });
-  }
-
-  _getPreferences() {
-    return this.$.restAPI.getPreferences();
-  }
-
-  _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= or CC= as part of the email address.
-    return msg.replace(REVIEWERS_REGEX, '$1=\u200B');
-  }
-
-  /**
-   * Utility function to make the necessary modifications to a change in the
-   * case an edit exists.
-   *
-   * @param {!Object} change
-   * @param {?Object} edit
-   */
-  _processEdit(change, edit) {
-    if (!edit) { return; }
-    change.revisions[edit.commit.commit] = {
-      _number: SPECIAL_PATCH_SET_NUM.EDIT,
-      basePatchNum: edit.base_patch_set_number,
-      commit: edit.commit,
-      fetch: edit.fetch,
-    };
-    // If the edit is based on the most recent patchset, load it by
-    // default, unless another patch set to load was specified in the URL.
-    if (!this._patchRange.patchNum &&
-        change.current_revision === edit.base_revision) {
-      change.current_revision = edit.commit.commit;
-      this.set('_patchRange.patchNum', SPECIAL_PATCH_SET_NUM.EDIT);
-      // Because edits are fibbed as revisions and added to the revisions
-      // array, and revision actions are always derived from the 'latest'
-      // patch set, we must copy over actions from the patch set base.
-      // Context: Issue 7243
-      change.revisions[edit.commit.commit].actions =
-          change.revisions[edit.base_revision].actions;
-    }
-  }
-
-  _getChangeDetail() {
-    const detailCompletes = this.$.restAPI.getChangeDetail(
-        this._changeNum, r => this._handleGetChangeDetailError(r));
-    const editCompletes = this._getEdit();
-    const prefCompletes = this._getPreferences();
-
-    return Promise.all([detailCompletes, editCompletes, prefCompletes])
-        .then(([change, edit, prefs]) => {
-          this._prefs = prefs;
-
-          if (!change) {
-            return '';
-          }
-          this._processEdit(change, edit);
-          // Issue 4190: Coalesce missing topics to null.
-          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;
-
-          // Slice returns a number as a string, convert to an int.
-          this._lineHeight =
-              parseInt(lineHeight.slice(0, lineHeight.length - 2), 10);
-
-          this._change = change;
-          if (!this._patchRange || !this._patchRange.patchNum ||
-              patchNumEquals(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._selectedRevision = currentRevision;
-            // TODO: Fetch and process files.
-          } else {
-            this._selectedRevision =
-              Object.values(this._change.revisions).find(
-                  revision => {
-                    // edit patchset is a special one
-                    const thePatchNum = this._patchRange.patchNum;
-                    if (thePatchNum === 'edit') {
-                      return revision._number === thePatchNum;
-                    }
-                    return revision._number === parseInt(thePatchNum, 10);
-                  });
-          }
-        });
-  }
-
-  _isSubmitEnabled(revisionActions) {
-    return !!(revisionActions && revisionActions.submit &&
-      revisionActions.submit.enabled);
-  }
-
-  _isParentCurrent(revisionActions) {
-    if (revisionActions && revisionActions.rebase) {
-      return !revisionActions.rebase.enabled;
-    } else {
-      return true;
-    }
-  }
-
-  _getEdit() {
-    return this.$.restAPI.getChangeEdit(this._changeNum, true);
-  }
-
-  _getLatestCommitMessage() {
-    return this.$.restAPI.getChangeCommitInfo(this._changeNum,
-        computeLatestPatchNum(this._allPatchSets)).then(commitInfo => {
-      if (!commitInfo) return Promise.resolve();
-      this._latestCommitMessage =
-                  this._prepareCommitMsgForLinkify(commitInfo.message);
-    });
-  }
-
-  _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.
-    let latestRev = null;
-    let latestPatchNum = -1;
-    for (const rev in change.revisions) {
-      if (!change.revisions.hasOwnProperty(rev)) { continue; }
-
-      if (change.revisions[rev]._number > latestPatchNum) {
-        latestRev = rev;
-        latestPatchNum = change.revisions[rev]._number;
-      }
-    }
-    return latestRev;
-  }
-
-  _getCommitInfo() {
-    return this.$.restAPI.getChangeCommitInfo(
-        this._changeNum, this._patchRange.patchNum).then(
-        commitInfo => {
-          this._commitInfo = commitInfo;
-        });
-  }
-
-  _reloadDraftsWithCallback(e) {
-    return this._reloadDrafts().then(() => e.detail.resolve());
-  }
-
-  /**
-   * Fetches a new changeComment object, and data for all types of comments
-   * (comments, robot comments, draft comments) is requested.
-   */
-  _reloadComments() {
-    // We are resetting all comment related properties, because we want to avoid
-    // a new change being loaded and then paired with outdated comments.
-    this._changeComments = undefined;
-    this._commentThreads = undefined;
-    this._diffDrafts = undefined;
-    this._draftCommentThreads = undefined;
-    this._robotCommentThreads = undefined;
-    return this.$.commentAPI.loadAll(this._changeNum)
-        .then(comments => this._recomputeComments(comments));
-  }
-
-  /**
-   * Fetches a new changeComment object, but only updated data for drafts is
-   * requested.
-   *
-   * TODO(taoalpha): clean up this and _reloadComments, as single comment
-   * can be a thread so it does not make sense to only update drafts
-   * without updating threads
-   */
-  _reloadDrafts() {
-    return this.$.commentAPI.reloadDrafts(this._changeNum)
-        .then(comments => this._recomputeComments(comments));
-  }
-
-  _recomputeComments(comments) {
-    this._changeComments = comments;
-    this._diffDrafts = {...this._changeComments.drafts};
-    this._commentThreads = this._changeComments.getAllThreadsForChange();
-    this._draftCommentThreads = this._commentThreads
-        .filter(thread => thread.comments[thread.comments.length - 1].__draft)
-        .map(thread => {
-          const copiedThread = {...thread};
-          // Make a hardcopy of all comments and collapse all but last one
-          const commentsInThread = copiedThread.comments = thread.comments
-              .map(comment => { return {...comment, collapsed: true}; });
-          commentsInThread[commentsInThread.length - 1].collapsed = false;
-          return copiedThread;
-        });
-  }
-
-  /**
-   * Reload the change.
-   *
-   * @param {boolean=} opt_isLocationChange Reloads the related changes
-   *     when true and ends reporting events that started on location change.
-   * @param {boolean=} opt_clearPatchset Reloads the related changes
-   *     ignoring any patchset choice made.
-   * @return {Promise} A promise that resolves when the core data has loaded.
-   *     Some non-core data loading may still be in-flight when the core data
-   *     promise resolves.
-   */
-  _reload(opt_isLocationChange, opt_clearPatchset) {
-    if (opt_clearPatchset) {
-      GerritNav.navigateToChange(this._change);
-      return;
-    }
-    this._loading = true;
-    this._relatedChangesCollapsed = true;
-    this.reporting.time(CHANGE_RELOAD_TIMING_LABEL);
-    this.reporting.time(CHANGE_DATA_TIMING_LABEL);
-
-    // Array to house all promises related to data requests.
-    const allDataPromises = [];
-
-    // Resolves when the change detail and the edit patch set (if available)
-    // are loaded.
-    const detailCompletes = this._getChangeDetail();
-    allDataPromises.push(detailCompletes);
-
-    // Resolves when the loading flag is set to false, meaning that some
-    // change content may start appearing.
-    const loadingFlagSet = detailCompletes
-        .then(() => {
-          this._loading = false;
-          this.dispatchEvent(new CustomEvent('change-details-loaded',
-              {bubbles: true, composed: true}));
-        })
-        .then(() => {
-          this.reporting.timeEnd(CHANGE_RELOAD_TIMING_LABEL);
-          if (opt_isLocationChange) {
-            this.reporting.changeDisplayed();
-          }
-        });
-
-    // Resolves when the project config has loaded.
-    const projectConfigLoaded = detailCompletes
-        .then(() => this._getProjectConfig());
-    allDataPromises.push(projectConfigLoaded);
-
-    // Resolves when change comments have loaded (comments, drafts and robot
-    // comments).
-    const commentsLoaded = this._reloadComments();
-    allDataPromises.push(commentsLoaded);
-
-    let coreDataPromise;
-
-    // If the patch number is specified
-    if (this._patchRange && this._patchRange.patchNum) {
-      // Because a specific patchset is specified, reload the resources that
-      // are keyed by patch number or patch range.
-      const patchResourcesLoaded = this._reloadPatchNumDependentResources();
-      allDataPromises.push(patchResourcesLoaded);
-
-      // Promise resolves when the change detail and patch dependent resources
-      // have loaded.
-      const detailAndPatchResourcesLoaded =
-          Promise.all([patchResourcesLoaded, loadingFlagSet]);
-
-      // Promise resolves when mergeability information has loaded.
-      const mergeabilityLoaded = detailAndPatchResourcesLoaded
-          .then(() => this._getMergeability());
-      allDataPromises.push(mergeabilityLoaded);
-
-      // Promise resovles when the change actions have loaded.
-      const actionsLoaded = detailAndPatchResourcesLoaded
-          .then(() => this.$.actions.reload());
-      allDataPromises.push(actionsLoaded);
-
-      // The core data is loaded when both mergeability and actions are known.
-      coreDataPromise = Promise.all([mergeabilityLoaded, actionsLoaded]);
-    } else {
-      // Resolves when the file list has loaded.
-      const fileListReload = loadingFlagSet
-          .then(() => this.$.fileList.reload());
-      allDataPromises.push(fileListReload);
-
-      const latestCommitMessageLoaded = loadingFlagSet.then(() => {
-        // If the latest commit message is known, there is nothing to do.
-        if (this._latestCommitMessage) { return Promise.resolve(); }
-        return this._getLatestCommitMessage();
-      });
-      allDataPromises.push(latestCommitMessageLoaded);
-
-      // Promise resolves when mergeability information has loaded.
-      const mergeabilityLoaded = loadingFlagSet
-          .then(() => this._getMergeability());
-      allDataPromises.push(mergeabilityLoaded);
-
-      // Core data is loaded when mergeability has been loaded.
-      coreDataPromise = mergeabilityLoaded;
-    }
-
-    if (opt_isLocationChange) {
-      this._editingCommitMessage = false;
-      const relatedChangesLoaded = coreDataPromise
-          .then(() => this.$.relatedChanges.reload());
-      allDataPromises.push(relatedChangesLoaded);
-    }
-
-    Promise.all(allDataPromises).then(() => {
-      this.reporting.timeEnd(CHANGE_DATA_TIMING_LABEL);
-      if (opt_isLocationChange) {
-        this.reporting.changeFullyLoaded();
-      }
-    });
-
-    return coreDataPromise;
-  }
-
-  /**
-   * Kicks off requests for resources that rely on the patch range
-   * (`this._patchRange`) being defined.
-   */
-  _reloadPatchNumDependentResources() {
-    return Promise.all([
-      this._getCommitInfo(),
-      this.$.fileList.reload(),
-    ]);
-  }
-
-  _getMergeability() {
-    if (!this._change) {
-      this._mergeable = null;
-      return Promise.resolve();
-    }
-    // If the change is closed, it is not mergeable. Note: already merged
-    // changes are obviously not mergeable, but the mergeability API will not
-    // answer for abandoned changes.
-    if (this._change.status === ChangeStatus.MERGED ||
-        this._change.status === ChangeStatus.ABANDONED) {
-      this._mergeable = false;
-      return Promise.resolve();
-    }
-
-    this._mergeable = null;
-    return this.$.restAPI.getMergeable(this._changeNum).then(m => {
-      this._mergeable = m.mergeable;
-    });
-  }
-
-  _computeCanStartReview(change) {
-    return !!(change.actions && change.actions.ready &&
-      change.actions.ready.enabled);
-  }
-
-  _computeReplyDisabled() { return false; }
-
-  _computeChangePermalinkAriaLabel(changeNum) {
-    return 'Change ' + changeNum;
-  }
-
-  _computeCommitMessageCollapsed(collapsed, collapsible) {
-    return collapsible && collapsed;
-  }
-
-  _computeRelatedChangesClass(collapsed) {
-    return collapsed ? 'collapsed' : '';
-  }
-
-  _computeCollapseText(collapsed) {
-    // Symbols are up and down triangles.
-    return collapsed ? '\u25bc Show more' : '\u25b2 Show less';
-  }
-
-  /**
-   * Returns the text to be copied when
-   * click the copy icon next to change subject
-   *
-   * @param {!Object} change
-   */
-  _computeCopyTextForTitle(change) {
-    return `${change._number}: ${change.subject} | ` +
-     `${location.protocol}//${location.host}` +
-       `${this._computeChangeUrl(change)}`;
-  }
-
-  _toggleCommitCollapsed() {
-    this._commitCollapsed = !this._commitCollapsed;
-    if (this._commitCollapsed) {
-      window.scrollTo(0, 0);
-    }
-  }
-
-  _toggleRelatedChangesCollapsed() {
-    this._relatedChangesCollapsed = !this._relatedChangesCollapsed;
-    if (this._relatedChangesCollapsed) {
-      window.scrollTo(0, 0);
-    }
-  }
-
-  _computeCommitCollapsible(commitMessage) {
-    if (!commitMessage) { return false; }
-    return commitMessage.split('\n').length >= MIN_LINES_FOR_COMMIT_COLLAPSE;
-  }
-
-  _getOffsetHeight(element) {
-    return element.offsetHeight;
-  }
-
-  _getScrollHeight(element) {
-    return element.scrollHeight;
-  }
-
-  /**
-   * Get the line height of an element to the nearest integer.
-   */
-  _getLineHeight(element) {
-    const lineHeightStr = getComputedStyle(element).lineHeight;
-    return Math.round(lineHeightStr.slice(0, lineHeightStr.length - 2));
-  }
-
-  /**
-   * New max height for the related changes section, shorter than the existing
-   * change info height.
-   */
-  _updateRelatedChangeMaxHeight() {
-    // Takes into account approximate height for the expand button and
-    // bottom margin.
-    const EXTRA_HEIGHT = 30;
-    let newHeight;
-
-    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 * EXTRA_HEIGHT,
-          MINIMUM_RELATED_MAX_HEIGHT);
-      newHeight = medRelatedHeight;
-    } else {
-      if (this._commitCollapsible) {
-        // 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.
-        newHeight = this._getOffsetHeight(this.$.commitMessage);
-      } else {
-        newHeight = this._getOffsetHeight(this.$.commitAndRelated) -
-            EXTRA_HEIGHT;
-      }
-    }
-    const stylesToUpdate = {};
-
-    // 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 = newHeight % lineHeight;
-    newHeight = newHeight - remainder;
-
-    stylesToUpdate['--relation-chain-max-height'] = newHeight + 'px';
-
-    // Update the max-height of the relation chain to this new height.
-    if (this._commitCollapsible) {
-      stylesToUpdate['--related-change-btn-top-padding'] = remainder + 'px';
-    }
-
-    this.updateStyles(stylesToUpdate);
-  }
-
-  _computeShowRelatedToggle() {
-    // Make sure the max height has been applied, since there is now content
-    // to populate.
-    if (!getComputedStyleValue('--relation-chain-max-height', this)) {
-      this._updateRelatedChangeMaxHeight();
-    }
-    // Prevents showMore from showing when click on related change, since the
-    // line height would be positive, but related changes height is 0.
-    if (!this._getScrollHeight(this.$.relatedChanges)) {
-      return this._showRelatedToggle = false;
-    }
-
-    if (this._getScrollHeight(this.$.relatedChanges) >
-        (this._getOffsetHeight(this.$.relatedChanges) +
-        this._getLineHeight(this.$.relatedChanges))) {
-      return this._showRelatedToggle = true;
-    }
-    this._showRelatedToggle = false;
-  }
-
-  _updateToggleContainerClass(showRelatedToggle) {
-    if (showRelatedToggle) {
-      this.$.relatedChangesToggle.classList.add('showToggle');
-    } else {
-      this.$.relatedChangesToggle.classList.remove('showToggle');
-    }
-  }
-
-  _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(() => {
-      const change = this._change;
-      fetchChangeUpdates(change, this.$.restAPI).then(result => {
-        let toastMessage = null;
-        if (!result.isLatest) {
-          toastMessage = ReloadToastMessage.NEWER_REVISION;
-        } else if (result.newStatus === ChangeStatus.MERGED) {
-          toastMessage = ReloadToastMessage.MERGED;
-        } else if (result.newStatus === ChangeStatus.ABANDONED) {
-          toastMessage = ReloadToastMessage.ABANDONED;
-        } else if (result.newStatus === ChangeStatus.NEW) {
-          toastMessage = ReloadToastMessage.RESTORED;
-        } else if (result.newMessages) {
-          toastMessage = ReloadToastMessage.NEW_MESSAGE;
-        }
-
-        // We have to make sure that the update is still relevant for the user.
-        // Since starting to fetch the change update the user may have sent a
-        // reply, or the change might have been reloaded, or it could be in the
-        // process of being reloaded.
-        const changeWasReloaded = change !== this._change;
-        if (!toastMessage || this._loading || changeWasReloaded) {
-          this._startUpdateCheckTimer();
-          return;
-        }
-
-        this._cancelUpdateCheckTimer();
-        this.dispatchEvent(new CustomEvent('show-alert', {
-          detail: {
-            message: toastMessage,
-            // Persist this alert.
-            dismissOnNavigation: true,
-            action: 'Reload',
-            callback: () => {
-              this._reload(/* opt_isLocationChange= */false,
-                  /* opt_clearPatchset= */true);
-            },
-          },
-          composed: true, bubbles: true,
-        }));
-      });
-    }, this._serverConfig.change.update_delay * 1000);
-  }
-
-  _cancelUpdateCheckTimer() {
-    if (this._updateCheckTimerHandle) {
-      this.cancelAsync(this._updateCheckTimerHandle);
-    }
-    this._updateCheckTimerHandle = null;
-  }
-
-  _handleVisibilityChange() {
-    if (document.hidden && this._updateCheckTimerHandle) {
-      this._cancelUpdateCheckTimer();
-    } else if (!this._updateCheckTimerHandle) {
-      this._startUpdateCheckTimer();
-    }
-  }
-
-  _handleTopicChanged() {
-    this.$.relatedChanges.reload();
-  }
-
-  _computeHeaderClass(editMode) {
-    const classes = ['header'];
-    if (editMode) { classes.push('editMode'); }
-    return classes.join(' ');
-  }
-
-  _computeEditMode(patchRangeRecord, paramsRecord) {
-    if ([patchRangeRecord, paramsRecord].includes(undefined)) {
-      return undefined;
-    }
-
-    if (paramsRecord.base && paramsRecord.base.edit) { return true; }
-
-    const patchRange = patchRangeRecord.base || {};
-    return patchNumEquals(patchRange.patchNum, SPECIAL_PATCH_SET_NUM.EDIT);
-  }
-
-  _handleFileActionTap(e) {
-    e.preventDefault();
-    const controls = this.$.fileListHeader
-        .shadowRoot.querySelector('#editControls');
-    const path = e.detail.path;
-    switch (e.detail.action) {
-      case GrEditConstants.Actions.DELETE.id:
-        controls.openDeleteDialog(path);
-        break;
-      case GrEditConstants.Actions.OPEN.id:
-        GerritNav.navigateToRelativeUrl(
-            GerritNav.getEditUrlForDiff(this._change, path,
-                this._patchRange.patchNum));
-        break;
-      case GrEditConstants.Actions.RENAME.id:
-        controls.openRenameDialog(path);
-        break;
-      case GrEditConstants.Actions.RESTORE.id:
-        controls.openRestoreDialog(path);
-        break;
-    }
-  }
-
-  _computeCommitMessageKey(number, revision) {
-    return `c${number}_rev${revision}`;
-  }
-
-  _patchNumChanged(patchNumStr) {
-    if (!this._selectedRevision) {
-      return;
-    }
-
-    let patchNum = parseInt(patchNumStr, 10);
-    if (patchNumStr === 'edit') {
-      patchNum = patchNumStr;
-    }
-
-    if (patchNum === this._selectedRevision._number) {
-      return;
-    }
-    this._selectedRevision = Object.values(this._change.revisions).find(
-        revision => revision._number === patchNum);
-  }
-
-  /**
-   * If an edit exists already, load it. Otherwise, toggle edit mode via the
-   * navigation API.
-   */
-  _handleEditTap() {
-    const editInfo = Object.values(this._change.revisions).find(info =>
-      info._number === SPECIAL_PATCH_SET_NUM.EDIT);
-
-    if (editInfo) {
-      GerritNav.navigateToChange(this._change, SPECIAL_PATCH_SET_NUM.EDIT);
-      return;
-    }
-
-    // Avoid putting patch set in the URL unless a non-latest patch set is
-    // selected.
-    let patchNum;
-    if (!patchNumEquals(this._patchRange.patchNum,
-        computeLatestPatchNum(this._allPatchSets))) {
-      patchNum = this._patchRange.patchNum;
-    }
-    GerritNav.navigateToChange(this._change, patchNum, null, true);
-  }
-
-  _handleStopEditTap() {
-    GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
-  }
-
-  _resetReplyOverlayFocusStops() {
-    this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
-  }
-
-  _handleToggleStar(e) {
-    this.$.restAPI.saveChangeStarred(e.detail.change._number,
-        e.detail.starred);
-  }
-
-  _getRevisionInfo(change) {
-    return new RevisionInfo(change);
-  }
-
-  _computeCurrentRevision(currentRevision, revisions) {
-    return currentRevision && revisions && revisions[currentRevision];
-  }
-
-  _computeDiffPrefsDisabled(disableDiffPrefs, loggedIn) {
-    return disableDiffPrefs || !loggedIn;
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeLatestPatchNum(allPatchSets) {
-    return computeLatestPatchNum(allPatchSets);
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _hasEditBasedOnCurrentPatchSet(allPatchSets) {
-    return hasEditBasedOnCurrentPatchSet(allPatchSets);
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _hasEditPatchsetLoaded(patchRangeRecord) {
-    return hasEditPatchsetLoaded(patchRangeRecord);
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeAllPatchSets(change) {
-    return computeAllPatchSets(change);
-  }
-}
-
-customElements.define(GrChangeView.is, GrChangeView);
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
new file mode 100644
index 0000000..24841fc
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -0,0 +1,2752 @@
+/**
+ * @license
+ * 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.
+ */
+import '@polymer/paper-tabs/paper-tabs';
+import '../../../styles/shared-styles';
+import '../../diff/gr-comment-api/gr-comment-api';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../shared/gr-account-link/gr-account-link';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-change-star/gr-change-star';
+import '../../shared/gr-change-status/gr-change-status';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-editable-content/gr-editable-content';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import '../../shared/gr-linked-text/gr-linked-text';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
+import '../gr-change-actions/gr-change-actions';
+import '../gr-change-metadata/gr-change-metadata';
+import '../../shared/gr-icons/gr-icons';
+import '../gr-commit-info/gr-commit-info';
+import '../gr-download-dialog/gr-download-dialog';
+import '../gr-file-list-header/gr-file-list-header';
+import '../gr-included-in-dialog/gr-included-in-dialog';
+import '../gr-messages-list/gr-messages-list';
+import '../gr-related-changes-list/gr-related-changes-list';
+import '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
+import '../gr-reply-dialog/gr-reply-dialog';
+import '../gr-thread-list/gr-thread-list';
+import '../gr-upload-help-dialog/gr-upload-help-dialog';
+import '../../checks/gr-checks-tab';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-change-view_html';
+import {
+  KeyboardShortcutMixin,
+  Shortcut,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {GrEditConstants} from '../../edit/gr-edit-constants';
+import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
+import {getComputedStyleValue} from '../../../utils/dom-util';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
+import {PrimaryTab, SecondaryTab} from '../../../constants/constants';
+import {NO_ROBOT_COMMENTS_THREADS_MSG} from '../../../constants/messages';
+import {appContext} from '../../../services/app-context';
+import {ChangeStatus} from '../../../constants/constants';
+import {
+  computeAllPatchSets,
+  computeLatestPatchNum,
+  fetchChangeUpdates,
+  hasEditBasedOnCurrentPatchSet,
+  hasEditPatchsetLoaded,
+  patchNumEquals,
+  PatchSet,
+} from '../../../utils/patch-set-util';
+import {changeStatuses, changeStatusString} from '../../../utils/change-util';
+import {EventType as PluginEventType} from '../../plugins/gr-plugin-types';
+import {customElement, property, observe} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrJsApiInterface} from '../../shared/gr-js-api-interface/gr-js-api-interface-element';
+import {GrApplyFixDialog} from '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
+import {GrFileListHeader} from '../gr-file-list-header/gr-file-list-header';
+import {GrEditableContent} from '../../shared/gr-editable-content/gr-editable-content';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
+import {GrChangeStar} from '../../shared/gr-change-star/gr-change-star';
+import {GrChangeActions} from '../gr-change-actions/gr-change-actions';
+import {
+  AccountDetailInfo,
+  ChangeInfo,
+  NumericChangeId,
+  PatchRange,
+  ActionNameToActionInfoMap,
+  CommitId,
+  PatchSetNum,
+  ParentPatchSetNum,
+  EditPatchSetNum,
+  ServerInfo,
+  ConfigInfo,
+  PreferencesInfo,
+  CommitInfo,
+  RevisionInfo,
+  EditInfo,
+  LabelNameToInfoMap,
+  UrlEncodedCommentId,
+  QuickLabelInfo,
+  ApprovalInfo,
+  ElementPropertyDeepChange,
+} from '../../../types/common';
+import {DiffPreferencesInfo} from '../../../types/diff';
+import {GrReplyDialog, FocusTarget} from '../gr-reply-dialog/gr-reply-dialog';
+import {GrIncludedInDialog} from '../gr-included-in-dialog/gr-included-in-dialog';
+import {GrDownloadDialog} from '../gr-download-dialog/gr-download-dialog';
+import {GrChangeMetadata} from '../gr-change-metadata/gr-change-metadata';
+import {
+  GrCommentApi,
+  ChangeComments,
+} from '../../diff/gr-comment-api/gr-comment-api';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
+import {
+  CommentThread,
+  UIDraft,
+  DraftInfo,
+  isDraftThread,
+  isRobot,
+} from '../../../utils/comment-util';
+import {
+  PolymerDeepPropertyChange,
+  PolymerSpliceChange,
+  PolymerSplice,
+} from '@polymer/polymer/interfaces';
+import {AppElementChangeViewParams} from '../../gr-app-types';
+import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
+import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
+import {
+  EditRevisionInfo,
+  ParsedChangeInfo,
+} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {
+  GrFileList,
+  DEFAULT_NUM_FILES_SHOWN,
+} from '../gr-file-list/gr-file-list';
+import {ChangeViewState, isPolymerSpliceChange} from '../../../types/types';
+import {
+  CustomKeyboardEvent,
+  EditableContentSaveEvent,
+  OpenFixPreviewEvent,
+  SwitchTabEvent,
+} from '../../../types/events';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrMessagesList} from '../gr-messages-list/gr-messages-list';
+import {GrThreadList} from '../gr-thread-list/gr-thread-list';
+import {PORTING_COMMENTS_CHANGE_LATENCY_LABEL} from '../../../services/gr-reporting/gr-reporting';
+import {fireAlert, firePageError} from '../../../utils/event-util';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {fireTitleChange} from '../../../utils/event-util';
+
+const CHANGE_ID_ERROR = {
+  MISMATCH: 'mismatch',
+  MISSING: 'missing',
+};
+const CHANGE_ID_REGEX_PATTERN = /^(Change-Id:\s|Link:.*\/id\/)(I[0-9a-f]{8,40})/gm;
+
+const MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
+
+const REVIEWERS_REGEX = /^(R|CC)=/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 = '75em';
+
+// 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;
+
+const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
+
+const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
+
+const MSG_PREFIX = '#message-';
+
+const ReloadToastMessage = {
+  NEWER_REVISION: 'A newer patch set has been uploaded',
+  RESTORED: 'This change has been restored',
+  ABANDONED: 'This change has been abandoned',
+  MERGED: 'This change has been merged',
+  NEW_MESSAGE: 'There are new messages on this change',
+};
+
+enum DiffViewMode {
+  SIDE_BY_SIDE = 'SIDE_BY_SIDE',
+  UNIFIED = 'UNIFIED_DIFF',
+}
+
+const CHANGE_DATA_TIMING_LABEL = 'ChangeDataLoaded';
+const CHANGE_RELOAD_TIMING_LABEL = 'ChangeReloaded';
+const SEND_REPLY_TIMING_LABEL = 'SendReply';
+// Making the tab names more unique in case a plugin adds one with same name
+const ROBOT_COMMENTS_LIMIT = 10;
+
+export interface GrChangeView {
+  $: {
+    restAPI: RestApiService & Element;
+    jsAPI: GrJsApiInterface;
+    commentAPI: GrCommentApi;
+    applyFixDialog: GrApplyFixDialog;
+    fileList: GrFileList & Element;
+    fileListHeader: GrFileListHeader;
+    commitMessageEditor: GrEditableContent;
+    includedInOverlay: GrOverlay;
+    includedInDialog: GrIncludedInDialog;
+    downloadOverlay: GrOverlay;
+    downloadDialog: GrDownloadDialog;
+    uploadHelpOverlay: GrOverlay;
+    replyOverlay: GrOverlay;
+    replyDialog: GrReplyDialog;
+    mainContent: HTMLDivElement;
+    relatedChanges: GrRelatedChangesList;
+    changeStar: GrChangeStar;
+    actions: GrChangeActions;
+    commitMessage: HTMLDivElement;
+    commitAndRelated: HTMLDivElement;
+    metadata: GrChangeMetadata;
+    relatedChangesToggle: HTMLDivElement;
+    mainChangeInfo: HTMLDivElement;
+    commitCollapseToggleButton: GrButton;
+    commitCollapseToggle: HTMLDivElement;
+    relatedChangesToggleButton: GrButton;
+    replyBtn: GrButton;
+  };
+}
+
+export type ChangeViewPatchRange = Partial<PatchRange>;
+
+@customElement('gr-change-view')
+export class GrChangeView extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the title of the page should change.
+   *
+   * @event title-change
+   */
+
+  /**
+   * Fired if an error occurs when fetching the change data.
+   *
+   * @event page-error
+   */
+
+  /**
+   * Fired if being logged in is required.
+   *
+   * @event show-auth-required
+   */
+
+  reporting = appContext.reportingService;
+
+  flagsService = appContext.flagsService;
+
+  /**
+   * URL params passed from the router.
+   */
+  @property({type: Object, observer: '_paramsChanged'})
+  params?: AppElementChangeViewParams;
+
+  @property({type: Object, notify: true, observer: '_viewStateChanged'})
+  viewState: Partial<ChangeViewState> = {};
+
+  @property({type: String})
+  backPage?: string;
+
+  @property({type: Boolean})
+  hasParent?: boolean;
+
+  @property({type: Object})
+  keyEventTarget = document.body;
+
+  @property({type: Boolean})
+  disableEdit = false;
+
+  @property({type: Boolean})
+  disableDiffPrefs = false;
+
+  @property({
+    type: Boolean,
+    computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
+  })
+  _diffPrefsDisabled?: boolean;
+
+  @property({type: Array})
+  _commentThreads?: CommentThread[];
+
+  // TODO(taoalpha): Consider replacing diffDrafts
+  // with _draftCommentThreads everywhere, currently only
+  // replaced in reply-dialog
+  @property({type: Array})
+  _draftCommentThreads?: CommentThread[];
+
+  @property({
+    type: Array,
+    computed:
+      '_computeRobotCommentThreads(_commentThreads,' +
+      ' _currentRobotCommentsPatchSet, _showAllRobotComments)',
+  })
+  _robotCommentThreads?: CommentThread[];
+
+  @property({type: Object, observer: '_startUpdateCheckTimer'})
+  _serverConfig?: ServerInfo;
+
+  @property({type: Object})
+  _diffPrefs?: DiffPreferencesInfo;
+
+  @property({type: Number, observer: '_numFilesShownChanged'})
+  _numFilesShown = DEFAULT_NUM_FILES_SHOWN;
+
+  @property({type: Object})
+  _account?: AccountDetailInfo;
+
+  @property({type: Object})
+  _prefs?: PreferencesInfo;
+
+  @property({type: Object})
+  _changeComments?: ChangeComments;
+
+  @property({type: Boolean, computed: '_computeCanStartReview(_change)'})
+  _canStartReview?: boolean;
+
+  @property({type: Object, observer: '_changeChanged'})
+  _change?: ChangeInfo | ParsedChangeInfo;
+
+  @property({type: Object, computed: '_getRevisionInfo(_change)'})
+  _revisionInfo?: RevisionInfoClass;
+
+  @property({type: Object})
+  _commitInfo?: CommitInfo;
+
+  @property({
+    type: Object,
+    computed:
+      '_computeCurrentRevision(_change.current_revision, ' +
+      '_change.revisions)',
+    observer: '_handleCurrentRevisionUpdate',
+  })
+  _currentRevision?: RevisionInfo;
+
+  @property({type: String})
+  _changeNum?: NumericChangeId;
+
+  @property({type: Object})
+  _diffDrafts?: {[path: string]: UIDraft[]} = {};
+
+  @property({type: Boolean})
+  _editingCommitMessage = false;
+
+  @property({
+    type: Boolean,
+    computed:
+      '_computeHideEditCommitMessage(_loggedIn, ' +
+      '_editingCommitMessage, _change, _editMode, _commitCollapsed, ' +
+      '_commitCollapsible)',
+  })
+  _hideEditCommitMessage?: boolean;
+
+  @property({type: String})
+  _diffAgainst?: string;
+
+  @property({type: String})
+  _latestCommitMessage: string | null = '';
+
+  @property({type: Object})
+  _constants = {
+    SecondaryTab,
+    PrimaryTab,
+  };
+
+  @property({type: Object})
+  _messages = NO_ROBOT_COMMENTS_THREADS_MSG;
+
+  @property({type: Number})
+  _lineHeight?: number;
+
+  @property({
+    type: String,
+    computed:
+      '_computeChangeIdCommitMessageError(_latestCommitMessage, _change)',
+  })
+  _changeIdCommitMessageError?: string;
+
+  @property({type: Object})
+  _patchRange?: ChangeViewPatchRange;
+
+  @property({type: String})
+  _filesExpanded?: string;
+
+  @property({type: String})
+  _basePatchNum?: string;
+
+  @property({type: Object})
+  _selectedRevision?: RevisionInfo | EditRevisionInfo;
+
+  @property({type: Object})
+  _currentRevisionActions?: ActionNameToActionInfoMap;
+
+  @property({
+    type: Array,
+    computed: '_computeAllPatchSets(_change, _change.revisions.*)',
+  })
+  _allPatchSets?: PatchSet[];
+
+  @property({type: Boolean})
+  _loggedIn = false;
+
+  @property({type: Boolean})
+  _loading?: boolean;
+
+  @property({type: Object})
+  _projectConfig?: ConfigInfo;
+
+  @property({
+    type: String,
+    computed: '_computeReplyButtonLabel(_diffDrafts.*, _canStartReview)',
+  })
+  _replyButtonLabel = 'Reply';
+
+  @property({type: String})
+  _selectedPatchSet?: string;
+
+  @property({type: Number})
+  _shownFileCount?: number;
+
+  @property({type: Boolean})
+  _initialLoadComplete = false;
+
+  @property({type: Boolean})
+  _replyDisabled = true;
+
+  @property({type: String, computed: '_changeStatusString(_change)'})
+  _changeStatus?: string;
+
+  @property({
+    type: String,
+    computed: '_computeChangeStatusChips(_change, _mergeable, _submitEnabled)',
+  })
+  _changeStatuses?: string[];
+
+  /** If false, then the "Show more" button was used to expand. */
+  @property({type: Boolean})
+  _commitCollapsed = true;
+
+  /** Is the "Show more/less" button visible? */
+  @property({
+    type: Boolean,
+    computed: '_computeCommitCollapsible(_latestCommitMessage)',
+  })
+  _commitCollapsible?: boolean;
+
+  @property({type: Boolean})
+  _relatedChangesCollapsed = true;
+
+  @property({type: Number})
+  _updateCheckTimerHandle?: number | null;
+
+  @property({
+    type: Boolean,
+    computed: '_computeEditMode(_patchRange.*, params.*)',
+  })
+  _editMode?: boolean;
+
+  @property({type: Boolean, observer: '_updateToggleContainerClass'})
+  _showRelatedToggle = false;
+
+  @property({
+    type: Boolean,
+    computed: '_isParentCurrent(_currentRevisionActions)',
+  })
+  _parentIsCurrent?: boolean;
+
+  @property({
+    type: Boolean,
+    computed: '_isSubmitEnabled(_currentRevisionActions)',
+  })
+  _submitEnabled?: boolean;
+
+  @property({type: Boolean})
+  _mergeable: boolean | null = null;
+
+  @property({type: Boolean})
+  _showFileTabContent = true;
+
+  @property({type: Array})
+  _dynamicTabHeaderEndpoints: string[] = [];
+
+  @property({type: Array})
+  _dynamicTabContentEndpoints: string[] = [];
+
+  @property({type: String})
+  // The dynamic content of the plugin added tab
+  _selectedTabPluginEndpoint?: string;
+
+  @property({type: String})
+  // The dynamic heading of the plugin added tab
+  _selectedTabPluginHeader?: string;
+
+  @property({
+    type: Array,
+    computed:
+      '_computeRobotCommentsPatchSetDropdownItems(_change, _commentThreads)',
+  })
+  _robotCommentsPatchSetDropdownItems: DropdownLink[] = [];
+
+  @property({type: Number})
+  _currentRobotCommentsPatchSet?: PatchSetNum;
+
+  /**
+   * this is a two-element tuple to always
+   * hold the current active tab for both primary and secondary tabs
+   */
+  @property({type: Array})
+  _activeTabs: string[] = [PrimaryTab.FILES, SecondaryTab.CHANGE_LOG];
+
+  @property({type: Boolean})
+  _showAllRobotComments = false;
+
+  @property({type: Boolean})
+  _showRobotCommentsButton = false;
+
+  _throttledToggleChangeStar?: EventListener;
+
+  _isChecksEnabled = false;
+
+  keyboardShortcuts() {
+    return {
+      [Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
+      [Shortcut.EMOJI_DROPDOWN]: null, // DOC_ONLY binding
+      [Shortcut.REFRESH_CHANGE]: '_handleRefreshChange',
+      [Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
+      [Shortcut.OPEN_DOWNLOAD_DIALOG]: '_handleOpenDownloadDialogShortcut',
+      [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
+      [Shortcut.TOGGLE_CHANGE_STAR]: '_throttledToggleChangeStar',
+      [Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
+      [Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
+      [Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
+      [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_expandAllDiffs',
+      [Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
+      [Shortcut.EDIT_TOPIC]: '_handleEditTopic',
+      [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
+      [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
+      [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
+      [Shortcut.DIFF_RIGHT_AGAINST_LATEST]: '_handleDiffRightAgainstLatest',
+      [Shortcut.DIFF_BASE_AGAINST_LATEST]: '_handleDiffBaseAgainstLatest',
+    };
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._isChecksEnabled = this.flagsService.isEnabled(
+      KnownExperimentId.CI_REBOOT_CHECKS
+    );
+  }
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+    this._throttledToggleChangeStar = this._throttleWrap(e =>
+      this._handleToggleChangeStar(e as CustomKeyboardEvent)
+    );
+  }
+
+  /** @override */
+  created() {
+    super.created();
+
+    this.addEventListener('topic-changed', () => this._handleTopicChanged());
+
+    this.addEventListener(
+      // When an overlay is opened in a mobile viewport, the overlay has a full
+      // screen view. When it has a full screen view, we do not want the
+      // background to be scrollable. This will eliminate background scroll by
+      // hiding most of the contents on the screen upon opening, and showing
+      // again upon closing.
+      'fullscreen-overlay-opened',
+      () => this._handleHideBackgroundContent()
+    );
+
+    this.addEventListener('fullscreen-overlay-closed', () =>
+      this._handleShowBackgroundContent()
+    );
+
+    this.addEventListener('diff-comments-modified', () =>
+      this._handleReloadCommentThreads()
+    );
+
+    this.addEventListener('open-reply-dialog', () => this._openReplyDialog());
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._getServerConfig().then(config => {
+      this._serverConfig = config;
+      this._replyDisabled = false;
+    });
+
+    this._getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+      if (loggedIn) {
+        this.$.restAPI.getAccount().then(acct => {
+          this._account = acct;
+        });
+      }
+      this._setDiffViewMode();
+    });
+
+    getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => {
+        this._dynamicTabHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-tab-header'
+        );
+        this._dynamicTabContentEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-tab-content'
+        );
+        if (
+          this._dynamicTabContentEndpoints.length !==
+          this._dynamicTabHeaderEndpoints.length
+        ) {
+          console.warn('Different number of tab headers and tab content.');
+        }
+      })
+      .then(() => this._initActiveTabs(this.params));
+
+    this.addEventListener('comment-save', e => this._handleCommentSave(e));
+    this.addEventListener('comment-refresh', () => this._reloadDrafts());
+    this.addEventListener('comment-discard', e =>
+      this._handleCommentDiscard(e)
+    );
+    this.addEventListener('change-message-deleted', () => this._reload());
+    this.addEventListener('editable-content-save', e =>
+      this._handleCommitMessageSave(e)
+    );
+    this.addEventListener('editable-content-cancel', () =>
+      this._handleCommitMessageCancel()
+    );
+    this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
+    this.addEventListener('close-fix-preview', () => this._onCloseFixPreview());
+    this.listen(window, 'scroll', '_handleScroll');
+    this.listen(document, 'visibilitychange', '_handleVisibilityChange');
+
+    this.addEventListener('show-primary-tab', e =>
+      this._setActivePrimaryTab(e)
+    );
+    this.addEventListener('show-secondary-tab', e =>
+      this._setActiveSecondaryTab(e)
+    );
+    this.addEventListener('reload', e => {
+      e.stopPropagation();
+      this._reload(
+        /* isLocationChange= */ false,
+        /* clearPatchset= */ e.detail && e.detail.clearPatchset
+      );
+    });
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this.unlisten(window, 'scroll', '_handleScroll');
+    this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
+
+    if (this._updateCheckTimerHandle) {
+      this._cancelUpdateCheckTimer();
+    }
+  }
+
+  get messagesList(): GrMessagesList | null {
+    return this.shadowRoot!.querySelector('gr-messages-list');
+  }
+
+  get threadList(): GrThreadList | null {
+    return this.shadowRoot!.querySelector('gr-thread-list');
+  }
+
+  _changeStatusString(change: ChangeInfo) {
+    return changeStatusString(change);
+  }
+
+  _setDiffViewMode(opt_reset?: boolean) {
+    if (!opt_reset && this.viewState.diffViewMode) {
+      return;
+    }
+
+    return this._getPreferences()
+      .then(prefs => {
+        if (!this.viewState.diffMode && prefs) {
+          this.set('viewState.diffMode', prefs.default_diff_view);
+        }
+      })
+      .then(() => {
+        if (!this.viewState.diffMode) {
+          this.set('viewState.diffMode', 'SIDE_BY_SIDE');
+        }
+      });
+  }
+
+  _onOpenFixPreview(e: OpenFixPreviewEvent) {
+    this.$.applyFixDialog.open(e);
+  }
+
+  _onCloseFixPreview() {
+    this._reload();
+  }
+
+  _handleToggleDiffMode(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    if (this.viewState.diffMode === DiffViewMode.SIDE_BY_SIDE) {
+      this.$.fileListHeader.setDiffViewMode(DiffViewMode.UNIFIED);
+    } else {
+      this.$.fileListHeader.setDiffViewMode(DiffViewMode.SIDE_BY_SIDE);
+    }
+  }
+
+  _isTabActive(tab: string, activeTabs: string[]) {
+    return activeTabs.includes(tab);
+  }
+
+  /**
+   * Actual implementation of switching a tab
+   *
+   * @param paperTabs - the parent tabs container
+   */
+  _setActiveTab(
+    paperTabs: PaperTabsElement,
+    activeDetails: {
+      activeTabName?: string;
+      activeTabIndex?: number;
+      scrollIntoView?: boolean;
+    }
+  ) {
+    const {activeTabName, activeTabIndex, scrollIntoView} = activeDetails;
+    const tabs = paperTabs.querySelectorAll('paper-tab') as NodeListOf<
+      HTMLElement
+    >;
+    let activeIndex = -1;
+    if (activeTabIndex !== undefined) {
+      activeIndex = activeTabIndex;
+    } else {
+      for (let i = 0; i <= tabs.length; i++) {
+        const tab = tabs[i];
+        if (tab.dataset['name'] === activeTabName) {
+          activeIndex = i;
+          break;
+        }
+      }
+    }
+    if (activeIndex === -1) {
+      console.warn('tab not found with given info', activeDetails);
+      return;
+    }
+    const tabName = tabs[activeIndex].dataset['name'];
+    if (scrollIntoView) {
+      paperTabs.scrollIntoView();
+    }
+    if (paperTabs.selected !== activeIndex) {
+      paperTabs.selected = activeIndex;
+      this.reporting.reportInteraction('show-tab', {tabName});
+    }
+    return tabName;
+  }
+
+  /**
+   * Changes active primary tab.
+   */
+  _setActivePrimaryTab(e: SwitchTabEvent) {
+    const primaryTabs = this.shadowRoot!.querySelector(
+      '#primaryTabs'
+    ) as PaperTabsElement;
+    const activeTabName = this._setActiveTab(primaryTabs, {
+      activeTabName: e.detail.tab,
+      activeTabIndex: e.detail.value,
+      scrollIntoView: e.detail.scrollIntoView,
+    });
+    if (activeTabName) {
+      this._activeTabs = [activeTabName, this._activeTabs[1]];
+
+      // update plugin endpoint if its a plugin tab
+      const pluginIndex = (this._dynamicTabHeaderEndpoints || []).indexOf(
+        activeTabName
+      );
+      if (pluginIndex !== -1) {
+        this._selectedTabPluginEndpoint = this._dynamicTabContentEndpoints[
+          pluginIndex
+        ];
+        this._selectedTabPluginHeader = this._dynamicTabHeaderEndpoints[
+          pluginIndex
+        ];
+      } else {
+        this._selectedTabPluginEndpoint = '';
+        this._selectedTabPluginHeader = '';
+      }
+    }
+  }
+
+  /**
+   * Changes active secondary tab.
+   */
+  _setActiveSecondaryTab(e: SwitchTabEvent) {
+    const secondaryTabs = this.shadowRoot!.querySelector(
+      '#secondaryTabs'
+    ) as PaperTabsElement;
+    const activeTabName = this._setActiveTab(secondaryTabs, {
+      activeTabName: e.detail.tab,
+      activeTabIndex: e.detail.value,
+      scrollIntoView: e.detail.scrollIntoView,
+    });
+    if (activeTabName) {
+      this._activeTabs = [this._activeTabs[0], activeTabName];
+    }
+  }
+
+  _handleEditCommitMessage() {
+    this._editingCommitMessage = true;
+    this.$.commitMessageEditor.focusTextarea();
+  }
+
+  _handleCommitMessageSave(e: EditableContentSaveEvent) {
+    if (!this._change) throw new Error('missing required change property');
+    if (!this._changeNum)
+      throw new Error('missing required changeNum property');
+    // Trim trailing whitespace from each line.
+    const message = e.detail.content.replace(TRAILING_WHITESPACE_REGEX, '');
+
+    this.$.jsAPI.handleCommitMessage(this._change, message);
+
+    this.$.commitMessageEditor.disabled = true;
+    this.$.restAPI
+      .putChangeCommitMessage(this._changeNum, message)
+      .then(resp => {
+        this.$.commitMessageEditor.disabled = false;
+        if (!resp.ok) {
+          return;
+        }
+
+        this._latestCommitMessage = this._prepareCommitMsgForLinkify(message);
+        this._editingCommitMessage = false;
+        this._reloadWindow();
+      })
+      .catch(() => {
+        this.$.commitMessageEditor.disabled = false;
+      });
+  }
+
+  _reloadWindow() {
+    window.location.reload();
+  }
+
+  _handleCommitMessageCancel() {
+    this._editingCommitMessage = false;
+  }
+
+  _computeChangeStatusChips(
+    change: ChangeInfo | undefined,
+    mergeable: boolean | null,
+    submitEnabled?: boolean
+  ) {
+    if (!change) {
+      return undefined;
+    }
+
+    // Show no chips until mergeability is loaded.
+    if (mergeable === null) {
+      return [];
+    }
+
+    const options = {
+      includeDerived: true,
+      mergeable: !!mergeable,
+      submitEnabled: !!submitEnabled,
+    };
+    return changeStatuses(change, options);
+  }
+
+  _computeHideEditCommitMessage(
+    loggedIn: boolean,
+    editing: boolean,
+    change: ChangeInfo,
+    editMode?: boolean,
+    collapsed?: boolean,
+    collapsible?: boolean
+  ) {
+    if (
+      !loggedIn ||
+      editing ||
+      (change && change.status === ChangeStatus.MERGED) ||
+      editMode ||
+      (collapsed && collapsible)
+    ) {
+      return true;
+    }
+
+    return false;
+  }
+
+  _robotCommentCountPerPatchSet(threads: CommentThread[]) {
+    return threads.reduce((robotCommentCountMap, thread) => {
+      const comments = thread.comments;
+      const robotCommentsCount = comments.reduce(
+        (acc, comment) => (isRobot(comment) ? acc + 1 : acc),
+        0
+      );
+      if (comments[0].patch_set)
+        robotCommentCountMap[`${comments[0].patch_set}`] =
+          (robotCommentCountMap[`${comments[0].patch_set}`] || 0) +
+          robotCommentsCount;
+      return robotCommentCountMap;
+    }, {} as {[patchset: string]: number});
+  }
+
+  _computeText(patch: RevisionInfo, commentThreads: CommentThread[]) {
+    const commentCount = this._robotCommentCountPerPatchSet(commentThreads);
+    const commentCnt = commentCount[patch._number] || 0;
+    if (commentCnt === 0) return `Patchset ${patch._number}`;
+    const findingsText = commentCnt === 1 ? 'finding' : 'findings';
+    return `Patchset ${patch._number} (${commentCnt} ${findingsText})`;
+  }
+
+  _computeRobotCommentsPatchSetDropdownItems(
+    change: ChangeInfo,
+    commentThreads: CommentThread[]
+  ) {
+    if (!change || !commentThreads || !change.revisions) return [];
+
+    return Object.values(change.revisions)
+      .filter(patch => patch._number !== 'edit')
+      .map(patch => {
+        return {
+          text: this._computeText(patch, commentThreads),
+          value: patch._number,
+        };
+      })
+      .sort((a, b) => (b.value as number) - (a.value as number));
+  }
+
+  _handleCurrentRevisionUpdate(currentRevision: RevisionInfo) {
+    this._currentRobotCommentsPatchSet = currentRevision._number;
+  }
+
+  _handleRobotCommentPatchSetChanged(e: CustomEvent<{value: string}>) {
+    const patchSet = Number(e.detail.value) as PatchSetNum;
+    if (patchSet === this._currentRobotCommentsPatchSet) return;
+    this._currentRobotCommentsPatchSet = patchSet;
+  }
+
+  _computeShowText(showAllRobotComments: boolean) {
+    return showAllRobotComments ? 'Show Less' : 'Show more';
+  }
+
+  _toggleShowRobotComments() {
+    this._showAllRobotComments = !this._showAllRobotComments;
+  }
+
+  _computeRobotCommentThreads(
+    commentThreads: CommentThread[],
+    currentRobotCommentsPatchSet: PatchSetNum,
+    showAllRobotComments: boolean
+  ) {
+    if (!commentThreads || !currentRobotCommentsPatchSet) return [];
+    const threads = commentThreads.filter(thread => {
+      const comments = thread.comments || [];
+      return (
+        comments.length &&
+        isRobot(comments[0]) &&
+        comments[0].patch_set === currentRobotCommentsPatchSet
+      );
+    });
+    this._showRobotCommentsButton = threads.length > ROBOT_COMMENTS_LIMIT;
+    return threads.slice(
+      0,
+      showAllRobotComments ? undefined : ROBOT_COMMENTS_LIMIT
+    );
+  }
+
+  _handleReloadCommentThreads() {
+    // Get any new drafts that have been saved in the diff view and show
+    // in the comment thread view.
+    this._reloadDrafts().then(() => {
+      this._commentThreads = this._changeComments?.getAllThreadsForChange();
+      flush();
+    });
+  }
+
+  _handleReloadDiffComments(
+    e: CustomEvent<{rootId: UrlEncodedCommentId; path: string}>
+  ) {
+    // Keeps the file list counts updated.
+    this._reloadDrafts().then(() => {
+      // Get any new drafts that have been saved in the thread view and show
+      // in the diff view.
+      this.$.fileList.reloadCommentsForThreadWithRootId(
+        e.detail.rootId,
+        e.detail.path
+      );
+      flush();
+    });
+  }
+
+  _computeTotalCommentCounts(
+    unresolvedCount: number,
+    changeComments: ChangeComments
+  ) {
+    if (!changeComments) return undefined;
+    const draftCount = changeComments.computeDraftCount();
+    const unresolvedString = GrCountStringFormatter.computeString(
+      unresolvedCount,
+      'unresolved'
+    );
+    const draftString = GrCountStringFormatter.computePluralString(
+      draftCount,
+      'draft'
+    );
+
+    return (
+      unresolvedString +
+      // Add a comma and space if both unresolved and draft comments exist.
+      (unresolvedString && draftString ? ', ' : '') +
+      draftString
+    );
+  }
+
+  _handleCommentSave(e: CustomEvent<{comment: DraftInfo}>) {
+    const draft = e.detail.comment;
+    if (!draft.__draft || !draft.path) return;
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+
+    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
+    const diffDrafts = {...this._diffDrafts};
+    if (!diffDrafts[draft.path]) {
+      diffDrafts[draft.path] = [draft];
+      this._diffDrafts = diffDrafts;
+      return;
+    }
+    for (let i = 0; i < diffDrafts[draft.path].length; i++) {
+      if (diffDrafts[draft.path][i].id === draft.id) {
+        diffDrafts[draft.path][i] = draft;
+        this._diffDrafts = diffDrafts;
+        return;
+      }
+    }
+    diffDrafts[draft.path].push(draft);
+    diffDrafts[draft.path].sort(
+      (c1, c2) =>
+        // No line number means that it’s a file comment. Sort it above the
+        // others.
+        (c1.line || -1) - (c2.line || -1)
+    );
+    this._diffDrafts = diffDrafts;
+  }
+
+  _handleCommentDiscard(e: CustomEvent<{comment: DraftInfo}>) {
+    const draft = e.detail.comment;
+    if (!draft.__draft || !draft.path) {
+      return;
+    }
+
+    if (!this._diffDrafts || !this._diffDrafts[draft.path]) {
+      return;
+    }
+    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;
+      }
+    }
+    if (index === -1) {
+      // It may be a draft that hasn’t been added to _diffDrafts since it was
+      // never saved.
+      return;
+    }
+
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    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
+    const diffDrafts = {...this._diffDrafts};
+    diffDrafts[draft.path].splice(index, 1);
+    if (diffDrafts[draft.path].length === 0) {
+      delete diffDrafts[draft.path];
+    }
+    this._diffDrafts = diffDrafts;
+  }
+
+  _handleReplyTap(e: MouseEvent) {
+    e.preventDefault();
+    this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
+  }
+
+  _handleOpenDiffPrefs() {
+    this.$.fileList.openDiffPrefs();
+  }
+
+  _handleOpenIncludedInDialog() {
+    this.$.includedInDialog.loadData().then(() => {
+      flush();
+      this.$.includedInOverlay.refit();
+    });
+    this.$.includedInOverlay.open();
+  }
+
+  _handleIncludedInDialogClose() {
+    this.$.includedInOverlay.close();
+  }
+
+  _handleOpenDownloadDialog() {
+    this.$.downloadOverlay.open().then(() => {
+      this.$.downloadOverlay.setFocusStops(
+        this.$.downloadDialog.getFocusStops()
+      );
+      this.$.downloadDialog.focus();
+    });
+  }
+
+  _handleDownloadDialogClose() {
+    this.$.downloadOverlay.close();
+  }
+
+  _handleOpenUploadHelpDialog() {
+    this.$.uploadHelpOverlay.open();
+  }
+
+  _handleCloseUploadHelpDialog() {
+    this.$.uploadHelpOverlay.close();
+  }
+
+  _handleMessageReply(e: CustomEvent<{message: {message: string}}>) {
+    const msg: string = e.detail.message.message;
+    const quoteStr =
+      msg
+        .split('\n')
+        .map(line => '> ' + line)
+        .join('\n') + '\n\n';
+    this.$.replyDialog.quote = quoteStr;
+    this._openReplyDialog(this.$.replyDialog.FocusTarget.BODY);
+  }
+
+  _handleHideBackgroundContent() {
+    this.$.mainContent.classList.add('overlayOpen');
+  }
+
+  _handleShowBackgroundContent() {
+    this.$.mainContent.classList.remove('overlayOpen');
+  }
+
+  _handleReplySent() {
+    this.addEventListener(
+      'change-details-loaded',
+      () => {
+        this.reporting.timeEnd(SEND_REPLY_TIMING_LABEL);
+      },
+      {once: true}
+    );
+    this.$.replyOverlay.close();
+    this._reload();
+  }
+
+  _handleReplyCancel() {
+    this.$.replyOverlay.close();
+  }
+
+  _handleReplyAutogrow() {
+    // If the textarea resizes, we need to re-fit the overlay.
+    this.debounce(
+      'reply-overlay-refit',
+      () => {
+        this.$.replyOverlay.refit();
+      },
+      REPLY_REFIT_DEBOUNCE_INTERVAL_MS
+    );
+  }
+
+  _handleShowReplyDialog(e: CustomEvent<{value: {ccsOnly: boolean}}>) {
+    let target = this.$.replyDialog.FocusTarget.REVIEWERS;
+    if (e.detail.value && e.detail.value.ccsOnly) {
+      target = this.$.replyDialog.FocusTarget.CCS;
+    }
+    this._openReplyDialog(target);
+  }
+
+  _handleScroll() {
+    this.debounce(
+      'scroll',
+      () => {
+        this.viewState.scrollTop = document.body.scrollTop;
+      },
+      150
+    );
+  }
+
+  _setShownFiles(e: CustomEvent<{length: number}>) {
+    this._shownFileCount = e.detail.length;
+  }
+
+  _expandAllDiffs(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+    this.$.fileList.expandAllDiffs();
+  }
+
+  _collapseAllDiffs() {
+    this.$.fileList.collapseAllDiffs();
+  }
+
+  _paramsChanged(value: AppElementChangeViewParams) {
+    if (value.view !== GerritView.CHANGE) {
+      this._initialLoadComplete = false;
+      return;
+    }
+
+    if (value.changeNum && value.project) {
+      this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
+    }
+
+    const patchChanged =
+      this._patchRange &&
+      value.patchNum !== undefined &&
+      value.basePatchNum !== undefined &&
+      (this._patchRange.patchNum !== value.patchNum ||
+        this._patchRange.basePatchNum !== value.basePatchNum);
+    const changeChanged = this._changeNum !== value.changeNum;
+
+    const patchRange: ChangeViewPatchRange = {
+      patchNum: value.patchNum,
+      basePatchNum: value.basePatchNum || ParentPatchSetNum,
+    };
+
+    this.$.fileList.collapseAllDiffs();
+    this._patchRange = patchRange;
+
+    // If the change has already been loaded and the parameter change is only
+    // in the patch range, then don't do a full reload.
+    if (!changeChanged && patchChanged) {
+      if (!patchRange.patchNum) {
+        patchRange.patchNum = computeLatestPatchNum(this._allPatchSets);
+      }
+      this._reloadPatchNumDependentResources().then(() => {
+        this._sendShowChangeEvent();
+      });
+      return;
+    }
+
+    this._initialLoadComplete = false;
+    this._changeNum = value.changeNum;
+    this.$.relatedChanges.clear();
+
+    this._reload(true).then(() => {
+      this._performPostLoadTasks();
+    });
+
+    getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => {
+        this._initActiveTabs(value);
+      });
+  }
+
+  _initActiveTabs(params?: AppElementChangeViewParams) {
+    let primaryTab = PrimaryTab.FILES;
+    if (params && params.queryMap && params.queryMap.has('tab')) {
+      primaryTab = params.queryMap.get('tab') as PrimaryTab;
+    }
+    this._setActivePrimaryTab(
+      new CustomEvent('initActiveTab', {
+        detail: {
+          tab: primaryTab,
+        },
+      })
+    );
+    this._setActiveSecondaryTab(
+      new CustomEvent('initActiveTab', {
+        detail: {
+          tab: SecondaryTab.CHANGE_LOG,
+        },
+      })
+    );
+  }
+
+  _sendShowChangeEvent() {
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    this.$.jsAPI.handleEvent(PluginEventType.SHOW_CHANGE, {
+      change: this._change,
+      patchNum: this._patchRange.patchNum,
+      info: {mergeable: this._mergeable},
+    });
+  }
+
+  _performPostLoadTasks() {
+    this._maybeShowReplyDialog();
+    this._maybeShowRevertDialog();
+    this._maybeShowDownloadDialog();
+
+    this._sendShowChangeEvent();
+
+    this.async(() => {
+      if (this.viewState.scrollTop) {
+        document.documentElement.scrollTop = document.body.scrollTop = this.viewState.scrollTop;
+      } else {
+        this._maybeScrollToMessage(window.location.hash);
+      }
+      this._initialLoadComplete = true;
+    });
+  }
+
+  @observe('params', '_change')
+  _paramsAndChangeChanged(
+    value?: AppElementChangeViewParams,
+    change?: ChangeInfo
+  ) {
+    // Polymer 2: check for undefined
+    if (!value || !change) {
+      return;
+    }
+
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    // If the change number or patch range is different, then reset the
+    // selected file index.
+    const patchRangeState = this.viewState.patchRange;
+    if (
+      this.viewState.changeNum !== this._changeNum ||
+      !patchRangeState ||
+      patchRangeState.basePatchNum !== this._patchRange.basePatchNum ||
+      patchRangeState.patchNum !== this._patchRange.patchNum
+    ) {
+      this._resetFileListViewState();
+    }
+  }
+
+  _viewStateChanged(viewState: ChangeViewState) {
+    this._numFilesShown = viewState.numFilesShown
+      ? viewState.numFilesShown
+      : DEFAULT_NUM_FILES_SHOWN;
+  }
+
+  _numFilesShownChanged(numFilesShown: number) {
+    this.viewState.numFilesShown = numFilesShown;
+  }
+
+  _handleMessageAnchorTap(e: CustomEvent<{id: string}>) {
+    if (!this._change) throw new Error('missing required change property');
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    const hash = MSG_PREFIX + e.detail.id;
+    const url = GerritNav.getUrlForChange(
+      this._change,
+      this._patchRange.patchNum,
+      this._patchRange.basePatchNum,
+      this._editMode,
+      hash
+    );
+    history.replaceState(null, '', url);
+  }
+
+  _maybeScrollToMessage(hash: string) {
+    if (hash.startsWith(MSG_PREFIX) && this.messagesList) {
+      this.messagesList.scrollToMessage(hash.substr(MSG_PREFIX.length));
+    }
+  }
+
+  _getLocationSearch() {
+    // Not inlining to make it easier to test.
+    return window.location.search;
+  }
+
+  _getUrlParameter(param: string) {
+    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];
+      }
+    }
+    return null;
+  }
+
+  _maybeShowRevertDialog() {
+    getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => this._getLoggedIn())
+      .then(loggedIn => {
+        if (
+          !loggedIn ||
+          !this._change ||
+          this._change.status !== ChangeStatus.MERGED
+        ) {
+          // Do not display dialog if not logged-in or the change is not
+          // merged.
+          return;
+        }
+        if (this._getUrlParameter('revert')) {
+          this.$.actions.showRevertDialog();
+        }
+      });
+  }
+
+  _maybeShowReplyDialog() {
+    this._getLoggedIn().then(loggedIn => {
+      if (!loggedIn) {
+        return;
+      }
+
+      if (this.viewState.showReplyDialog) {
+        this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
+        // 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);
+      }
+    });
+  }
+
+  _maybeShowDownloadDialog() {
+    if (this.viewState.showDownloadDialog) {
+      this._handleOpenDownloadDialog();
+      this.set('viewState.showDownloadDialog', false);
+    }
+  }
+
+  _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._setDiffViewMode(true);
+      this.set('_numFilesShown', DEFAULT_NUM_FILES_SHOWN);
+    }
+    this.set('viewState.changeNum', this._changeNum);
+    this.set('viewState.patchRange', this._patchRange);
+  }
+
+  _changeChanged(change?: ChangeInfo | ParsedChangeInfo) {
+    if (!change || !this._patchRange || !this._allPatchSets) {
+      return;
+    }
+
+    // We get the parent first so we keep the original value for basePatchNum
+    // and not the updated value.
+    const parent = this._getBasePatchNum(change, this._patchRange);
+
+    this.set(
+      '_patchRange.patchNum',
+      this._patchRange.patchNum || computeLatestPatchNum(this._allPatchSets)
+    );
+
+    this.set('_patchRange.basePatchNum', parent);
+
+    const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
+    fireTitleChange(this, title);
+  }
+
+  /**
+   * Gets base patch number, if it is a parent try and decide from
+   * preference whether to default to `auto merge`, `Parent 1` or `PARENT`.
+   */
+  _getBasePatchNum(
+    change: ChangeInfo | ParsedChangeInfo,
+    patchRange: ChangeViewPatchRange
+  ) {
+    if (patchRange.basePatchNum && patchRange.basePatchNum !== 'PARENT') {
+      return patchRange.basePatchNum;
+    }
+
+    const revisionInfo = this._getRevisionInfo(change);
+    if (!revisionInfo) return 'PARENT';
+
+    const parentCounts = revisionInfo.getParentCountMap();
+    // check that there is at least 2 parents otherwise fall back to 1,
+    // which means there is only one parent.
+    const parentCount = hasOwnProperty(parentCounts, 1) ? parentCounts[1] : 1;
+
+    const preferFirst =
+      this._prefs && this._prefs.default_base_for_merges === 'FIRST_PARENT';
+
+    if (parentCount > 1 && preferFirst && !patchRange.patchNum) {
+      return -1;
+    }
+
+    return 'PARENT';
+  }
+
+  _computeChangeUrl(change: ChangeInfo) {
+    return GerritNav.getUrlForChange(change);
+  }
+
+  _computeShowCommitInfo(changeStatus: string, current_revision: RevisionInfo) {
+    return changeStatus === 'Merged' && current_revision;
+  }
+
+  _computeMergedCommitInfo(
+    current_revision: CommitId,
+    revisions: {[revisionId: string]: RevisionInfo}
+  ) {
+    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
+    if (!rev.commit.commit) {
+      rev.commit.commit = current_revision;
+    }
+    return rev.commit;
+  }
+
+  _computeChangeIdClass(displayChangeId: string) {
+    return displayChangeId === CHANGE_ID_ERROR.MISMATCH ? 'warning' : '';
+  }
+
+  _computeTitleAttributeWarning(displayChangeId: string) {
+    if (displayChangeId === CHANGE_ID_ERROR.MISMATCH) {
+      return 'Change-Id mismatch';
+    } else if (displayChangeId === CHANGE_ID_ERROR.MISSING) {
+      return 'No Change-Id in commit message';
+    }
+    return undefined;
+  }
+
+  _computeChangeIdCommitMessageError(
+    commitMessage?: string,
+    change?: ChangeInfo
+  ) {
+    if (change === undefined) {
+      return undefined;
+    }
+
+    if (!commitMessage) {
+      return CHANGE_ID_ERROR.MISSING;
+    }
+
+    // Find the last match in the commit message:
+    let changeId;
+    let changeIdArr;
+
+    while ((changeIdArr = CHANGE_ID_REGEX_PATTERN.exec(commitMessage))) {
+      changeId = changeIdArr[2];
+    }
+
+    if (changeId) {
+      // A change-id is detected in the commit message.
+
+      if (changeId === change.change_id) {
+        // The change-id found matches the real change-id.
+        return null;
+      }
+      // The change-id found does not match the change-id.
+      return CHANGE_ID_ERROR.MISMATCH;
+    }
+    // There is no change-id in the commit message.
+    return CHANGE_ID_ERROR.MISSING;
+  }
+
+  _computeReplyButtonLabel(
+    changeRecord?: ElementPropertyDeepChange<
+      GrChangeView,
+      '_diffDrafts'
+    > | null,
+    canStartReview?: boolean
+  ) {
+    if (changeRecord === undefined || canStartReview === undefined) {
+      return 'Reply';
+    }
+
+    const drafts = (changeRecord && changeRecord.base) || {};
+    const draftCount = Object.keys(drafts).reduce(
+      (count, file) => count + drafts[file].length,
+      0
+    );
+
+    let label = canStartReview ? 'Start Review' : 'Reply';
+    if (draftCount > 0) {
+      label += ` (${draftCount})`;
+    }
+    return label;
+  }
+
+  _handleOpenReplyDialog(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+    this._getLoggedIn().then(isLoggedIn => {
+      if (!isLoggedIn) {
+        this.dispatchEvent(
+          new CustomEvent('show-auth-required', {
+            composed: true,
+            bubbles: true,
+          })
+        );
+        return;
+      }
+
+      e.preventDefault();
+      this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
+    });
+  }
+
+  _handleOpenDownloadDialogShortcut(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this._handleOpenDownloadDialog();
+  }
+
+  _handleEditTopic(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.metadata.editTopic();
+  }
+
+  _handleDiffAgainstBase(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+    if (!this._change) throw new Error('missing required change property');
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+      fireAlert(this, 'Base is already selected.');
+      return;
+    }
+    GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
+  }
+
+  _handleDiffBaseAgainstLeft(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+    if (!this._change) throw new Error('missing required change property');
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+      fireAlert(this, 'Left is already base.');
+      return;
+    }
+    GerritNav.navigateToChange(this._change, this._patchRange.basePatchNum);
+  }
+
+  _handleDiffAgainstLatest(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+    if (!this._change) throw new Error('missing required change property');
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+      fireAlert(this, 'Latest is already selected.');
+      return;
+    }
+    GerritNav.navigateToChange(
+      this._change,
+      latestPatchNum,
+      this._patchRange.basePatchNum
+    );
+  }
+
+  _handleDiffRightAgainstLatest(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+    if (!this._change) throw new Error('missing required change property');
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+      fireAlert(this, 'Right is already latest.');
+      return;
+    }
+    GerritNav.navigateToChange(
+      this._change,
+      latestPatchNum,
+      this._patchRange.patchNum
+    );
+  }
+
+  _handleDiffBaseAgainstLatest(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+    if (!this._change) throw new Error('missing required change property');
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (
+      patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
+      patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)
+    ) {
+      fireAlert(this, 'Already diffing base against latest.');
+      return;
+    }
+    GerritNav.navigateToChange(this._change, latestPatchNum);
+  }
+
+  _handleRefreshChange(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+    e.preventDefault();
+    this._reload(/* isLocationChange= */ false, /* clearPatchset= */ true);
+  }
+
+  _handleToggleChangeStar(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+    e.preventDefault();
+    this.$.changeStar.toggleStar();
+  }
+
+  _handleUpToDashboard(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this._determinePageBack();
+  }
+
+  _handleExpandAllMessages(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    if (this.messagesList) {
+      this.messagesList.handleExpandCollapse(true);
+    }
+  }
+
+  _handleCollapseAllMessages(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    if (this.messagesList) {
+      this.messagesList.handleExpandCollapse(false);
+    }
+  }
+
+  _handleOpenDiffPrefsShortcut(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    if (this._diffPrefsDisabled) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.fileList.openDiffPrefs();
+  }
+
+  _determinePageBack() {
+    // Default backPage to root if user came to change view page
+    // via an email link, etc.
+    GerritNav.navigateToRelativeUrl(this.backPage || GerritNav.getUrlForRoot());
+  }
+
+  _handleLabelRemoved(
+    splices: Array<PolymerSplice<ApprovalInfo[]>>,
+    path: string
+  ) {
+    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) as QuickLabelInfo;
+        if (
+          labelDict.approved &&
+          labelDict.approved._account_id === removed._account_id
+        ) {
+          this._reload();
+          return;
+        }
+      }
+    }
+  }
+
+  @observe('_change.labels.*')
+  _labelsChanged(
+    changeRecord: PolymerDeepPropertyChange<
+      LabelNameToInfoMap,
+      PolymerSpliceChange<ApprovalInfo[]>
+    >
+  ) {
+    if (!changeRecord) {
+      return;
+    }
+    if (changeRecord.value && isPolymerSpliceChange(changeRecord.value)) {
+      this._handleLabelRemoved(
+        changeRecord.value.indexSplices,
+        changeRecord.path
+      );
+    }
+    this.$.jsAPI.handleEvent(PluginEventType.LABEL_CHANGE, {
+      change: this._change,
+    });
+  }
+
+  _openReplyDialog(section?: FocusTarget) {
+    this.$.replyOverlay.open().finally(() => {
+      // the following code should be executed no matter open succeed or not
+      this._resetReplyOverlayFocusStops();
+      this.$.replyDialog.open(section);
+      flush();
+      this.$.replyOverlay.center();
+    });
+  }
+
+  _handleGetChangeDetailError(response?: Response | null) {
+    firePageError(this, response);
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  _getServerConfig() {
+    return this.$.restAPI.getConfig();
+  }
+
+  _getProjectConfig() {
+    if (!this._change) throw new Error('missing required change property');
+    return this.$.restAPI
+      .getProjectConfig(this._change.project)
+      .then(config => {
+        this._projectConfig = config;
+      });
+  }
+
+  _getPreferences() {
+    return this.$.restAPI.getPreferences();
+  }
+
+  _prepareCommitMsgForLinkify(msg: string) {
+    // 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= or CC= as part of the email address.
+    return msg.replace(REVIEWERS_REGEX, '$1=\u200B');
+  }
+
+  /**
+   * Utility function to make the necessary modifications to a change in the
+   * case an edit exists.
+   */
+  _processEdit(change: ParsedChangeInfo, edit?: EditInfo | false) {
+    if (!edit) return;
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    if (!edit.commit.commit) throw new Error('undefined edit.commit.commit');
+    const changeWithEdit = change;
+    if (changeWithEdit.revisions)
+      changeWithEdit.revisions[edit.commit.commit] = {
+        _number: EditPatchSetNum,
+        basePatchNum: edit.base_patch_set_number,
+        commit: edit.commit,
+        fetch: edit.fetch,
+      };
+
+    // If the edit is based on the most recent patchset, load it by
+    // default, unless another patch set to load was specified in the URL.
+    if (
+      !this._patchRange.patchNum &&
+      changeWithEdit.current_revision === edit.base_revision
+    ) {
+      changeWithEdit.current_revision = edit.commit.commit;
+      this.set('_patchRange.patchNum', EditPatchSetNum);
+      // Because edits are fibbed as revisions and added to the revisions
+      // array, and revision actions are always derived from the 'latest'
+      // patch set, we must copy over actions from the patch set base.
+      // Context: Issue 7243
+      if (changeWithEdit.revisions) {
+        changeWithEdit.revisions[edit.commit.commit].actions =
+          changeWithEdit.revisions[edit.base_revision].actions;
+      }
+    }
+  }
+
+  _getChangeDetail() {
+    if (!this._changeNum)
+      throw new Error('missing required changeNum property');
+    const detailCompletes = this.$.restAPI.getChangeDetail(this._changeNum, r =>
+      this._handleGetChangeDetailError(r)
+    );
+    const editCompletes = this._getEdit();
+    const prefCompletes = this._getPreferences();
+
+    return Promise.all([detailCompletes, editCompletes, prefCompletes]).then(
+      ([change, edit, prefs]) => {
+        this._prefs = prefs;
+
+        if (!change) {
+          return false;
+        }
+        this._processEdit(change, edit);
+        // Issue 4190: Coalesce missing topics to null.
+        // TODO(TS): code needs second thought,
+        // it might be that nulls were assigned to trigger some bindings
+        if (!change.topic) {
+          change.topic = (null as unknown) as undefined;
+        }
+        if (!change.reviewer_updates) {
+          change.reviewer_updates = (null as unknown) as undefined;
+        }
+        const latestRevisionSha = this._getLatestRevisionSHA(change);
+        if (!latestRevisionSha)
+          throw new Error('Could not find latest Revision Sha');
+        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;
+
+        // Slice returns a number as a string, convert to an int.
+        this._lineHeight = Number(lineHeight.slice(0, lineHeight.length - 2));
+
+        this._change = change;
+        if (
+          !this._patchRange ||
+          !this._patchRange.patchNum ||
+          patchNumEquals(this._patchRange.patchNum, currentRevision._number)
+        ) {
+          // CommitInfo.commit is optional, and may need patching.
+          if (currentRevision.commit && !currentRevision.commit.commit) {
+            currentRevision.commit.commit = latestRevisionSha as CommitId;
+          }
+          this._commitInfo = currentRevision.commit;
+          this._selectedRevision = currentRevision;
+          // TODO: Fetch and process files.
+        } else {
+          if (!this._change?.revisions || !this._patchRange) return false;
+          this._selectedRevision = Object.values(this._change.revisions).find(
+            revision => {
+              // edit patchset is a special one
+              const thePatchNum = this._patchRange!.patchNum;
+              if (thePatchNum === 'edit') {
+                return revision._number === thePatchNum;
+              }
+              return revision._number === Number(`${thePatchNum}`);
+            }
+          );
+        }
+        return false;
+      }
+    );
+  }
+
+  _isSubmitEnabled(revisionActions: ActionNameToActionInfoMap) {
+    return !!(
+      revisionActions &&
+      revisionActions.submit &&
+      revisionActions.submit.enabled
+    );
+  }
+
+  _isParentCurrent(revisionActions: ActionNameToActionInfoMap) {
+    if (revisionActions && revisionActions.rebase) {
+      return !revisionActions.rebase.enabled;
+    } else {
+      return true;
+    }
+  }
+
+  _getEdit() {
+    if (!this._changeNum)
+      return Promise.reject(new Error('missing required changeNum property'));
+    return this.$.restAPI.getChangeEdit(this._changeNum, true);
+  }
+
+  _getLatestCommitMessage() {
+    if (!this._changeNum)
+      throw new Error('missing required changeNum property');
+    const lastpatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (lastpatchNum === undefined)
+      throw new Error('missing lastPatchNum property');
+    return this.$.restAPI
+      .getChangeCommitInfo(this._changeNum, lastpatchNum)
+      .then(commitInfo => {
+        if (!commitInfo) return;
+        this._latestCommitMessage = this._prepareCommitMsgForLinkify(
+          commitInfo.message
+        );
+      });
+  }
+
+  _getLatestRevisionSHA(change: ChangeInfo | ParsedChangeInfo) {
+    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.
+    let latestRev = null;
+    let latestPatchNum = -1 as PatchSetNum;
+    for (const rev in change.revisions) {
+      if (!hasOwnProperty(change.revisions, rev)) {
+        continue;
+      }
+
+      if (change.revisions[rev]._number > latestPatchNum) {
+        latestRev = rev;
+        latestPatchNum = change.revisions[rev]._number;
+      }
+    }
+    return latestRev;
+  }
+
+  _getCommitInfo() {
+    if (!this._changeNum)
+      throw new Error('missing required changeNum property');
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    if (this._patchRange.patchNum === undefined)
+      throw new Error('missing required patchNum property');
+    return this.$.restAPI
+      .getChangeCommitInfo(this._changeNum, this._patchRange.patchNum)
+      .then(commitInfo => {
+        this._commitInfo = commitInfo;
+      });
+  }
+
+  _reloadDraftsWithCallback(e: CustomEvent<{resolve: () => void}>) {
+    return this._reloadDrafts().then(() => e.detail.resolve());
+  }
+
+  /**
+   * Fetches a new changeComment object, and data for all types of comments
+   * (comments, robot comments, draft comments) is requested.
+   */
+  _reloadComments() {
+    // We are resetting all comment related properties, because we want to avoid
+    // a new change being loaded and then paired with outdated comments.
+    this._changeComments = undefined;
+    this._commentThreads = undefined;
+    this._diffDrafts = undefined;
+    this._draftCommentThreads = undefined;
+    this._robotCommentThreads = undefined;
+    if (!this._changeNum)
+      throw new Error('missing required changeNum property');
+
+    const portedCommentsPromise = this.$.commentAPI.getPortedComments(
+      this._changeNum
+    );
+    const commentsPromise = this.$.commentAPI
+      .loadAll(this._changeNum)
+      .then(comments => {
+        this.reporting.time(PORTING_COMMENTS_CHANGE_LATENCY_LABEL);
+        this._recomputeComments(comments);
+      });
+    Promise.all([portedCommentsPromise, commentsPromise]).then(() => {
+      this.reporting.timeEnd(PORTING_COMMENTS_CHANGE_LATENCY_LABEL);
+    });
+    return commentsPromise;
+  }
+
+  /**
+   * Fetches a new changeComment object, but only updated data for drafts is
+   * requested.
+   *
+   * TODO(taoalpha): clean up this and _reloadComments, as single comment
+   * can be a thread so it does not make sense to only update drafts
+   * without updating threads
+   */
+  _reloadDrafts() {
+    if (!this._changeNum)
+      throw new Error('missing required changeNum property');
+    return this.$.commentAPI
+      .reloadDrafts(this._changeNum)
+      .then(comments => this._recomputeComments(comments));
+  }
+
+  _recomputeComments(comments: ChangeComments) {
+    this._changeComments = comments;
+    this._diffDrafts = {...this._changeComments.drafts};
+    this._commentThreads = this._changeComments.getAllThreadsForChange();
+    this._draftCommentThreads = this._commentThreads
+      .filter(isDraftThread)
+      .map(thread => {
+        const copiedThread = {...thread};
+        // Make a hardcopy of all comments and collapse all but last one
+        const commentsInThread = (copiedThread.comments = thread.comments.map(
+          comment => {
+            return {...comment, collapsed: true as boolean};
+          }
+        ));
+        commentsInThread[commentsInThread.length - 1].collapsed = false;
+        return copiedThread;
+      });
+  }
+
+  /**
+   * Reload the change.
+   *
+   * @param isLocationChange Reloads the related changes
+   * when true and ends reporting events that started on location change.
+   * @param clearPatchset Reloads the related changes
+   * ignoring any patchset choice made.
+   * @return A promise that resolves when the core data has loaded.
+   * Some non-core data loading may still be in-flight when the core data
+   * promise resolves.
+   */
+  _reload(isLocationChange?: boolean, clearPatchset?: boolean) {
+    if (clearPatchset && this._change) {
+      GerritNav.navigateToChange(this._change);
+      return Promise.resolve([]);
+    }
+    this._loading = true;
+    this._relatedChangesCollapsed = true;
+    this.reporting.time(CHANGE_RELOAD_TIMING_LABEL);
+    this.reporting.time(CHANGE_DATA_TIMING_LABEL);
+
+    // Array to house all promises related to data requests.
+    const allDataPromises: Promise<unknown>[] = [];
+
+    // Resolves when the change detail and the edit patch set (if available)
+    // are loaded.
+    const detailCompletes = this._getChangeDetail();
+    allDataPromises.push(detailCompletes);
+
+    // Resolves when the loading flag is set to false, meaning that some
+    // change content may start appearing.
+    const loadingFlagSet = detailCompletes
+      .then(() => {
+        this._loading = false;
+        this.dispatchEvent(
+          new CustomEvent('change-details-loaded', {
+            bubbles: true,
+            composed: true,
+          })
+        );
+      })
+      .then(() => {
+        this.reporting.timeEnd(CHANGE_RELOAD_TIMING_LABEL);
+        if (isLocationChange) {
+          this.reporting.changeDisplayed();
+        }
+      });
+
+    // Resolves when the project config has loaded.
+    const projectConfigLoaded = detailCompletes.then(() =>
+      this._getProjectConfig()
+    );
+    allDataPromises.push(projectConfigLoaded);
+
+    // Resolves when change comments have loaded (comments, drafts and robot
+    // comments).
+    const commentsLoaded = this._reloadComments();
+    allDataPromises.push(commentsLoaded);
+
+    let coreDataPromise;
+
+    // If the patch number is specified
+    if (this._patchRange && this._patchRange.patchNum) {
+      // Because a specific patchset is specified, reload the resources that
+      // are keyed by patch number or patch range.
+      const patchResourcesLoaded = this._reloadPatchNumDependentResources();
+      allDataPromises.push(patchResourcesLoaded);
+
+      // Promise resolves when the change detail and patch dependent resources
+      // have loaded.
+      const detailAndPatchResourcesLoaded = Promise.all([
+        patchResourcesLoaded,
+        loadingFlagSet,
+      ]);
+
+      // Promise resolves when mergeability information has loaded.
+      const mergeabilityLoaded = detailAndPatchResourcesLoaded.then(() =>
+        this._getMergeability()
+      );
+      allDataPromises.push(mergeabilityLoaded);
+
+      // Promise resovles when the change actions have loaded.
+      const actionsLoaded = detailAndPatchResourcesLoaded.then(() =>
+        this.$.actions.reload()
+      );
+      allDataPromises.push(actionsLoaded);
+
+      // The core data is loaded when both mergeability and actions are known.
+      coreDataPromise = Promise.all([mergeabilityLoaded, actionsLoaded]);
+    } else {
+      // Resolves when the file list has loaded.
+      const fileListReload = loadingFlagSet.then(() =>
+        this.$.fileList.reload()
+      );
+      allDataPromises.push(fileListReload);
+
+      const latestCommitMessageLoaded = loadingFlagSet.then(() => {
+        // If the latest commit message is known, there is nothing to do.
+        if (this._latestCommitMessage) {
+          return Promise.resolve();
+        }
+        return this._getLatestCommitMessage();
+      });
+      allDataPromises.push(latestCommitMessageLoaded);
+
+      // Promise resolves when mergeability information has loaded.
+      const mergeabilityLoaded = loadingFlagSet.then(() =>
+        this._getMergeability()
+      );
+      allDataPromises.push(mergeabilityLoaded);
+
+      // Core data is loaded when mergeability has been loaded.
+      coreDataPromise = Promise.all([mergeabilityLoaded]);
+    }
+
+    if (isLocationChange) {
+      this._editingCommitMessage = false;
+      const relatedChangesLoaded = coreDataPromise.then(() =>
+        this.$.relatedChanges.reload()
+      );
+      allDataPromises.push(relatedChangesLoaded);
+    }
+
+    Promise.all(allDataPromises).then(() => {
+      this.reporting.timeEnd(CHANGE_DATA_TIMING_LABEL);
+      if (isLocationChange) {
+        this.reporting.changeFullyLoaded();
+      }
+    });
+
+    return coreDataPromise;
+  }
+
+  /**
+   * Kicks off requests for resources that rely on the patch range
+   * (`this._patchRange`) being defined.
+   */
+  _reloadPatchNumDependentResources() {
+    return Promise.all([this._getCommitInfo(), this.$.fileList.reload()]);
+  }
+
+  _getMergeability() {
+    if (!this._change) {
+      this._mergeable = null;
+      return Promise.resolve();
+    }
+    // If the change is closed, it is not mergeable. Note: already merged
+    // changes are obviously not mergeable, but the mergeability API will not
+    // answer for abandoned changes.
+    if (
+      this._change.status === ChangeStatus.MERGED ||
+      this._change.status === ChangeStatus.ABANDONED
+    ) {
+      this._mergeable = false;
+      return Promise.resolve();
+    }
+
+    if (!this._changeNum) {
+      return Promise.reject(new Error('missing required changeNum property'));
+    }
+
+    this._mergeable = null;
+    return this.$.restAPI.getMergeable(this._changeNum).then(mergableInfo => {
+      if (mergableInfo) {
+        this._mergeable = mergableInfo.mergeable;
+      }
+    });
+  }
+
+  _computeCanStartReview(change: ChangeInfo) {
+    return !!(
+      change.actions &&
+      change.actions.ready &&
+      change.actions.ready.enabled
+    );
+  }
+
+  _computeReplyDisabled() {
+    return false;
+  }
+
+  _computeChangePermalinkAriaLabel(changeNum: NumericChangeId) {
+    return `Change ${changeNum}`;
+  }
+
+  _computeCommitMessageCollapsed(collapsed?: boolean, collapsible?: boolean) {
+    return collapsible && collapsed;
+  }
+
+  _computeRelatedChangesClass(collapsed: boolean) {
+    return collapsed ? 'collapsed' : '';
+  }
+
+  _computeCollapseText(collapsed: boolean) {
+    // Symbols are up and down triangles.
+    return collapsed ? '\u25bc Show more' : '\u25b2 Show less';
+  }
+
+  /**
+   * Returns the text to be copied when
+   * click the copy icon next to change subject
+   */
+  _computeCopyTextForTitle(change: ChangeInfo) {
+    return (
+      `${change._number}: ${change.subject} | ` +
+      `${location.protocol}//${location.host}` +
+      `${this._computeChangeUrl(change)}`
+    );
+  }
+
+  _toggleCommitCollapsed() {
+    this._commitCollapsed = !this._commitCollapsed;
+    if (this._commitCollapsed) {
+      window.scrollTo(0, 0);
+    }
+  }
+
+  _toggleRelatedChangesCollapsed() {
+    this._relatedChangesCollapsed = !this._relatedChangesCollapsed;
+    if (this._relatedChangesCollapsed) {
+      window.scrollTo(0, 0);
+    }
+  }
+
+  _computeCommitCollapsible(commitMessage?: string) {
+    if (!commitMessage) {
+      return false;
+    }
+    return commitMessage.split('\n').length >= MIN_LINES_FOR_COMMIT_COLLAPSE;
+  }
+
+  _getOffsetHeight(element: HTMLElement) {
+    return element.offsetHeight;
+  }
+
+  _getScrollHeight(element: HTMLElement) {
+    return element.scrollHeight;
+  }
+
+  /**
+   * Get the line height of an element to the nearest integer.
+   */
+  _getLineHeight(element: Element) {
+    const lineHeightStr = getComputedStyle(element).lineHeight;
+    return Math.round(Number(lineHeightStr.slice(0, lineHeightStr.length - 2)));
+  }
+
+  /**
+   * New max height for the related changes section, shorter than the existing
+   * change info height.
+   */
+  _updateRelatedChangeMaxHeight() {
+    // Takes into account approximate height for the expand button and
+    // bottom margin.
+    const EXTRA_HEIGHT = 30;
+    let newHeight;
+
+    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 * EXTRA_HEIGHT,
+        MINIMUM_RELATED_MAX_HEIGHT
+      );
+      newHeight = medRelatedHeight;
+    } else {
+      if (this._commitCollapsible) {
+        // 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.
+        newHeight = this._getOffsetHeight(this.$.commitMessage);
+      } else {
+        newHeight =
+          this._getOffsetHeight(this.$.commitAndRelated) - EXTRA_HEIGHT;
+      }
+    }
+    const stylesToUpdate: {[key: string]: string} = {};
+
+    // 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 = newHeight % lineHeight;
+    newHeight = newHeight - remainder;
+
+    stylesToUpdate['--relation-chain-max-height'] = `${newHeight}px`;
+
+    // Update the max-height of the relation chain to this new height.
+    if (this._commitCollapsible) {
+      stylesToUpdate['--related-change-btn-top-padding'] = `${remainder}px`;
+    }
+
+    this.updateStyles(stylesToUpdate);
+  }
+
+  _computeShowRelatedToggle() {
+    // Make sure the max height has been applied, since there is now content
+    // to populate.
+    if (!getComputedStyleValue('--relation-chain-max-height', this)) {
+      this._updateRelatedChangeMaxHeight();
+    }
+    // Prevents showMore from showing when click on related change, since the
+    // line height would be positive, but related changes height is 0.
+    if (!this._getScrollHeight(this.$.relatedChanges)) {
+      return (this._showRelatedToggle = false);
+    }
+
+    if (
+      this._getScrollHeight(this.$.relatedChanges) >
+      this._getOffsetHeight(this.$.relatedChanges) +
+        this._getLineHeight(this.$.relatedChanges)
+    ) {
+      return (this._showRelatedToggle = true);
+    }
+    return (this._showRelatedToggle = false);
+  }
+
+  _updateToggleContainerClass(showRelatedToggle: boolean) {
+    if (showRelatedToggle) {
+      this.$.relatedChangesToggle.classList.add('showToggle');
+    } else {
+      this.$.relatedChangesToggle.classList.remove('showToggle');
+    }
+  }
+
+  _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(() => {
+      if (!this._change) throw new Error('missing required change property');
+      const change = this._change;
+      fetchChangeUpdates(change, this.$.restAPI).then(result => {
+        let toastMessage = null;
+        if (!result.isLatest) {
+          toastMessage = ReloadToastMessage.NEWER_REVISION;
+        } else if (result.newStatus === ChangeStatus.MERGED) {
+          toastMessage = ReloadToastMessage.MERGED;
+        } else if (result.newStatus === ChangeStatus.ABANDONED) {
+          toastMessage = ReloadToastMessage.ABANDONED;
+        } else if (result.newStatus === ChangeStatus.NEW) {
+          toastMessage = ReloadToastMessage.RESTORED;
+        } else if (result.newMessages) {
+          toastMessage = ReloadToastMessage.NEW_MESSAGE;
+        }
+
+        // We have to make sure that the update is still relevant for the user.
+        // Since starting to fetch the change update the user may have sent a
+        // reply, or the change might have been reloaded, or it could be in the
+        // process of being reloaded.
+        const changeWasReloaded = change !== this._change;
+        if (!toastMessage || this._loading || changeWasReloaded) {
+          this._startUpdateCheckTimer();
+          return;
+        }
+
+        this._cancelUpdateCheckTimer();
+        this.dispatchEvent(
+          new CustomEvent('show-alert', {
+            detail: {
+              message: toastMessage,
+              // Persist this alert.
+              dismissOnNavigation: true,
+              action: 'Reload',
+              callback: () => {
+                this._reload(
+                  /* isLocationChange= */ false,
+                  /* clearPatchset= */ true
+                );
+              },
+            },
+            composed: true,
+            bubbles: true,
+          })
+        );
+      });
+    }, this._serverConfig.change.update_delay * 1000);
+  }
+
+  _cancelUpdateCheckTimer() {
+    if (this._updateCheckTimerHandle) {
+      this.cancelAsync(this._updateCheckTimerHandle);
+    }
+    this._updateCheckTimerHandle = null;
+  }
+
+  _handleVisibilityChange() {
+    if (document.hidden && this._updateCheckTimerHandle) {
+      this._cancelUpdateCheckTimer();
+    } else if (!this._updateCheckTimerHandle) {
+      this._startUpdateCheckTimer();
+    }
+  }
+
+  _handleTopicChanged() {
+    this.$.relatedChanges.reload();
+  }
+
+  _computeHeaderClass(editMode?: boolean) {
+    const classes = ['header'];
+    if (editMode) {
+      classes.push('editMode');
+    }
+    return classes.join(' ');
+  }
+
+  _computeEditMode(
+    patchRangeRecord: PolymerDeepPropertyChange<
+      ChangeViewPatchRange,
+      ChangeViewPatchRange
+    >,
+    paramsRecord: PolymerDeepPropertyChange<
+      AppElementChangeViewParams,
+      AppElementChangeViewParams
+    >
+  ) {
+    if (!patchRangeRecord || !paramsRecord) {
+      return undefined;
+    }
+
+    if (paramsRecord.base && paramsRecord.base.edit) {
+      return true;
+    }
+
+    const patchRange = patchRangeRecord.base || {};
+    return patchNumEquals(patchRange.patchNum, EditPatchSetNum);
+  }
+
+  _handleFileActionTap(e: CustomEvent<{path: string; action: string}>) {
+    e.preventDefault();
+    const controls = this.$.fileListHeader.shadowRoot!.querySelector(
+      '#editControls'
+    ) as GrEditControls | null;
+    if (!controls) throw new Error('Missing edit controls');
+    if (!this._change) throw new Error('missing required change property');
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    const path = e.detail.path;
+    switch (e.detail.action) {
+      case GrEditConstants.Actions.DELETE.id:
+        controls.openDeleteDialog(path);
+        break;
+      case GrEditConstants.Actions.OPEN.id:
+        GerritNav.navigateToRelativeUrl(
+          GerritNav.getEditUrlForDiff(
+            this._change,
+            path,
+            this._patchRange.patchNum
+          )
+        );
+        break;
+      case GrEditConstants.Actions.RENAME.id:
+        controls.openRenameDialog(path);
+        break;
+      case GrEditConstants.Actions.RESTORE.id:
+        controls.openRestoreDialog(path);
+        break;
+    }
+  }
+
+  _computeCommitMessageKey(number: NumericChangeId, revision: CommitId) {
+    return `c${number}_rev${revision}`;
+  }
+
+  @observe('_patchRange.patchNum')
+  _patchNumChanged(patchNumStr: PatchSetNum) {
+    if (!this._selectedRevision) {
+      return;
+    }
+    if (!this._change) throw new Error('missing required change property');
+
+    let patchNum: PatchSetNum;
+    if (patchNumStr === 'edit') {
+      patchNum = EditPatchSetNum;
+    } else {
+      patchNum = Number(`${patchNumStr}`) as PatchSetNum;
+    }
+
+    if (patchNum === this._selectedRevision._number) {
+      return;
+    }
+    if (this._change.revisions)
+      this._selectedRevision = Object.values(this._change.revisions).find(
+        revision => revision._number === patchNum
+      );
+  }
+
+  /**
+   * If an edit exists already, load it. Otherwise, toggle edit mode via the
+   * navigation API.
+   */
+  _handleEditTap() {
+    if (!this._change || !this._change.revisions)
+      throw new Error('missing required change property');
+    const editInfo = Object.values(this._change.revisions).find(
+      info => info._number === EditPatchSetNum
+    );
+
+    if (editInfo) {
+      GerritNav.navigateToChange(this._change, EditPatchSetNum);
+      return;
+    }
+
+    // Avoid putting patch set in the URL unless a non-latest patch set is
+    // selected.
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    let patchNum;
+    if (
+      !patchNumEquals(
+        this._patchRange.patchNum,
+        computeLatestPatchNum(this._allPatchSets)
+      )
+    ) {
+      patchNum = this._patchRange.patchNum;
+    }
+    GerritNav.navigateToChange(this._change, patchNum, undefined, true);
+  }
+
+  _handleStopEditTap() {
+    if (!this._change) throw new Error('missing required change property');
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
+  }
+
+  _resetReplyOverlayFocusStops() {
+    this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
+  }
+
+  _handleToggleStar(e: CustomEvent<{change: ChangeInfo; starred: boolean}>) {
+    this.$.restAPI.saveChangeStarred(e.detail.change._number, e.detail.starred);
+  }
+
+  _getRevisionInfo(change: ChangeInfo | ParsedChangeInfo) {
+    return new RevisionInfoClass(change);
+  }
+
+  _computeCurrentRevision(
+    currentRevision: CommitId,
+    revisions: {[revisionId: string]: RevisionInfo}
+  ) {
+    return currentRevision && revisions && revisions[currentRevision];
+  }
+
+  _computeDiffPrefsDisabled(disableDiffPrefs: boolean, loggedIn: boolean) {
+    return disableDiffPrefs || !loggedIn;
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeLatestPatchNum(allPatchSets: PatchSet[]) {
+    return computeLatestPatchNum(allPatchSets);
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _hasEditBasedOnCurrentPatchSet(allPatchSets: PatchSet[]) {
+    return hasEditBasedOnCurrentPatchSet(allPatchSets);
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _hasEditPatchsetLoaded(
+    patchRangeRecord: PolymerDeepPropertyChange<
+      ChangeViewPatchRange,
+      ChangeViewPatchRange
+    >
+  ) {
+    const patchRange = patchRangeRecord.base;
+    if (!patchRange) {
+      return false;
+    }
+    return hasEditPatchsetLoaded(patchRange);
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeAllPatchSets(change: ChangeInfo) {
+    return computeAllPatchSets(change);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-view': GrChangeView;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
index f0b8372..fbf01dc 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
@@ -349,6 +349,9 @@
   >
     <section class="changeInfoSection">
       <div class$="[[_computeHeaderClass(_editMode)]]">
+        <h1 class="assistive-tech-only">
+          Change [[_change._number]]: [[_change.subject]]
+        </h1>
         <div class="headerTitle">
           <div class="changeStatuses">
             <template is="dom-repeat" items="[[_changeStatuses]]" as="status">
@@ -419,6 +422,7 @@
         <!-- end commit actions -->
       </div>
       <!-- end header -->
+      <h2 class="assistive-tech-only">Change metadata</h2>
       <div class="changeInfo">
         <div class="changeInfo-column changeMetadata hideOnMobileOverlay">
           <gr-change-metadata
@@ -436,6 +440,7 @@
         <div id="mainChangeInfo" class="changeInfo-column mainChangeInfo">
           <div id="commitAndRelated" class="hideOnMobileOverlay">
             <div class="commitContainer">
+              <h3 class="assistive-tech-only">Commit Message</h3>
               <div>
                 <gr-button
                   id="replyBtn"
@@ -539,6 +544,7 @@
       </div>
     </section>
 
+    <h2 class="assistive-tech-only">Files and Comments tabs</h2>
     <paper-tabs id="primaryTabs" on-selected-changed="_setActivePrimaryTab">
       <paper-tab data-name$="[[_constants.PrimaryTab.FILES]]">Files</paper-tab>
       <paper-tab
@@ -552,6 +558,11 @@
           <span>Comments</span></gr-tooltip-content
         >
       </paper-tab>
+      <template is="dom-if" if="[[_isChecksEnabled]]">
+        <paper-tab data-name$="[[_constants.PrimaryTab.CHECKS]]"
+          >Checks</paper-tab
+        >
+      </template>
       <template
         is="dom-repeat"
         items="[[_dynamicTabHeaderEndpoints]]"
@@ -637,10 +648,17 @@
           logged-in="[[_loggedIn]]"
           only-show-robot-comments-with-human-reply=""
           on-thread-list-modified="_handleReloadDiffComments"
+          unresolved-only
         ></gr-thread-list>
       </template>
       <template
         is="dom-if"
+        if="[[_isTabActive(_constants.PrimaryTab.CHECKS, _activeTabs)]]"
+      >
+        <gr-checks-tab id="checksTab"></gr-checks-tab>
+      </template>
+      <template
+        is="dom-if"
         if="[[_isTabActive(_constants.PrimaryTab.FINDINGS, _activeTabs)]]"
       >
         <gr-dropdown-list
@@ -698,6 +716,7 @@
       </paper-tab>
     </paper-tabs>
     <section class="changeLog">
+      <h2 class="assistive-tech-only">Change Log</h2>
       <template
         is="dom-if"
         if="[[_isTabActive(_constants.SecondaryTab.CHANGE_LOG, _activeTabs)]]"
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
deleted file mode 100644
index 6170bea..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
+++ /dev/null
@@ -1,2518 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import '../../edit/gr-edit-constants.js';
-import './gr-change-view.js';
-import {PrimaryTab, SecondaryTab, ChangeStatus} from '../../../constants/constants.js';
-
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GrEditConstants} from '../../edit/gr-edit-constants.js';
-import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {getComputedStyleValue} from '../../../utils/dom-util.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-import {EventType} from '../../plugins/gr-plugin-types.js';
-
-import 'lodash/lodash.js';
-import {generateChange, TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
-import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
-import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-const fixture = fixtureFromElement('gr-change-view');
-
-suite('gr-change-view tests', () => {
-  let element;
-
-  let navigateToChangeStub;
-
-  suiteSetup(() => {
-    const kb = TestKeyboardShortcutBinder.push();
-    kb.bindShortcut(Shortcut.SEND_REPLY, 'ctrl+enter');
-    kb.bindShortcut(Shortcut.REFRESH_CHANGE, 'shift+r');
-    kb.bindShortcut(Shortcut.OPEN_REPLY_DIALOG, 'a');
-    kb.bindShortcut(Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
-    kb.bindShortcut(Shortcut.TOGGLE_DIFF_MODE, 'm');
-    kb.bindShortcut(Shortcut.TOGGLE_CHANGE_STAR, 's');
-    kb.bindShortcut(Shortcut.UP_TO_DASHBOARD, 'u');
-    kb.bindShortcut(Shortcut.EXPAND_ALL_MESSAGES, 'x');
-    kb.bindShortcut(Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
-    kb.bindShortcut(Shortcut.OPEN_DIFF_PREFS, ',');
-    kb.bindShortcut(Shortcut.EDIT_TOPIC, 't');
-  });
-
-  suiteTeardown(() => {
-    TestKeyboardShortcutBinder.pop();
-  });
-
-  const TEST_SCROLL_TOP_PX = 100;
-
-  const ROBOT_COMMENTS_LIMIT = 10;
-
-  // TODO: should have a mock service to generate VALID fake data
-  const THREADS = [
-    {
-      comments: [
-        {
-          __path: '/COMMIT_MSG',
-          author: {
-            _account_id: 1000000,
-            name: 'user',
-            username: 'user',
-          },
-          patch_set: 2,
-          robot_id: 'rb1',
-          id: 'ecf0b9fa_fe1a5f62',
-          line: 5,
-          updated: '2018-02-08 18:49:18.000000000',
-          message: 'test',
-          unresolved: true,
-        },
-        {
-          __path: '/COMMIT_MSG',
-          author: {
-            _account_id: 1000000,
-            name: 'user',
-            username: 'user',
-          },
-          patch_set: 4,
-          id: 'ecf0b9fa_fe1a5f62_1',
-          line: 5,
-          updated: '2018-02-08 18:49:18.000000000',
-          message: 'test',
-          unresolved: true,
-        },
-        {
-          id: '503008e2_0ab203ee',
-          path: '/COMMIT_MSG',
-          line: 5,
-          in_reply_to: 'ecf0b9fa_fe1a5f62',
-          updated: '2018-02-13 22:48:48.018000000',
-          message: 'draft',
-          unresolved: false,
-          __draft: true,
-          __draftID: '0.m683trwff68',
-          __editing: false,
-          patch_set: '2',
-        },
-      ],
-      patchNum: 4,
-      path: '/COMMIT_MSG',
-      line: 5,
-      rootId: 'ecf0b9fa_fe1a5f62',
-      start_datetime: '2018-02-08 18:49:18.000000000',
-    },
-    {
-      comments: [
-        {
-          __path: '/COMMIT_MSG',
-          author: {
-            _account_id: 1000000,
-            name: 'user',
-            username: 'user',
-          },
-          patch_set: 3,
-          id: 'ecf0b9fa_fe5f62',
-          robot_id: 'rb2',
-          line: 5,
-          updated: '2018-02-08 18:49:18.000000000',
-          message: 'test',
-          unresolved: true,
-        },
-        {
-          __path: 'test.txt',
-          author: {
-            _account_id: 1000000,
-            name: 'user',
-            username: 'user',
-          },
-          patch_set: 3,
-          id: '09a9fb0a_1484e6cf',
-          side: 'PARENT',
-          updated: '2018-02-13 22:47:19.000000000',
-          message: 'Some comment on another patchset.',
-          unresolved: false,
-        },
-      ],
-      patchNum: 3,
-      path: 'test.txt',
-      rootId: '09a9fb0a_1484e6cf',
-      start_datetime: '2018-02-13 22:47:19.000000000',
-      commentSide: 'PARENT',
-    },
-    {
-      comments: [
-        {
-          __path: '/COMMIT_MSG',
-          author: {
-            _account_id: 1000000,
-            name: 'user',
-            username: 'user',
-          },
-          patch_set: 2,
-          id: '8caddf38_44770ec1',
-          line: 4,
-          updated: '2018-02-13 22:48:40.000000000',
-          message: 'Another unresolved comment',
-          unresolved: true,
-        },
-      ],
-      patchNum: 2,
-      path: '/COMMIT_MSG',
-      line: 4,
-      rootId: '8caddf38_44770ec1',
-      start_datetime: '2018-02-13 22:48:40.000000000',
-    },
-    {
-      comments: [
-        {
-          __path: '/COMMIT_MSG',
-          author: {
-            _account_id: 1000000,
-            name: 'user',
-            username: 'user',
-          },
-          patch_set: 2,
-          id: 'scaddf38_44770ec1',
-          line: 4,
-          updated: '2018-02-14 22:48:40.000000000',
-          message: 'Yet another unresolved comment',
-          unresolved: true,
-        },
-      ],
-      patchNum: 2,
-      path: '/COMMIT_MSG',
-      line: 4,
-      rootId: 'scaddf38_44770ec1',
-      start_datetime: '2018-02-14 22:48:40.000000000',
-    },
-    {
-      comments: [
-        {
-          id: 'zcf0b9fa_fe1a5f62',
-          path: '/COMMIT_MSG',
-          line: 6,
-          updated: '2018-02-15 22:48:48.018000000',
-          message: 'resolved draft',
-          unresolved: false,
-          __draft: true,
-          __draftID: '0.m683trwff68',
-          __editing: false,
-          patch_set: '2',
-        },
-      ],
-      patchNum: 4,
-      path: '/COMMIT_MSG',
-      line: 6,
-      rootId: 'zcf0b9fa_fe1a5f62',
-      start_datetime: '2018-02-09 18:49:18.000000000',
-    },
-    {
-      comments: [
-        {
-          __path: '/COMMIT_MSG',
-          author: {
-            _account_id: 1000000,
-            name: 'user',
-            username: 'user',
-          },
-          patch_set: 4,
-          id: 'rc1',
-          line: 5,
-          updated: '2019-02-08 18:49:18.000000000',
-          message: 'test',
-          unresolved: true,
-          robot_id: 'rc1',
-        },
-      ],
-      patchNum: 4,
-      path: '/COMMIT_MSG',
-      line: 5,
-      rootId: 'rc1',
-      start_datetime: '2019-02-08 18:49:18.000000000',
-    },
-    {
-      comments: [
-        {
-          __path: '/COMMIT_MSG',
-          author: {
-            _account_id: 1000000,
-            name: 'user',
-            username: 'user',
-          },
-          patch_set: 4,
-          id: 'rc2',
-          line: 5,
-          updated: '2019-03-08 18:49:18.000000000',
-          message: 'test',
-          unresolved: true,
-          robot_id: 'rc2',
-        },
-        {
-          __path: '/COMMIT_MSG',
-          author: {
-            _account_id: 1000000,
-            name: 'user',
-            username: 'user',
-          },
-          patch_set: 4,
-          id: 'c2_1',
-          line: 5,
-          updated: '2019-03-08 18:49:18.000000000',
-          message: 'test',
-          unresolved: true,
-        },
-      ],
-      patchNum: 4,
-      path: '/COMMIT_MSG',
-      line: 5,
-      rootId: 'rc2',
-      start_datetime: '2019-03-08 18:49:18.000000000',
-    },
-  ];
-
-  setup(() => {
-    // Since pluginEndpoints are global, must reset state.
-    _testOnly_resetEndpoints();
-    navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({test: 'config'}); },
-      getAccount() { return Promise.resolve(null); },
-      getDiffComments() { return Promise.resolve({}); },
-      getDiffRobotComments() { return Promise.resolve({}); },
-      getDiffDrafts() { return Promise.resolve({}); },
-      _fetchSharedCacheURL() { return Promise.resolve({}); },
-    });
-    element = fixture.instantiate();
-    sinon.stub(element.$.actions, 'reload').returns(Promise.resolve());
-    getPluginLoader().loadPlugins([]);
-    pluginApi.install(
-        plugin => {
-          plugin.registerDynamicCustomComponent(
-              'change-view-tab-header',
-              'gr-checks-change-view-tab-header-view'
-          );
-          plugin.registerDynamicCustomComponent(
-              'change-view-tab-content',
-              'gr-checks-view'
-          );
-        },
-        '0.1',
-        'http://some/plugins/url.html'
-    );
-  });
-
-  teardown(done => {
-    flush(() => {
-      done();
-    });
-  });
-
-  const getCustomCssValue =
-      cssParam => getComputedStyleValue(cssParam, element);
-
-  test('_handleMessageAnchorTap', () => {
-    element._changeNum = '1';
-    element._patchRange = {
-      basePatchNum: 'PARENT',
-      patchNum: 1,
-    };
-    const getUrlStub = sinon.stub(GerritNav, 'getUrlForChange');
-    const replaceStateStub = sinon.stub(history, 'replaceState');
-    element._handleMessageAnchorTap({detail: {id: 'a12345'}});
-
-    assert.equal(getUrlStub.lastCall.args[4], '#message-a12345');
-    assert.isTrue(replaceStateStub.called);
-  });
-
-  test('_handleDiffAgainstBase', () => {
-    element._change = generateChange({revisionsCount: 10});
-    element._patchRange = {
-      patchNum: 3,
-      basePatchNum: 1,
-    };
-    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-    element._handleDiffAgainstBase(new CustomEvent(''));
-    assert(navigateToChangeStub.called);
-    const args = navigateToChangeStub.getCall(0).args;
-    assert.equal(args[0], element._change);
-    assert.equal(args[1], 3);
-  });
-
-  test('_handleDiffAgainstLatest', () => {
-    element._change = generateChange({revisionsCount: 10});
-    element._patchRange = {
-      basePatchNum: 1,
-      patchNum: 3,
-    };
-    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-    element._handleDiffAgainstLatest(new CustomEvent(''));
-    assert(navigateToChangeStub.called);
-    const args = navigateToChangeStub.getCall(0).args;
-    assert.equal(args[0], element._change);
-    assert.equal(args[1], 10);
-    assert.equal(args[2], 1);
-  });
-
-  test('_handleDiffBaseAgainstLeft', () => {
-    element._change = generateChange({revisionsCount: 10});
-    element._patchRange = {
-      patchNum: 3,
-      basePatchNum: 1,
-    };
-    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-    element._handleDiffBaseAgainstLeft(new CustomEvent(''));
-    assert(navigateToChangeStub.called);
-    const args = navigateToChangeStub.getCall(0).args;
-    assert.equal(args[0], element._change);
-    assert.equal(args[1], 1);
-  });
-
-  test('_handleDiffRightAgainstLatest', () => {
-    element._change = generateChange({revisionsCount: 10});
-    element._patchRange = {
-      basePatchNum: 1,
-      patchNum: 3,
-    };
-    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-    element._handleDiffRightAgainstLatest(new CustomEvent(''));
-    assert(navigateToChangeStub.called);
-    const args = navigateToChangeStub.getCall(0).args;
-    assert.equal(args[1], 10);
-    assert.equal(args[2], 3);
-  });
-
-  test('_handleDiffBaseAgainstLatest', () => {
-    element._change = generateChange({revisionsCount: 10});
-    element._patchRange = {
-      basePatchNum: 1,
-      patchNum: 3,
-    };
-    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-    element._handleDiffBaseAgainstLatest(new CustomEvent(''));
-    assert(navigateToChangeStub.called);
-    const args = navigateToChangeStub.getCall(0).args;
-    assert.equal(args[1], 10);
-    assert.isNotOk(args[2]);
-  });
-
-  suite('plugins adding to file tab', () => {
-    setup(done => {
-      // Resolving it here instead of during setup() as other tests depend
-      // on flush() not being called during setup.
-      flush(() => done());
-    });
-
-    test('plugin added tab shows up as a dynamic endpoint', () => {
-      assert(element._dynamicTabHeaderEndpoints.includes(
-          'change-view-tab-header-url'));
-      const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
-      // 4 Tabs are : Files, Comment Threads, Plugin, Findings
-      assert.equal(paperTabs.querySelectorAll('paper-tab').length, 4);
-      assert.equal(paperTabs.querySelectorAll('paper-tab')[2].dataset.name,
-          'change-view-tab-header-url');
-    });
-
-    test('_setActivePrimaryTab switched tab correctly', done => {
-      element._setActivePrimaryTab({detail:
-          {tab: 'change-view-tab-header-url'}});
-      flush(() => {
-        assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
-        done();
-      });
-    });
-
-    test('show-primary-tab switched primary tab correctly', done => {
-      element.dispatchEvent(
-          new CustomEvent('show-primary-tab', {
-            composed: true,
-            bubbles: true,
-            detail: {
-              tab: 'change-view-tab-header-url',
-            },
-          }));
-      flush(() => {
-        assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
-        done();
-      });
-    });
-
-    test('param change should switch primary tab correctly', done => {
-      assert.equal(element._activeTabs[0], PrimaryTab.FILES);
-      const queryMap = new Map();
-      queryMap.set('tab', PrimaryTab.FINDINGS);
-      // view is required
-      element.params = {
-        view: GerritNav.View.CHANGE,
-        ...element.params, queryMap};
-      flush(() => {
-        assert.equal(element._activeTabs[0], PrimaryTab.FINDINGS);
-        done();
-      });
-    });
-
-    test('invalid param change should not switch primary tab', done => {
-      assert.equal(element._activeTabs[0], PrimaryTab.FILES);
-      const queryMap = new Map();
-      queryMap.set('tab', 'random');
-      // view is required
-      element.params = {
-        view: GerritNav.View.CHANGE,
-        ...element.params, queryMap};
-      flush(() => {
-        assert.equal(element._activeTabs[0], PrimaryTab.FILES);
-        done();
-      });
-    });
-
-    test('switching tab sets _selectedTabPluginEndpoint', done => {
-      const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
-      MockInteractions.tap(paperTabs.querySelectorAll('paper-tab')[2]);
-      flush(() => {
-        assert.equal(element._selectedTabPluginEndpoint,
-            'change-view-tab-content-url');
-        done();
-      });
-    });
-  });
-
-  suite('keyboard shortcuts', () => {
-    let clock;
-    setup(() => {
-      clock = sinon.useFakeTimers();
-    });
-
-    teardown(() => {
-      clock.restore();
-      sinon.restore();
-    });
-
-    test('t to add topic', () => {
-      const editStub = sinon.stub(element.$.metadata, 'editTopic');
-      MockInteractions.pressAndReleaseKeyOn(element, 83, null, 't');
-      assert(editStub.called);
-    });
-
-    test('S should toggle the CL star', () => {
-      const starStub = sinon.stub(element.$.changeStar, 'toggleStar');
-      MockInteractions.pressAndReleaseKeyOn(element, 83, null, 's');
-      assert(starStub.called);
-    });
-
-    test('toggle star is throttled', () => {
-      const starStub = sinon.stub(element.$.changeStar, 'toggleStar');
-      MockInteractions.pressAndReleaseKeyOn(element, 83, null, 's');
-      assert(starStub.called);
-      MockInteractions.pressAndReleaseKeyOn(element, 83, null, 's');
-      assert.equal(starStub.callCount, 1);
-      clock.tick(1000);
-      MockInteractions.pressAndReleaseKeyOn(element, 83, null, 's');
-      assert.equal(starStub.callCount, 2);
-    });
-
-    test('U should navigate to root if no backPage set', () => {
-      const relativeNavStub = sinon.stub(GerritNav,
-          'navigateToRelativeUrl');
-      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-      assert.isTrue(relativeNavStub.called);
-      assert.isTrue(relativeNavStub.lastCall.calledWithExactly(
-          GerritNav.getUrlForRoot()));
-    });
-
-    test('U should navigate to backPage if set', () => {
-      const relativeNavStub = sinon.stub(GerritNav,
-          'navigateToRelativeUrl');
-      element.backPage = '/dashboard/self';
-      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-      assert.isTrue(relativeNavStub.called);
-      assert.isTrue(relativeNavStub.lastCall.calledWithExactly(
-          '/dashboard/self'));
-    });
-
-    test('A fires an error event when not logged in', done => {
-      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
-      const loggedInErrorSpy = sinon.spy();
-      element.addEventListener('show-auth-required', loggedInErrorSpy);
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      flush(() => {
-        assert.isFalse(element.$.replyOverlay.opened);
-        assert.isTrue(loggedInErrorSpy.called);
-        done();
-      });
-    });
-
-    test('shift A does not open reply overlay', done => {
-      sinon.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 => {
-      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-      element._change = generateChange({
-        revisionsCount: 1,
-        messagesCount: 1,
-      });
-      element._change.labels = {};
-      sinon.stub(element.$.restAPI, 'getChangeDetail')
-          .callsFake(() => Promise.resolve(generateChange({
-            // element has latest info
-            revisionsCount: 1,
-            messagesCount: 1,
-          })));
-
-      const openSpy = sinon.spy(element, '_openReplyDialog');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      flush(() => {
-        assert.isTrue(element.$.replyOverlay.opened);
-        element.$.replyOverlay.close();
-        assert.isFalse(element.$.replyOverlay.opened);
-        assert(openSpy.lastCall.calledWithExactly(
-            element.$.replyDialog.FocusTarget.ANY),
-        '_openReplyDialog should have been passed ANY');
-        assert.equal(openSpy.callCount, 1);
-        done();
-      });
-    });
-
-    test('fullscreen-overlay-opened hides content', () => {
-      element._loggedIn = true;
-      element._loading = false;
-      element._change = {
-        owner: {_account_id: 1},
-        labels: {},
-        actions: {
-          abandon: {
-            enabled: true,
-            label: 'Abandon',
-            method: 'POST',
-            title: 'Abandon',
-          },
-        },
-      };
-      sinon.spy(element, '_handleHideBackgroundContent');
-      element.$.replyDialog.dispatchEvent(
-          new CustomEvent('fullscreen-overlay-opened', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleHideBackgroundContent.called);
-      assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
-      assert.equal(getComputedStyle(element.$.actions).display, 'flex');
-    });
-
-    test('fullscreen-overlay-closed shows content', () => {
-      element._loggedIn = true;
-      element._loading = false;
-      element._change = {
-        owner: {_account_id: 1},
-        labels: {},
-        actions: {
-          abandon: {
-            enabled: true,
-            label: 'Abandon',
-            method: 'POST',
-            title: 'Abandon',
-          },
-        },
-      };
-      sinon.spy(element, '_handleShowBackgroundContent');
-      element.$.replyDialog.dispatchEvent(
-          new CustomEvent('fullscreen-overlay-closed', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleShowBackgroundContent.called);
-      assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
-    });
-
-    test('expand all messages when expand-diffs fired', () => {
-      const handleExpand =
-          sinon.stub(element.$.fileList, 'expandAllDiffs');
-      element.$.fileListHeader.dispatchEvent(
-          new CustomEvent('expand-diffs', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(handleExpand.called);
-    });
-
-    test('collapse all messages when collapse-diffs fired', () => {
-      const handleCollapse =
-      sinon.stub(element.$.fileList, 'collapseAllDiffs');
-      element.$.fileListHeader.dispatchEvent(
-          new CustomEvent('collapse-diffs', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(handleCollapse.called);
-    });
-
-    test('X should expand all messages', done => {
-      flush(() => {
-        const handleExpand = sinon.stub(element.messagesList,
-            'handleExpandCollapse');
-        MockInteractions.pressAndReleaseKeyOn(element, 88, null, 'x');
-        assert(handleExpand.calledWith(true));
-        done();
-      });
-    });
-
-    test('Z should collapse all messages', done => {
-      flush(() => {
-        const handleExpand = sinon.stub(element.messagesList,
-            'handleExpandCollapse');
-        MockInteractions.pressAndReleaseKeyOn(element, 90, null, 'z');
-        assert(handleExpand.calledWith(false));
-        done();
-      });
-    });
-
-    test('reload event from reply dialog is processed', () => {
-      const handleReloadStub = sinon.stub(element, '_reload');
-      element.$.replyDialog.dispatchEvent(new CustomEvent('reload',
-          {detail: {clearPatchset: true}, bubbles: true, composed: true}));
-      assert.isTrue(handleReloadStub.called);
-    });
-
-    test('shift + R should fetch and navigate to the latest patch set',
-        done => {
-          element._changeNum = '42';
-          element._patchRange = {
-            basePatchNum: 'PARENT',
-            patchNum: 1,
-          };
-          element._change = {
-            change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-            _number: 42,
-            revisions: {
-              rev1: {_number: 1, commit: {parents: []}},
-            },
-            current_revision: 'rev1',
-            status: 'NEW',
-            labels: {},
-            actions: {},
-          };
-
-          const reloadChangeStub = sinon.stub(element, '_reload');
-          MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
-          flush(() => {
-            assert.isTrue(reloadChangeStub.called);
-            done();
-          });
-        });
-
-    test('d should open download overlay', () => {
-      const stub = sinon.stub(element.$.downloadOverlay, 'open').returns(
-          new Promise(resolve => {})
-      );
-      MockInteractions.pressAndReleaseKeyOn(element, 68, null, 'd');
-      assert.isTrue(stub.called);
-    });
-
-    test(', should open diff preferences', () => {
-      const stub = sinon.stub(
-          element.$.fileList.$.diffPreferencesDialog, 'open');
-      element._loggedIn = false;
-      element.disableDiffPrefs = true;
-      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
-      assert.isFalse(stub.called);
-
-      element._loggedIn = true;
-      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
-      assert.isFalse(stub.called);
-
-      element.disableDiffPrefs = false;
-      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
-      assert.isTrue(stub.called);
-    });
-
-    test('m should toggle diff mode', () => {
-      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-      const setModeStub = sinon.stub(element.$.fileListHeader,
-          'setDiffViewMode');
-      const e = {preventDefault: () => {}};
-      flush();
-
-      element.viewState.diffMode = 'SIDE_BY_SIDE';
-      element._handleToggleDiffMode(e);
-      assert.isTrue(setModeStub.calledWith('UNIFIED_DIFF'));
-
-      element.viewState.diffMode = 'UNIFIED_DIFF';
-      element._handleToggleDiffMode(e);
-      assert.isTrue(setModeStub.calledWith('SIDE_BY_SIDE'));
-    });
-  });
-
-  suite('reloading drafts', () => {
-    let reloadStub;
-    const drafts = {
-      'testfile.txt': [
-        {
-          patch_set: 5,
-          id: 'dd2982f5_c01c9e6a',
-          line: 1,
-          updated: '2017-11-08 18:47:45.000000000',
-          message: 'test',
-          unresolved: true,
-        },
-      ],
-    };
-    setup(() => {
-      // Fake computeDraftCount as its required for ChangeComments,
-      // see gr-comment-api#reloadDrafts.
-      reloadStub = sinon.stub(element.$.commentAPI, 'reloadDrafts')
-          .returns(Promise.resolve({
-            drafts,
-            getAllThreadsForChange: () => ([]),
-            computeDraftCount: () => 1,
-          }));
-    });
-
-    test('drafts are reloaded when reload-drafts fired', done => {
-      element.$.fileList.dispatchEvent(
-          new CustomEvent('reload-drafts', {
-            detail: {
-              resolve: () => {
-                assert.isTrue(reloadStub.called);
-                assert.deepEqual(element._diffDrafts, drafts);
-                done();
-              },
-            },
-            composed: true, bubbles: true,
-          }));
-    });
-
-    test('drafts are reloaded when comment-refresh fired', () => {
-      element.dispatchEvent(
-          new CustomEvent('comment-refresh', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(reloadStub.called);
-    });
-  });
-
-  suite('_recomputeComments', () => {
-    setup(() => {
-      element._changeNum = '1';
-      element._change = {_number: '1'};
-      flush();
-      // Fake computeDraftCount as its required for ChangeComments,
-      // see gr-comment-api#reloadDrafts.
-      sinon.stub(element.$.commentAPI, 'reloadDrafts')
-          .returns(Promise.resolve({
-            drafts: {},
-            getAllThreadsForChange: () => THREADS,
-            computeDraftCount: () => 0,
-          }));
-      element._change = generateChange();
-      element._changeNum = element._change._number;
-    });
-
-    test('draft threads should be a new copy with correct states', done => {
-      element.$.fileList.dispatchEvent(
-          new CustomEvent('reload-drafts', {
-            detail: {
-              resolve: () => {
-                assert.equal(element._draftCommentThreads.length, 2);
-                assert.equal(
-                    element._draftCommentThreads[0].rootId,
-                    THREADS[0].rootId
-                );
-                assert.notEqual(
-                    element._draftCommentThreads[0].comments,
-                    THREADS[0].comments
-                );
-                assert.notEqual(
-                    element._draftCommentThreads[0].comments[0],
-                    THREADS[0].comments[0]
-                );
-                assert.isTrue(
-                    element._draftCommentThreads[0]
-                        .comments
-                        .slice(0, 2)
-                        .every(c => c.collapsed === true)
-                );
-
-                assert.isTrue(
-                    element._draftCommentThreads[0]
-                        .comments[2]
-                        .collapsed === false
-                );
-                done();
-              },
-            },
-            composed: true, bubbles: true,
-          }));
-    });
-  });
-
-  test('diff comments modified', () => {
-    sinon.spy(element, '_handleReloadCommentThreads');
-    return element._reloadComments().then(() => {
-      element.dispatchEvent(
-          new CustomEvent('diff-comments-modified', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleReloadCommentThreads.called);
-    });
-  });
-
-  test('thread list modified', () => {
-    sinon.spy(element, '_handleReloadDiffComments');
-    element._activeTabs = [PrimaryTab.COMMENT_THREADS, SecondaryTab.CHANGE_LOG];
-    flush();
-
-    return element._reloadComments().then(() => {
-      element.threadList.dispatchEvent(
-          new CustomEvent('thread-list-modified', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleReloadDiffComments.called);
-
-      let draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
-          .returns(1);
-      assert.equal(element._computeTotalCommentCounts(5,
-          element._changeComments), '5 unresolved, 1 draft');
-      assert.equal(element._computeTotalCommentCounts(0,
-          element._changeComments), '1 draft');
-      draftStub.restore();
-      draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
-          .returns(0);
-      assert.equal(element._computeTotalCommentCounts(0,
-          element._changeComments), '');
-      assert.equal(element._computeTotalCommentCounts(1,
-          element._changeComments), '1 unresolved');
-      draftStub.restore();
-      draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
-          .returns(2);
-      assert.equal(element._computeTotalCommentCounts(1,
-          element._changeComments), '1 unresolved, 2 drafts');
-      draftStub.restore();
-    });
-  });
-
-  suite('thread list and change log tabs', () => {
-    setup(() => {
-      element._changeNum = '1';
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 1,
-      };
-      element._change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev2: {_number: 2, commit: {parents: []}},
-          rev1: {_number: 1, commit: {parents: []}},
-          rev13: {_number: 13, commit: {parents: []}},
-          rev3: {_number: 3, commit: {parents: []}},
-        },
-        current_revision: 'rev3',
-        status: 'NEW',
-        labels: {
-          test: {
-            all: [],
-            default_value: 0,
-            values: [],
-            approved: {},
-          },
-        },
-      };
-      sinon.stub(element.$.relatedChanges, 'reload');
-      sinon.stub(element, '_reload').returns(Promise.resolve());
-      sinon.spy(element, '_paramsChanged');
-      element.params = {view: 'change', changeNum: '1'};
-    });
-  });
-
-  suite('Findings comment tab', () => {
-    setup(done => {
-      element._changeNum = '42';
-      element._change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        project: 'testRepo',
-        revisions: {
-          rev2: {_number: 2, commit: {parents: []}},
-          rev1: {_number: 1, commit: {parents: []}},
-          rev13: {_number: 13, commit: {parents: []}},
-          rev3: {_number: 3, commit: {parents: []}},
-          rev4: {_number: 4, commit: {parents: []}},
-        },
-        current_revision: 'rev4',
-        _number: '1',
-      };
-      element._changeNum = '1';
-      element._commentThreads = THREADS;
-      const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
-      MockInteractions.tap(paperTabs.querySelectorAll('paper-tab')[3]);
-      flush(() => {
-        done();
-      });
-    });
-
-    test('robot comments count per patchset', () => {
-      const count = element._robotCommentCountPerPatchSet(THREADS);
-      const expectedCount = {
-        2: 1,
-        3: 1,
-        4: 2,
-      };
-      assert.deepEqual(count, expectedCount);
-      assert.equal(element._computeText({_number: 2}, THREADS),
-          'Patchset 2 (1 finding)');
-      assert.equal(element._computeText({_number: 4}, THREADS),
-          'Patchset 4 (2 findings)');
-      assert.equal(element._computeText({_number: 5}, THREADS),
-          'Patchset 5');
-    });
-
-    test('only robot comments are rendered', () => {
-      assert.equal(element._robotCommentThreads.length, 2);
-      assert.equal(element._robotCommentThreads[0].comments[0].robot_id,
-          'rc1');
-      assert.equal(element._robotCommentThreads[1].comments[0].robot_id,
-          'rc2');
-    });
-
-    test('changing patchsets resets robot comments', done => {
-      element.set('_change.current_revision', 'rev3');
-      flush(() => {
-        assert.equal(element._robotCommentThreads.length, 1);
-        done();
-      });
-    });
-
-    test('Show more button is hidden', () => {
-      assert.isNull(element.shadowRoot.querySelector('.show-robot-comments'));
-    });
-
-    suite('robot comments show more button', () => {
-      setup(done => {
-        const arr = [];
-        for (let i = 0; i <= 30; i++) {
-          arr.push(...THREADS);
-        }
-        element._commentThreads = arr;
-        flush(() => {
-          done();
-        });
-      });
-
-      test('Show more button is rendered', () => {
-        assert.isOk(element.shadowRoot.querySelector('.show-robot-comments'));
-        assert.equal(element._robotCommentThreads.length,
-            ROBOT_COMMENTS_LIMIT);
-      });
-
-      test('Clicking show more button renders all comments', done => {
-        MockInteractions.tap(element.shadowRoot.querySelector(
-            '.show-robot-comments'));
-        flush(() => {
-          assert.equal(element._robotCommentThreads.length, 62);
-          done();
-        });
-      });
-    });
-  });
-
-  test('reply button is not visible when logged out', () => {
-    assert.equal(getComputedStyle(element.$.replyBtn).display, 'none');
-    element._loggedIn = true;
-    assert.notEqual(getComputedStyle(element.$.replyBtn).display, 'none');
-  });
-
-  test('download tap calls _handleOpenDownloadDialog', () => {
-    sinon.stub(element, '_handleOpenDownloadDialog');
-    element.$.actions.dispatchEvent(
-        new CustomEvent('download-tap', {
-          composed: true, bubbles: true,
-        }));
-    assert.isTrue(element._handleOpenDownloadDialog.called);
-  });
-
-  test('fetches the server config on attached', done => {
-    flush(() => {
-      assert.equal(element._serverConfig.test, 'config');
-      done();
-    });
-  });
-
-  test('_changeStatuses', () => {
-    element._loading = false;
-    element._change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev2: {_number: 2},
-        rev1: {_number: 1},
-        rev13: {_number: 13},
-        rev3: {_number: 3},
-      },
-      current_revision: 'rev3',
-      status: ChangeStatus.MERGED,
-      work_in_progress: true,
-      labels: {
-        test: {
-          all: [],
-          default_value: 0,
-          values: [],
-          approved: {},
-        },
-      },
-    };
-    element._mergeable = true;
-    const expectedStatuses = ['Merged', 'WIP'];
-    assert.deepEqual(element._changeStatuses, expectedStatuses);
-    assert.equal(element._changeStatus, expectedStatuses.join(', '));
-    flush();
-    const statusChips = dom(element.root)
-        .querySelectorAll('gr-change-status');
-    assert.equal(statusChips.length, 2);
-  });
-
-  test('diff preferences open when open-diff-prefs is fired', () => {
-    const overlayOpenStub = sinon.stub(element.$.fileList,
-        'openDiffPrefs');
-    element.$.fileListHeader.dispatchEvent(
-        new CustomEvent('open-diff-prefs', {
-          composed: true, bubbles: true,
-        }));
-    assert.isTrue(overlayOpenStub.called);
-  });
-
-  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';
-    result = element._prepareCommitMsgForLinkify(commitMessage);
-    assert.equal(result, 'R=\u200Btest@google.com\nR=\u200Btest@google.com');
-
-    commitMessage = 'CC=test@google.com';
-    result = element._prepareCommitMsgForLinkify(commitMessage);
-    assert.equal(result, 'CC=\u200Btest@google.com');
-  });
-
-  test('_isSubmitEnabled', () => {
-    assert.isFalse(element._isSubmitEnabled({}));
-    assert.isFalse(element._isSubmitEnabled({submit: {}}));
-    assert.isTrue(element._isSubmitEnabled(
-        {submit: {enabled: true}}));
-  });
-
-  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',
-      patchNum: 1,
-    };
-    element._change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      owner: {email: 'abc@def'},
-      revisions: {
-        rev2: {_number: 2, commit: {parents: []}},
-        rev1: {_number: 1, commit: {parents: []}},
-        rev13: {_number: 13, commit: {parents: []}},
-        rev3: {_number: 3, commit: {parents: []}},
-      },
-      current_revision: 'rev3',
-      status: 'NEW',
-      labels: {
-        test: {
-          all: [vote],
-          default_value: 0,
-          values: [],
-          approved: {},
-        },
-      },
-    };
-    flush();
-    const reloadStub = sinon.stub(element, '_reload');
-    element.splice('_change.labels.test.all', 0, 1);
-    assert.isFalse(reloadStub.called);
-    element._change.labels.test.all.push(vote);
-    element._change.labels.test.all.push(vote);
-    element._change.labels.test.approved = vote;
-    flush();
-    element.splice('_change.labels.test.all', 0, 2);
-    assert.isTrue(reloadStub.called);
-    assert.isTrue(reloadStub.calledOnce);
-  });
-
-  test('reply button has updated count when there are drafts', () => {
-    const getLabel = element._computeReplyButtonLabel;
-
-    assert.equal(getLabel(null, false), 'Reply');
-    assert.equal(getLabel(null, true), 'Start Review');
-
-    const changeRecord = {base: null};
-    assert.equal(getLabel(changeRecord, false), 'Reply');
-
-    changeRecord.base = {};
-    assert.equal(getLabel(changeRecord, false), 'Reply');
-
-    changeRecord.base = {
-      'file1.txt': [{}],
-      'file2.txt': [{}, {}],
-    };
-    assert.equal(getLabel(changeRecord, false), 'Reply (3)');
-    assert.equal(getLabel(changeRecord, true), 'Start Review (3)');
-  });
-
-  test('comment events properly update diff drafts', () => {
-    element._patchRange = {
-      basePatchNum: 'PARENT',
-      patchNum: 2,
-    };
-    const draft = {
-      __draft: true,
-      id: 'id1',
-      path: '/foo/bar.txt',
-      text: 'hello',
-    };
-    element._handleCommentSave({detail: {comment: draft}});
-    draft.patch_set = 2;
-    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
-    draft.patch_set = null;
-    draft.text = 'hello, there';
-    element._handleCommentSave({detail: {comment: draft}});
-    draft.patch_set = 2;
-    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
-    const draft2 = {
-      __draft: true,
-      id: 'id2',
-      path: '/foo/bar.txt',
-      text: 'hola',
-    };
-    element._handleCommentSave({detail: {comment: draft2}});
-    draft2.patch_set = 2;
-    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft, draft2]});
-    draft.patch_set = null;
-    element._handleCommentDiscard({detail: {comment: draft}});
-    draft.patch_set = 2;
-    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft2]});
-    element._handleCommentDiscard({detail: {comment: draft2}});
-    assert.deepEqual(element._diffDrafts, {});
-  });
-
-  test('change num change', () => {
-    element._changeNum = null;
-    element._patchRange = {
-      basePatchNum: 'PARENT',
-      patchNum: 2,
-    };
-    element._change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      labels: {},
-    };
-    element.viewState.changeNum = null;
-    element.viewState.diffMode = 'UNIFIED';
-    assert.equal(element.viewState.numFilesShown, 200);
-    assert.equal(element._numFilesShown, 200);
-    element._numFilesShown = 150;
-    flush();
-    assert.equal(element.viewState.diffMode, 'UNIFIED');
-    assert.equal(element.viewState.numFilesShown, 150);
-
-    element._changeNum = '1';
-    element.params = {changeNum: '1'};
-    element._change.newProp = '1';
-    flush();
-    assert.equal(element.viewState.diffMode, 'UNIFIED');
-    assert.equal(element.viewState.changeNum, '1');
-
-    element._changeNum = '2';
-    element.params = {changeNum: '2'};
-    element._change.newProp = '2';
-    flush();
-    assert.equal(element.viewState.diffMode, 'UNIFIED');
-    assert.equal(element.viewState.changeNum, '2');
-    assert.equal(element.viewState.numFilesShown, 200);
-    assert.equal(element._numFilesShown, 200);
-  });
-
-  test('_setDiffViewMode is called with reset when new change is loaded',
-      () => {
-        sinon.stub(element, '_setDiffViewMode');
-        element.viewState = {changeNum: 1};
-        element._changeNum = 2;
-        element._resetFileListViewState();
-        assert.isTrue(
-            element._setDiffViewMode.lastCall.calledWithExactly(true));
-      });
-
-  test('diffViewMode is propagated from file list header', () => {
-    element.viewState = {diffMode: 'UNIFIED'};
-    element.$.fileListHeader.diffViewMode = 'SIDE_BY_SIDE';
-    assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
-  });
-
-  test('diffMode defaults to side by side without preferences', done => {
-    sinon.stub(element.$.restAPI, 'getPreferences').returns(
-        Promise.resolve({}));
-    // No user prefs or diff view mode set.
-
-    element._setDiffViewMode().then(() => {
-      assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
-      done();
-    });
-  });
-
-  test('diffMode defaults to preference when not already set', done => {
-    sinon.stub(element.$.restAPI, 'getPreferences').returns(
-        Promise.resolve({default_diff_view: 'UNIFIED'}));
-
-    element._setDiffViewMode().then(() => {
-      assert.equal(element.viewState.diffMode, 'UNIFIED');
-      done();
-    });
-  });
-
-  test('existing diffMode overrides preference', done => {
-    element.viewState.diffMode = 'SIDE_BY_SIDE';
-    sinon.stub(element.$.restAPI, 'getPreferences').returns(
-        Promise.resolve({default_diff_view: 'UNIFIED'}));
-    element._setDiffViewMode().then(() => {
-      assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
-      done();
-    });
-  });
-
-  test('don’t reload entire page when patchRange changes', () => {
-    const reloadStub = sinon.stub(element, '_reload').callsFake(
-        () => Promise.resolve());
-    const reloadPatchDependentStub = sinon.stub(element,
-        '_reloadPatchNumDependentResources')
-        .callsFake(() => Promise.resolve());
-    const relatedClearSpy = sinon.spy(element.$.relatedChanges, 'clear');
-    const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
-
-    const value = {
-      view: GerritNav.View.CHANGE,
-      patchNum: '1',
-    };
-    element._paramsChanged(value);
-    assert.isTrue(reloadStub.calledOnce);
-    assert.isTrue(relatedClearSpy.calledOnce);
-
-    element._initialLoadComplete = true;
-
-    value.basePatchNum = '1';
-    value.patchNum = '2';
-    element._paramsChanged(value);
-    assert.isFalse(reloadStub.calledTwice);
-    assert.isTrue(reloadPatchDependentStub.calledOnce);
-    assert.isTrue(relatedClearSpy.calledOnce);
-    assert.isTrue(collapseStub.calledTwice);
-  });
-
-  test('reload entire page when patchRange doesnt change', () => {
-    const reloadStub = sinon.stub(element, '_reload').callsFake(
-        () => Promise.resolve());
-    const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
-    const value = {
-      view: GerritNav.View.CHANGE,
-    };
-    element._paramsChanged(value);
-    assert.isTrue(reloadStub.calledOnce);
-    element._initialLoadComplete = true;
-    element._paramsChanged(value);
-    assert.isTrue(reloadStub.calledTwice);
-    assert.isTrue(collapseStub.calledTwice);
-  });
-
-  test('related changes are not updated after other action', done => {
-    sinon.stub(element, '_reload').callsFake(() => Promise.resolve());
-    sinon.stub(element.$.relatedChanges, 'reload');
-    const e = {detail: {action: 'abandon'}};
-    element._reload(e).then(() => {
-      assert.isFalse(navigateToChangeStub.called);
-      done();
-    });
-  });
-
-  test('_computeMergedCommitInfo', () => {
-    const dummyRevs = {
-      1: {commit: {commit: 1}},
-      2: {commit: {}},
-    };
-    assert.deepEqual(element._computeMergedCommitInfo(0, dummyRevs), {});
-    assert.deepEqual(element._computeMergedCommitInfo(1, dummyRevs),
-        dummyRevs[1].commit);
-
-    // Regression test for issue 5337.
-    const commit = element._computeMergedCommitInfo(2, dummyRevs);
-    assert.notDeepEqual(commit, dummyRevs[2]);
-    assert.deepEqual(commit, {commit: 2});
-  });
-
-  test('_computeCopyTextForTitle', () => {
-    const change = {
-      _number: 123,
-      subject: 'test subject',
-      revisions: {
-        rev1: {_number: 1},
-        rev3: {_number: 3},
-      },
-      current_revision: 'rev3',
-    };
-    sinon.stub(GerritNav, 'getUrlForChange')
-        .returns('/change/123');
-    assert.equal(
-        element._computeCopyTextForTitle(change),
-        `123: test subject | http://${location.host}/change/123`
-    );
-  });
-
-  test('get latest revision', () => {
-    let change = {
-      revisions: {
-        rev1: {_number: 1},
-        rev3: {_number: 3},
-      },
-      current_revision: 'rev3',
-    };
-    assert.equal(element._getLatestRevisionSHA(change), 'rev3');
-    change = {
-      revisions: {
-        rev1: {_number: 1},
-      },
-    };
-    assert.equal(element._getLatestRevisionSHA(change), 'rev1');
-  });
-
-  test('show commit message edit button', () => {
-    const _change = {
-      status: ChangeStatus.MERGED,
-    };
-    assert.isTrue(element._computeHideEditCommitMessage(false, false, {}));
-    assert.isTrue(element._computeHideEditCommitMessage(true, true, {}));
-    assert.isTrue(element._computeHideEditCommitMessage(false, true, {}));
-    assert.isFalse(element._computeHideEditCommitMessage(true, false, {}));
-    assert.isTrue(element._computeHideEditCommitMessage(true, false,
-        _change));
-    assert.isTrue(element._computeHideEditCommitMessage(true, false, {},
-        true));
-    assert.isFalse(element._computeHideEditCommitMessage(true, false, {},
-        false));
-  });
-
-  test('_handleCommitMessageSave trims trailing whitespace', () => {
-    const putStub = sinon.stub(element.$.restAPI, 'putChangeCommitMessage')
-        .returns(Promise.resolve({}));
-
-    const mockEvent = content => { return {detail: {content}}; };
-
-    element._handleCommitMessageSave(mockEvent('test \n  test '));
-    assert.equal(putStub.lastCall.args[1], 'test\n  test');
-
-    element._handleCommitMessageSave(mockEvent('  test\ntest'));
-    assert.equal(putStub.lastCall.args[1], '  test\ntest');
-
-    element._handleCommitMessageSave(mockEvent('\n\n\n\n\n\n\n\n'));
-    assert.equal(putStub.lastCall.args[1], '\n\n\n\n\n\n\n\n');
-  });
-
-  test('_computeChangeIdCommitMessageError', () => {
-    let commitMessage =
-      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483';
-    let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
-    assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        null);
-
-    change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
-    assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        'mismatch');
-
-    commitMessage = 'This is the greatest change.';
-    assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        'missing');
-  });
-
-  test('multiple change Ids in commit message picks last', () => {
-    const commitMessage = [
-      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
-      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
-    ].join('\n');
-    let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
-    assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        null);
-    change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
-    assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        'mismatch');
-  });
-
-  test('does not count change Id that starts mid line', () => {
-    const commitMessage = [
-      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
-      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
-    ].join(' and ');
-    let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
-    assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        null);
-    change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
-    assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        'mismatch');
-  });
-
-  test('_computeTitleAttributeWarning', () => {
-    let changeIdCommitMessageError = 'missing';
-    assert.equal(
-        element._computeTitleAttributeWarning(changeIdCommitMessageError),
-        'No Change-Id in commit message');
-
-    changeIdCommitMessageError = 'mismatch';
-    assert.equal(
-        element._computeTitleAttributeWarning(changeIdCommitMessageError),
-        'Change-Id mismatch');
-  });
-
-  test('_computeChangeIdClass', () => {
-    let changeIdCommitMessageError = 'missing';
-    assert.equal(
-        element._computeChangeIdClass(changeIdCommitMessageError), '');
-
-    changeIdCommitMessageError = 'mismatch';
-    assert.equal(
-        element._computeChangeIdClass(changeIdCommitMessageError), 'warning');
-  });
-
-  test('topic is coalesced to null', done => {
-    sinon.stub(element, '_changeChanged');
-    sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(
-        () => Promise.resolve({
-          id: '123456789',
-          labels: {},
-          current_revision: 'foo',
-          revisions: {foo: {commit: {}}},
-        }));
-
-    element._getChangeDetail().then(() => {
-      assert.isNull(element._change.topic);
-      done();
-    });
-  });
-
-  test('commit sha is populated from getChangeDetail', done => {
-    sinon.stub(element, '_changeChanged');
-    sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(
-        () => Promise.resolve({
-          id: '123456789',
-          labels: {},
-          current_revision: 'foo',
-          revisions: {foo: {commit: {}}},
-        }));
-
-    element._getChangeDetail().then(() => {
-      assert.equal('foo', element._commitInfo.commit);
-      done();
-    });
-  });
-
-  test('edit is added to change', () => {
-    sinon.stub(element, '_changeChanged');
-    sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(
-        () => Promise.resolve({
-          id: '123456789',
-          labels: {},
-          current_revision: 'foo',
-          revisions: {foo: {commit: {}}},
-        }));
-    sinon.stub(element, '_getEdit').callsFake(() => Promise.resolve({
-      base_patch_set_number: 1,
-      commit: {commit: 'bar'},
-    }));
-    element._patchRange = {};
-
-    return element._getChangeDetail().then(() => {
-      const revs = element._change.revisions;
-      assert.equal(Object.keys(revs).length, 2);
-      assert.deepEqual(revs['foo'], {commit: {commit: 'foo'}});
-      assert.deepEqual(revs['bar'], {
-        _number: SPECIAL_PATCH_SET_NUM.EDIT,
-        basePatchNum: 1,
-        commit: {commit: 'bar'},
-        fetch: undefined,
-      });
-    });
-  });
-
-  test('_getBasePatchNum', () => {
-    const _change = {
-      _number: 42,
-      revisions: {
-        '98da160735fb81604b4c40e93c368f380539dd0e': {
-          _number: 1,
-          commit: {
-            parents: [],
-          },
-        },
-      },
-    };
-    const _patchRange = {
-      basePatchNum: 'PARENT',
-    };
-    assert.equal(element._getBasePatchNum(_change, _patchRange), 'PARENT');
-
-    element._prefs = {
-      default_base_for_merges: 'FIRST_PARENT',
-    };
-
-    const _change2 = {
-      _number: 42,
-      revisions: {
-        '98da160735fb81604b4c40e93c368f380539dd0e': {
-          _number: 1,
-          commit: {
-            parents: [
-              {
-                commit: '6e12bdf1176eb4ab24d8491ba3b6d0704409cde8',
-                subject: 'test',
-              },
-              {
-                commit: '22f7db4754b5d9816fc581f3d9a6c0ef8429c841',
-                subject: 'test3',
-              },
-            ],
-          },
-        },
-      },
-    };
-    assert.equal(element._getBasePatchNum(_change2, _patchRange), -1);
-
-    _patchRange.patchNum = 1;
-    assert.equal(element._getBasePatchNum(_change2, _patchRange), 'PARENT');
-  });
-
-  test('_openReplyDialog called with `ANY` when coming from tap event',
-      () => {
-        const openStub = sinon.stub(element, '_openReplyDialog');
-        element._serverConfig = {};
-        MockInteractions.tap(element.$.replyBtn);
-        assert(openStub.lastCall.calledWithExactly(
-            element.$.replyDialog.FocusTarget.ANY),
-        '_openReplyDialog should have been passed ANY');
-        assert.equal(openStub.callCount, 1);
-      });
-
-  test('_openReplyDialog called with `BODY` when coming from message reply' +
-      'event', done => {
-    flush(() => {
-      const openStub = sinon.stub(element, '_openReplyDialog');
-      element.messagesList.dispatchEvent(
-          new CustomEvent('reply', {
-            detail:
-          {message: {message: 'text'}},
-            composed: true, bubbles: true,
-          }));
-      assert(openStub.lastCall.calledWithExactly(
-          element.$.replyDialog.FocusTarget.BODY),
-      '_openReplyDialog should have been passed BODY');
-      assert.equal(openStub.callCount, 1);
-      done();
-    });
-  });
-
-  test('reply dialog focus can be controlled', () => {
-    const FocusTarget = element.$.replyDialog.FocusTarget;
-    const openStub = sinon.stub(element, '_openReplyDialog');
-
-    const e = {detail: {}};
-    element._handleShowReplyDialog(e);
-    assert(openStub.lastCall.calledWithExactly(FocusTarget.REVIEWERS),
-        '_openReplyDialog should have been passed REVIEWERS');
-    assert.equal(openStub.callCount, 1);
-
-    e.detail.value = {ccsOnly: true};
-    element._handleShowReplyDialog(e);
-    assert(openStub.lastCall.calledWithExactly(FocusTarget.CCS),
-        '_openReplyDialog should have been passed CCS');
-    assert.equal(openStub.callCount, 2);
-  });
-
-  test('getUrlParameter functionality', () => {
-    const locationStub = sinon.stub(element, '_getLocationSearch');
-
-    locationStub.returns('?test');
-    assert.equal(element._getUrlParameter('test'), 'test');
-    locationStub.returns('?test2=12&test=3');
-    assert.equal(element._getUrlParameter('test'), 'test');
-    locationStub.returns('');
-    assert.isNull(element._getUrlParameter('test'));
-    locationStub.returns('?');
-    assert.isNull(element._getUrlParameter('test'));
-    locationStub.returns('?test2');
-    assert.isNull(element._getUrlParameter('test'));
-  });
-
-  test('revert dialog opened with revert param', done => {
-    sinon.stub(element.$.restAPI, 'getLoggedIn')
-        .callsFake(() => Promise.resolve(true));
-    sinon.stub(getPluginLoader(), 'awaitPluginsLoaded')
-        .callsFake(() => Promise.resolve());
-
-    element._patchRange = {
-      basePatchNum: 'PARENT',
-      patchNum: 2,
-    };
-    element._change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1, commit: {parents: []}},
-        rev2: {_number: 2, commit: {parents: []}},
-      },
-      current_revision: 'rev1',
-      status: ChangeStatus.MERGED,
-      labels: {},
-      actions: {},
-    };
-
-    sinon.stub(element, '_getUrlParameter').callsFake(
-        param => {
-          assert.equal(param, 'revert');
-          return param;
-        });
-
-    sinon.stub(element.$.actions, 'showRevertDialog').callsFake(
-        done);
-
-    element._maybeShowRevertDialog();
-    assert.isTrue(getPluginLoader().awaitPluginsLoaded.called);
-  });
-
-  suite('scroll related tests', () => {
-    test('document scrolling calls function to set scroll height', done => {
-      const originalHeight = document.body.scrollHeight;
-      const scrollStub = sinon.stub(element, '_handleScroll').callsFake(
-          () => {
-            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};
-
-      sinon.stub(element, '_reload').callsFake(() => {
-        // 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: GerritNav.View.CHANGE});
-    });
-
-    test('scrollTop is reset when new change is loaded', () => {
-      element._resetFileListViewState();
-      assert.equal(element.viewState.scrollTop, 0);
-    });
-  });
-
-  suite('reply dialog tests', () => {
-    setup(() => {
-      sinon.stub(element.$.replyDialog, '_draftChanged');
-      element._change = generateChange({
-        revisionsCount: 1,
-        messagesCount: 1,
-      });
-      element._change.labels = {};
-      sinon.stub(element.$.restAPI, 'getChangeDetail')
-          .callsFake(() => Promise.resolve(generateChange({
-            // element has latest info
-            revisionsCount: 1,
-            messagesCount: 1,
-          })));
-    });
-
-    test('show reply dialog on open-reply-dialog event', done => {
-      sinon.stub(element, '_openReplyDialog');
-      element.dispatchEvent(
-          new CustomEvent('open-reply-dialog', {
-            composed: true,
-            bubbles: true,
-            detail: {},
-          }));
-      flush(() => {
-        assert.isTrue(element._openReplyDialog.calledOnce);
-        done();
-      });
-    });
-
-    test('reply from comment adds quote text', () => {
-      const e = {detail: {message: {message: 'quote text'}}};
-      element._handleMessageReply(e);
-      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
-    });
-
-    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';
-      const e = {detail: {message: {message: 'quote text'}}};
-      element._handleMessageReply(e);
-      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
-    });
-
-    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';
-      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', () => {
-      const div = document.createElement('div');
-      element.$.replyDialog.draft = '> quote text\n\n some draft text';
-      element.$.replyDialog.quote = '> quote text\n\n';
-      const e = {target: div, preventDefault: sinon.spy()};
-      element._handleReplyTap(e);
-      assert.equal(element.$.replyDialog.draft,
-          '> quote text\n\n some draft text');
-      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
-    });
-  });
-
-  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', () => {
-    setup(() => {
-      element._change = generateChange({
-        revisionsCount: 1,
-        messagesCount: 1,
-      });
-      element._change.labels = {};
-      sinon.stub(element.$.restAPI, 'getChangeDetail')
-          .callsFake(() => Promise.resolve(generateChange({
-            // new patchset was uploaded
-            revisionsCount: 2,
-            messagesCount: 1,
-          })));
-    });
-
-    test('commitCollapseToggle hidden for short commit message', () => {
-      element._latestCommitMessage = '';
-      assert.isTrue(element.$.commitCollapseToggle.hasAttribute('hidden'));
-    });
-
-    test('commitCollapseToggle shown for long commit message', () => {
-      element._latestCommitMessage = _.times(31, String).join('\n');
-      assert.isFalse(element.$.commitCollapseToggle.hasAttribute('hidden'));
-    });
-
-    test('commitCollapseToggle functions', () => {
-      element._latestCommitMessage = _.times(35, String).join('\n');
-      assert.isTrue(element._commitCollapsed);
-      assert.isTrue(element._commitCollapsible);
-      assert.isTrue(
-          element.$.commitMessageEditor.hasAttribute('collapsed'));
-      MockInteractions.tap(element.$.commitCollapseToggleButton);
-      assert.isFalse(element._commitCollapsed);
-      assert.isTrue(element._commitCollapsible);
-      assert.isFalse(
-          element.$.commitMessageEditor.hasAttribute('collapsed'));
-    });
-  });
-
-  suite('related changes expand/collapse', () => {
-    let updateHeightSpy;
-    setup(() => {
-      updateHeightSpy = sinon.spy(element, '_updateRelatedChangeMaxHeight');
-    });
-
-    test('relatedChangesToggle shown height greater than changeInfo height',
-        () => {
-          assert.isFalse(element.$.relatedChangesToggle.classList
-              .contains('showToggle'));
-          sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
-          sinon.stub(element, '_getScrollHeight').callsFake(() => 60);
-          sinon.stub(element, '_getLineHeight').callsFake(() => 5);
-          sinon.stub(window, 'matchMedia')
-              .callsFake(() => { return {matches: true}; });
-          element.$.relatedChanges.dispatchEvent(
-              new CustomEvent('new-section-loaded'));
-          assert.isTrue(element.$.relatedChangesToggle.classList
-              .contains('showToggle'));
-          assert.equal(updateHeightSpy.callCount, 1);
-        });
-
-    test('relatedChangesToggle hidden height less than changeInfo height',
-        () => {
-          assert.isFalse(element.$.relatedChangesToggle.classList
-              .contains('showToggle'));
-          sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
-          sinon.stub(element, '_getScrollHeight').callsFake(() => 40);
-          sinon.stub(element, '_getLineHeight').callsFake(() => 5);
-          sinon.stub(window, 'matchMedia')
-              .callsFake(() => { return {matches: true}; });
-          element.$.relatedChanges.dispatchEvent(
-              new CustomEvent('new-section-loaded'));
-          assert.isFalse(element.$.relatedChangesToggle.classList
-              .contains('showToggle'));
-          assert.equal(updateHeightSpy.callCount, 1);
-        });
-
-    test('relatedChangesToggle functions', () => {
-      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
-      sinon.stub(window, 'matchMedia')
-          .callsFake(() => { return {matches: false}; });
-      element._relatedChangesLoading = false;
-      assert.isTrue(element._relatedChangesCollapsed);
-      assert.isTrue(
-          element.$.relatedChanges.classList.contains('collapsed'));
-      MockInteractions.tap(element.$.relatedChangesToggleButton);
-      assert.isFalse(element._relatedChangesCollapsed);
-      assert.isFalse(
-          element.$.relatedChanges.classList.contains('collapsed'));
-    });
-
-    test('_updateRelatedChangeMaxHeight without commit toggle', () => {
-      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
-      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
-      sinon.stub(window, 'matchMedia')
-          .callsFake(() => { return {matches: false}; });
-
-      // 50 (existing height) - 30 (extra height) = 20 (adjusted height).
-      // 20 (max existing height)  % 12 (line height) = 6 (remainder).
-      // 20 (adjusted height) - 8 (remainder) = 12 (max height to set).
-
-      element._updateRelatedChangeMaxHeight();
-      assert.equal(getCustomCssValue('--relation-chain-max-height'),
-          '12px');
-      assert.equal(getCustomCssValue('--related-change-btn-top-padding'),
-          '');
-    });
-
-    test('_updateRelatedChangeMaxHeight with commit toggle', () => {
-      element._latestCommitMessage = _.times(31, String).join('\n');
-      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
-      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
-      sinon.stub(window, 'matchMedia')
-          .callsFake(() => { return {matches: false}; });
-
-      // 50 (existing height) % 12 (line height) = 2 (remainder).
-      // 50 (existing height)  - 2 (remainder) = 48 (max height to set).
-
-      element._updateRelatedChangeMaxHeight();
-      assert.equal(getCustomCssValue('--relation-chain-max-height'),
-          '48px');
-      assert.equal(getCustomCssValue('--related-change-btn-top-padding'),
-          '2px');
-    });
-
-    test('_updateRelatedChangeMaxHeight in small screen mode', () => {
-      element._latestCommitMessage = _.times(31, String).join('\n');
-      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
-      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
-      sinon.stub(window, 'matchMedia')
-          .callsFake(() => { return {matches: true}; });
-
-      element._updateRelatedChangeMaxHeight();
-
-      // 400 (new height) % 12 (line height) = 4 (remainder).
-      // 400 (new height) - 4 (remainder) = 396.
-
-      assert.equal(getCustomCssValue('--relation-chain-max-height'),
-          '396px');
-    });
-
-    test('_updateRelatedChangeMaxHeight in medium screen mode', () => {
-      element._latestCommitMessage = _.times(31, String).join('\n');
-      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
-      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
-      sinon.stub(window, 'matchMedia').callsFake(() => {
-        if (window.matchMedia.lastCall.args[0] === '(max-width: 75em)') {
-          return {matches: true};
-        } else {
-          return {matches: false};
-        }
-      });
-
-      // 100 (new height) % 12 (line height) = 4 (remainder).
-      // 100 (new height) - 4 (remainder) = 96.
-      element._updateRelatedChangeMaxHeight();
-      assert.equal(getCustomCssValue('--relation-chain-max-height'),
-          '96px');
-    });
-
-    suite('update checks', () => {
-      setup(() => {
-        sinon.spy(element, '_startUpdateCheckTimer');
-        sinon.stub(element, 'async').callsFake( f => {
-          // Only fire the async callback one time.
-          if (element.async.callCount > 1) { return; }
-          f.call(element);
-        });
-        element._change = generateChange({
-          revisionsCount: 1,
-          messagesCount: 1,
-        });
-      });
-
-      test('_startUpdateCheckTimer negative delay', () => {
-        const getChangeDetailStub =
-            sinon.stub(element.$.restAPI, 'getChangeDetail')
-                .callsFake(() => Promise.resolve(generateChange({
-                  // element has latest info
-                  revisionsCount: 1,
-                  messagesCount: 1,
-                })));
-
-        element._serverConfig = {change: {update_delay: -1}};
-
-        assert.isTrue(element._startUpdateCheckTimer.called);
-        assert.isFalse(getChangeDetailStub.called);
-      });
-
-      test('_startUpdateCheckTimer up-to-date', async () => {
-        const getChangeDetailStub =
-            sinon.stub(element.$.restAPI, 'getChangeDetail')
-                .callsFake(() => Promise.resolve(generateChange({
-                  // element has latest info
-                  revisionsCount: 1,
-                  messagesCount: 1,
-                })));
-
-        element._serverConfig = {change: {update_delay: 12345}};
-        await flush();
-
-        assert.equal(element._startUpdateCheckTimer.callCount, 2);
-        assert.isTrue(getChangeDetailStub.called);
-        assert.equal(element.async.lastCall.args[1], 12345 * 1000);
-      });
-
-      test('_startUpdateCheckTimer out-of-date shows an alert', done => {
-        sinon.stub(element.$.restAPI, 'getChangeDetail')
-            .callsFake(() => Promise.resolve(generateChange({
-              // new patchset was uploaded
-              revisionsCount: 2,
-              messagesCount: 1,
-            })));
-
-        element.addEventListener('show-alert', e => {
-          assert.equal(e.detail.message,
-              'A newer patch set has been uploaded');
-          done();
-        });
-        element._serverConfig = {change: {update_delay: 12345}};
-
-        assert.equal(element._startUpdateCheckTimer.callCount, 1);
-      });
-
-      test('_startUpdateCheckTimer respects _loading', async () => {
-        sinon.stub(element.$.restAPI, 'getChangeDetail')
-            .callsFake(() => Promise.resolve(generateChange({
-              // new patchset was uploaded
-              revisionsCount: 2,
-              messagesCount: 1,
-            })));
-
-        element._loading = true;
-        element._serverConfig = {change: {update_delay: 12345}};
-        await flush();
-
-        // No toast, instead a second call to _startUpdateCheckTimer().
-        assert.equal(element._startUpdateCheckTimer.callCount, 2);
-      });
-
-      test('_startUpdateCheckTimer new status shows an alert', done => {
-        sinon.stub(element.$.restAPI, 'getChangeDetail')
-            .callsFake(() => Promise.resolve(generateChange({
-              // element has latest info
-              revisionsCount: 1,
-              messagesCount: 1,
-              status: ChangeStatus.MERGED,
-            })));
-
-        element.addEventListener('show-alert', e => {
-          assert.equal(e.detail.message, 'This change has been merged');
-          done();
-        });
-        element._serverConfig = {change: {update_delay: 12345}};
-      });
-
-      test('_startUpdateCheckTimer new messages shows an alert', done => {
-        sinon.stub(element.$.restAPI, 'getChangeDetail')
-            .callsFake(() => Promise.resolve(generateChange({
-              revisionsCount: 1,
-              // element has new message
-              messagesCount: 2,
-            })));
-        element.addEventListener('show-alert', e => {
-          assert.equal(e.detail.message,
-              'There are new messages on this change');
-          done();
-        });
-        element._serverConfig = {change: {update_delay: 12345}};
-      });
-    });
-
-    test('canStartReview computation', () => {
-      const change1 = {};
-      const change2 = {
-        actions: {
-          ready: {
-            enabled: true,
-          },
-        },
-      };
-      const change3 = {
-        actions: {
-          ready: {
-            label: 'Ready for Review',
-          },
-        },
-      };
-      assert.isFalse(element._computeCanStartReview(change1));
-      assert.isTrue(element._computeCanStartReview(change2));
-      assert.isFalse(element._computeCanStartReview(change3));
-    });
-  });
-
-  test('header class computation', () => {
-    assert.equal(element._computeHeaderClass(), 'header');
-    assert.equal(element._computeHeaderClass(true), 'header editMode');
-  });
-
-  test('_maybeScrollToMessage', done => {
-    flush(() => {
-      const scrollStub = sinon.stub(element.messagesList,
-          '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');
-      done();
-    });
-  });
-
-  test('topic update reloads related changes', () => {
-    sinon.stub(element.$.relatedChanges, 'reload');
-    element.dispatchEvent(new CustomEvent('topic-changed'));
-    assert.isTrue(element.$.relatedChanges.reload.calledOnce);
-  });
-
-  test('_computeEditMode', () => {
-    const callCompute = (range, params) =>
-      element._computeEditMode({base: range}, {base: params});
-    assert.isFalse(callCompute({}, {}));
-    assert.isTrue(callCompute({}, {edit: true}));
-    assert.isFalse(callCompute({basePatchNum: 'PARENT', patchNum: 1}, {}));
-    assert.isFalse(callCompute({basePatchNum: 'edit', patchNum: 1}, {}));
-    assert.isTrue(callCompute({basePatchNum: 1, patchNum: 'edit'}, {}));
-  });
-
-  test('_processEdit', () => {
-    element._patchRange = {};
-    const change = {
-      current_revision: 'foo',
-      revisions: {foo: {commit: {}, actions: {cherrypick: {enabled: true}}}},
-    };
-    let mockChange;
-
-    // With no edit, mockChange should be unmodified.
-    element._processEdit(mockChange = _.cloneDeep(change), null);
-    assert.deepEqual(mockChange, change);
-
-    // When edit is not based on the latest PS, current_revision should be
-    // unmodified.
-    const edit = {
-      base_patch_set_number: 1,
-      commit: {commit: 'bar'},
-      fetch: true,
-    };
-    element._processEdit(mockChange = _.cloneDeep(change), edit);
-    assert.notDeepEqual(mockChange, change);
-    assert.equal(mockChange.revisions.bar._number, SPECIAL_PATCH_SET_NUM.EDIT);
-    assert.equal(mockChange.current_revision, change.current_revision);
-    assert.deepEqual(mockChange.revisions.bar.commit, {commit: 'bar'});
-    assert.notOk(mockChange.revisions.bar.actions);
-
-    edit.base_revision = 'foo';
-    element._processEdit(mockChange = _.cloneDeep(change), edit);
-    assert.notDeepEqual(mockChange, change);
-    assert.equal(mockChange.current_revision, 'bar');
-    assert.deepEqual(mockChange.revisions.bar.actions,
-        mockChange.revisions.foo.actions);
-
-    // If _patchRange.patchNum is defined, do not load edit.
-    element._patchRange.patchNum = 'baz';
-    change.current_revision = 'baz';
-    element._processEdit(mockChange = _.cloneDeep(change), edit);
-    assert.equal(element._patchRange.patchNum, 'baz');
-    assert.notOk(mockChange.revisions.bar.actions);
-  });
-
-  test('file-action-tap handling', () => {
-    element._patchRange = {
-      basePatchNum: 'PARENT',
-      patchNum: 1,
-    };
-    const fileList = element.$.fileList;
-    const Actions = GrEditConstants.Actions;
-    element.$.fileListHeader.editMode = true;
-    flush();
-    const controls = element.$.fileListHeader
-        .shadowRoot.querySelector('#editControls');
-    sinon.stub(controls, 'openDeleteDialog');
-    sinon.stub(controls, 'openRenameDialog');
-    sinon.stub(controls, 'openRestoreDialog');
-    sinon.stub(GerritNav, 'getEditUrlForDiff');
-    sinon.stub(GerritNav, 'navigateToRelativeUrl');
-
-    // Delete
-    fileList.dispatchEvent(new CustomEvent('file-action-tap', {
-      detail: {action: Actions.DELETE.id, path: 'foo'},
-      bubbles: true,
-      composed: true,
-    }));
-    flush();
-
-    assert.isTrue(controls.openDeleteDialog.called);
-    assert.equal(controls.openDeleteDialog.lastCall.args[0], 'foo');
-
-    // Restore
-    fileList.dispatchEvent(new CustomEvent('file-action-tap', {
-      detail: {action: Actions.RESTORE.id, path: 'foo'},
-      bubbles: true,
-      composed: true,
-    }));
-    flush();
-
-    assert.isTrue(controls.openRestoreDialog.called);
-    assert.equal(controls.openRestoreDialog.lastCall.args[0], 'foo');
-
-    // Rename
-    fileList.dispatchEvent(new CustomEvent('file-action-tap', {
-      detail: {action: Actions.RENAME.id, path: 'foo'},
-      bubbles: true,
-      composed: true,
-    }));
-    flush();
-
-    assert.isTrue(controls.openRenameDialog.called);
-    assert.equal(controls.openRenameDialog.lastCall.args[0], 'foo');
-
-    // Open
-    fileList.dispatchEvent(new CustomEvent('file-action-tap', {
-      detail: {action: Actions.OPEN.id, path: 'foo'},
-      bubbles: true,
-      composed: true,
-    }));
-    flush();
-
-    assert.isTrue(GerritNav.getEditUrlForDiff.called);
-    assert.equal(GerritNav.getEditUrlForDiff.lastCall.args[1], 'foo');
-    assert.equal(GerritNav.getEditUrlForDiff.lastCall.args[2], '1');
-    assert.isTrue(GerritNav.navigateToRelativeUrl.called);
-  });
-
-  test('_selectedRevision updates when patchNum is changed', () => {
-    const revision1 = {_number: 1, commit: {parents: []}};
-    const revision2 = {_number: 2, commit: {parents: []}};
-    sinon.stub(element.$.restAPI, 'getChangeDetail').returns(
-        Promise.resolve({
-          revisions: {
-            aaa: revision1,
-            bbb: revision2,
-          },
-          labels: {},
-          actions: {},
-          current_revision: 'bbb',
-          change_id: 'loremipsumdolorsitamet',
-        }));
-    sinon.stub(element, '_getEdit').returns(Promise.resolve());
-    sinon.stub(element, '_getPreferences').returns(Promise.resolve({}));
-    element._patchRange = {patchNum: '2'};
-    return element._getChangeDetail().then(() => {
-      assert.strictEqual(element._selectedRevision, revision2);
-
-      element.set('_patchRange.patchNum', '1');
-      assert.strictEqual(element._selectedRevision, revision1);
-    });
-  });
-
-  test('_selectedRevision is assigned when patchNum is edit', () => {
-    const revision1 = {_number: 1, commit: {parents: []}};
-    const revision2 = {_number: 2, commit: {parents: []}};
-    const revision3 = {_number: 'edit', commit: {parents: []}};
-    sinon.stub(element.$.restAPI, 'getChangeDetail').returns(
-        Promise.resolve({
-          revisions: {
-            aaa: revision1,
-            bbb: revision2,
-            ccc: revision3,
-          },
-          labels: {},
-          actions: {},
-          current_revision: 'ccc',
-          change_id: 'loremipsumdolorsitamet',
-        }));
-    sinon.stub(element, '_getEdit').returns(Promise.resolve());
-    sinon.stub(element, '_getPreferences').returns(Promise.resolve({}));
-    element._patchRange = {patchNum: 'edit'};
-    return element._getChangeDetail().then(() => {
-      assert.strictEqual(element._selectedRevision, revision3);
-    });
-  });
-
-  test('_sendShowChangeEvent', () => {
-    element._change = {labels: {}};
-    element._patchRange = {patchNum: 4};
-    element._mergeable = true;
-    const showStub = sinon.stub(element.$.jsAPI, 'handleEvent');
-    element._sendShowChangeEvent();
-    assert.isTrue(showStub.calledOnce);
-    assert.equal(showStub.lastCall.args[0], EventType.SHOW_CHANGE);
-    assert.deepEqual(showStub.lastCall.args[1], {
-      change: {labels: {}},
-      patchNum: 4,
-      info: {mergeable: true},
-    });
-  });
-
-  suite('_handleEditTap', () => {
-    let fireEdit;
-
-    setup(() => {
-      fireEdit = () => {
-        element.$.actions.dispatchEvent(new CustomEvent('edit-tap'));
-      };
-      navigateToChangeStub.restore();
-
-      element._change = {revisions: {rev1: {_number: 1}}};
-    });
-
-    test('edit exists in revisions', done => {
-      sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
-        assert.equal(args.length, 2);
-        assert.equal(args[1], SPECIAL_PATCH_SET_NUM.EDIT); // patchNum
-        done();
-      });
-
-      element.set('_change.revisions.rev2',
-          {_number: SPECIAL_PATCH_SET_NUM.EDIT});
-      flush();
-
-      fireEdit();
-    });
-
-    test('no edit exists in revisions, non-latest patchset', done => {
-      sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
-        assert.equal(args.length, 4);
-        assert.equal(args[1], 1); // patchNum
-        assert.equal(args[3], true); // opt_isEdit
-        done();
-      });
-
-      element.set('_change.revisions.rev2', {_number: 2});
-      element._patchRange = {patchNum: 1};
-      flush();
-
-      fireEdit();
-    });
-
-    test('no edit exists in revisions, latest patchset', done => {
-      sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
-        assert.equal(args.length, 4);
-        // No patch should be specified when patchNum == latest.
-        assert.isNotOk(args[1]); // patchNum
-        assert.equal(args[3], true); // opt_isEdit
-        done();
-      });
-
-      element.set('_change.revisions.rev2', {_number: 2});
-      element._patchRange = {patchNum: 2};
-      flush();
-
-      fireEdit();
-    });
-  });
-
-  test('_handleStopEditTap', done => {
-    sinon.stub(element.$.metadata, '_computeLabelNames');
-    navigateToChangeStub.restore();
-    sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
-      assert.equal(args.length, 2);
-      assert.equal(args[1], 1); // patchNum
-      done();
-    });
-
-    element._patchRange = {patchNum: 1};
-    element.$.actions.dispatchEvent(new CustomEvent('stop-edit-tap',
-        {bubbles: false}));
-  });
-
-  suite('plugin endpoints', () => {
-    test('endpoint params', done => {
-      element._change = {labels: {}};
-      element._selectedRevision = {};
-      let hookEl;
-      let plugin;
-      pluginApi.install(
-          p => {
-            plugin = p;
-            plugin.hook('change-view-integration').getLastAttached()
-                .then(
-                    el => hookEl = el);
-          },
-          '0.1',
-          'http://some/plugins/url.html');
-      flush(() => {
-        assert.strictEqual(hookEl.plugin, plugin);
-        assert.strictEqual(hookEl.change, element._change);
-        assert.strictEqual(hookEl.revision, element._selectedRevision);
-        done();
-      });
-    });
-  });
-
-  suite('_getMergeability', () => {
-    let getMergeableStub;
-
-    setup(() => {
-      element._change = {labels: {}};
-      getMergeableStub = sinon.stub(element.$.restAPI, 'getMergeable')
-          .returns(Promise.resolve({mergeable: true}));
-    });
-
-    test('merged change', () => {
-      element._mergeable = null;
-      element._change.status = ChangeStatus.MERGED;
-      return element._getMergeability().then(() => {
-        assert.isFalse(element._mergeable);
-        assert.isFalse(getMergeableStub.called);
-      });
-    });
-
-    test('abandoned change', () => {
-      element._mergeable = null;
-      element._change.status = ChangeStatus.ABANDONED;
-      return element._getMergeability().then(() => {
-        assert.isFalse(element._mergeable);
-        assert.isFalse(getMergeableStub.called);
-      });
-    });
-
-    test('open change', () => {
-      element._mergeable = null;
-      return element._getMergeability().then(() => {
-        assert.isTrue(element._mergeable);
-        assert.isTrue(getMergeableStub.called);
-      });
-    });
-  });
-
-  test('_paramsChanged sets in projectLookup', () => {
-    sinon.stub(element.$.relatedChanges, 'reload');
-    sinon.stub(element, '_reload').returns(Promise.resolve());
-    const setStub = sinon.stub(element.$.restAPI, 'setInProjectLookup');
-    element._paramsChanged({
-      view: GerritNav.View.CHANGE,
-      changeNum: 101,
-      project: 'test-project',
-    });
-    assert.isTrue(setStub.calledOnce);
-    assert.isTrue(setStub.calledWith(101, 'test-project'));
-  });
-
-  test('_handleToggleStar called when star is tapped', () => {
-    element._change = {
-      owner: {_account_id: 1},
-      starred: false,
-    };
-    element._loggedIn = true;
-    const stub = sinon.stub(element, '_handleToggleStar');
-    flush();
-
-    MockInteractions.tap(element.$.changeStar.shadowRoot
-        .querySelector('button'));
-    assert.isTrue(stub.called);
-  });
-
-  suite('gr-reporting tests', () => {
-    setup(() => {
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 1,
-      };
-      sinon.stub(element, '_getChangeDetail').returns(Promise.resolve());
-      sinon.stub(element, '_getProjectConfig').returns(Promise.resolve());
-      sinon.stub(element, '_reloadComments').returns(Promise.resolve());
-      sinon.stub(element, '_getMergeability').returns(Promise.resolve());
-      sinon.stub(element, '_getLatestCommitMessage')
-          .returns(Promise.resolve());
-    });
-
-    test('don\'t report changedDisplayed on reply', done => {
-      const changeDisplayStub =
-        sinon.stub(element.reporting, 'changeDisplayed');
-      const changeFullyLoadedStub =
-        sinon.stub(element.reporting, 'changeFullyLoaded');
-      element._handleReplySent();
-      flush(() => {
-        assert.isFalse(changeDisplayStub.called);
-        assert.isFalse(changeFullyLoadedStub.called);
-        done();
-      });
-    });
-
-    test('report changedDisplayed on _paramsChanged', done => {
-      const changeDisplayStub =
-        sinon.stub(element.reporting, 'changeDisplayed');
-      const changeFullyLoadedStub =
-        sinon.stub(element.reporting, 'changeFullyLoaded');
-      element._paramsChanged({
-        view: GerritNav.View.CHANGE,
-        changeNum: 101,
-        project: 'test-project',
-      });
-      flush(() => {
-        assert.isTrue(changeDisplayStub.called);
-        assert.isTrue(changeFullyLoadedStub.called);
-        done();
-      });
-    });
-  });
-});
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
new file mode 100644
index 0000000..6262300
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -0,0 +1,2953 @@
+/**
+ * @license
+ * 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.
+ */
+
+import '../../../test/common-test-setup-karma';
+import '../../edit/gr-edit-constants';
+import './gr-change-view';
+import {
+  ChangeStatus,
+  CommentSide,
+  DefaultBase,
+  DiffViewMode,
+  HttpMethod,
+  PrimaryTab,
+  SecondaryTab,
+} from '../../../constants/constants';
+import {GrEditConstants} from '../../edit/gr-edit-constants';
+import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {getComputedStyleValue} from '../../../utils/dom-util';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit';
+import {EventType, PluginApi} from '../../plugins/gr-plugin-types';
+
+import 'lodash/lodash';
+import {TestKeyboardShortcutBinder} from '../../../test/test-utils';
+import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util';
+import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {
+  createAppElementChangeViewParams,
+  createApproval,
+  createChange,
+  createChangeConfig,
+  createChangeMessages,
+  createCommit,
+  createMergeable,
+  createPreferences,
+  createRevision,
+  createRevisions,
+  createServerInfo,
+  createUserConfig,
+  TEST_NUMERIC_CHANGE_ID,
+  TEST_PROJECT_NAME,
+  getCurrentRevision,
+  createEditRevision,
+  createAccountWithIdNameAndEmail,
+} from '../../../test/test-data-generators';
+import {ChangeViewPatchRange, GrChangeView} from './gr-change-view';
+import {
+  AccountId,
+  ApprovalInfo,
+  ChangeId,
+  ChangeInfo,
+  CommitId,
+  CommitInfo,
+  EditInfo,
+  EditPatchSetNum,
+  ElementPropertyDeepChange,
+  GitRef,
+  NumericChangeId,
+  ParentPatchSetNum,
+  ParsedJSON,
+  PatchRange,
+  PatchSetNum,
+  RevisionInfo,
+  RobotId,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import {
+  pressAndReleaseKeyOn,
+  tap,
+} from '@polymer/iron-test-helpers/mock-interactions';
+import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
+import {AppElementChangeViewParams} from '../../gr-app-types';
+import {
+  SinonFakeTimers,
+  SinonSpy,
+  SinonStubbedMember,
+} from 'sinon/pkg/sinon-esm';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {CustomKeyboardEvent} from '../../../types/events';
+import {
+  CommentThread,
+  DraftInfo,
+  UIDraft,
+  UIRobot,
+} from '../../../utils/comment-util';
+import 'lodash/lodash';
+import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+const fixture = fixtureFromElement('gr-change-view');
+
+type SinonSpyMember<F extends (...args: any) => any> = SinonSpy<
+  Parameters<F>,
+  ReturnType<F>
+>;
+
+suite('gr-change-view tests', () => {
+  let element: GrChangeView;
+
+  let navigateToChangeStub: SinonStubbedMember<typeof GerritNav.navigateToChange>;
+
+  suiteSetup(() => {
+    const kb = TestKeyboardShortcutBinder.push();
+    kb.bindShortcut(Shortcut.SEND_REPLY, 'ctrl+enter');
+    kb.bindShortcut(Shortcut.REFRESH_CHANGE, 'shift+r');
+    kb.bindShortcut(Shortcut.OPEN_REPLY_DIALOG, 'a');
+    kb.bindShortcut(Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
+    kb.bindShortcut(Shortcut.TOGGLE_DIFF_MODE, 'm');
+    kb.bindShortcut(Shortcut.TOGGLE_CHANGE_STAR, 's');
+    kb.bindShortcut(Shortcut.UP_TO_DASHBOARD, 'u');
+    kb.bindShortcut(Shortcut.EXPAND_ALL_MESSAGES, 'x');
+    kb.bindShortcut(Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
+    kb.bindShortcut(Shortcut.OPEN_DIFF_PREFS, ',');
+    kb.bindShortcut(Shortcut.EDIT_TOPIC, 't');
+  });
+
+  suiteTeardown(() => {
+    TestKeyboardShortcutBinder.pop();
+  });
+
+  const TEST_SCROLL_TOP_PX = 100;
+
+  const ROBOT_COMMENTS_LIMIT = 10;
+
+  // TODO: should have a mock service to generate VALID fake data
+  const THREADS: CommentThread[] = [
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000 as AccountId,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 2 as PatchSetNum,
+          robot_id: 'rb1' as RobotId,
+          id: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+          line: 5,
+          updated: '2018-02-08 18:49:18.000000000' as Timestamp,
+          message: 'test',
+          unresolved: true,
+        },
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000 as AccountId,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 4 as PatchSetNum,
+          id: 'ecf0b9fa_fe1a5f62_1' as UrlEncodedCommentId,
+          line: 5,
+          updated: '2018-02-08 18:49:18.000000000' as Timestamp,
+          message: 'test',
+          unresolved: true,
+        },
+        {
+          id: '503008e2_0ab203ee' as UrlEncodedCommentId,
+          path: '/COMMIT_MSG',
+          line: 5,
+          in_reply_to: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+          updated: '2018-02-13 22:48:48.018000000' as Timestamp,
+          message: 'draft',
+          unresolved: false,
+          __draft: true,
+          __draftID: '0.m683trwff68',
+          __editing: false,
+          patch_set: 2 as PatchSetNum,
+        },
+      ],
+      patchNum: 4 as PatchSetNum,
+      path: '/COMMIT_MSG',
+      line: 5,
+      rootId: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+      commentSide: CommentSide.REVISION,
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000 as AccountId,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 3 as PatchSetNum,
+          id: 'ecf0b9fa_fe5f62' as UrlEncodedCommentId,
+          robot_id: 'rb2' as RobotId,
+          line: 5,
+          updated: '2018-02-08 18:49:18.000000000' as Timestamp,
+          message: 'test',
+          unresolved: true,
+        },
+        {
+          __path: 'test.txt',
+          author: {
+            _account_id: 1000000 as AccountId,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 3 as PatchSetNum,
+          id: '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
+          side: CommentSide.PARENT,
+          updated: '2018-02-13 22:47:19.000000000' as Timestamp,
+          message: 'Some comment on another patchset.',
+          unresolved: false,
+        },
+      ],
+      patchNum: 3 as PatchSetNum,
+      path: 'test.txt',
+      rootId: '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
+      commentSide: CommentSide.PARENT,
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000 as AccountId,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 2 as PatchSetNum,
+          id: '8caddf38_44770ec1' as UrlEncodedCommentId,
+          line: 4,
+          updated: '2018-02-13 22:48:40.000000000' as Timestamp,
+          message: 'Another unresolved comment',
+          unresolved: true,
+        },
+      ],
+      patchNum: 2 as PatchSetNum,
+      path: '/COMMIT_MSG',
+      line: 4,
+      rootId: '8caddf38_44770ec1' as UrlEncodedCommentId,
+      commentSide: CommentSide.REVISION,
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000 as AccountId,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 2 as PatchSetNum,
+          id: 'scaddf38_44770ec1' as UrlEncodedCommentId,
+          line: 4,
+          updated: '2018-02-14 22:48:40.000000000' as Timestamp,
+          message: 'Yet another unresolved comment',
+          unresolved: true,
+        },
+      ],
+      patchNum: 2 as PatchSetNum,
+      path: '/COMMIT_MSG',
+      line: 4,
+      rootId: 'scaddf38_44770ec1' as UrlEncodedCommentId,
+      commentSide: CommentSide.REVISION,
+    },
+    {
+      comments: [
+        {
+          id: 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+          path: '/COMMIT_MSG',
+          line: 6,
+          updated: '2018-02-15 22:48:48.018000000' as Timestamp,
+          message: 'resolved draft',
+          unresolved: false,
+          __draft: true,
+          __draftID: '0.m683trwff68',
+          __editing: false,
+          patch_set: 2 as PatchSetNum,
+        },
+      ],
+      patchNum: 4 as PatchSetNum,
+      path: '/COMMIT_MSG',
+      line: 6,
+      rootId: 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+      commentSide: CommentSide.REVISION,
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000 as AccountId,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 4 as PatchSetNum,
+          id: 'rc1' as UrlEncodedCommentId,
+          line: 5,
+          updated: '2019-02-08 18:49:18.000000000' as Timestamp,
+          message: 'test',
+          unresolved: true,
+          robot_id: 'rc1' as RobotId,
+        },
+      ],
+      patchNum: 4 as PatchSetNum,
+      path: '/COMMIT_MSG',
+      line: 5,
+      rootId: 'rc1' as UrlEncodedCommentId,
+      commentSide: CommentSide.REVISION,
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000 as AccountId,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 4 as PatchSetNum,
+          id: 'rc2' as UrlEncodedCommentId,
+          line: 5,
+          updated: '2019-03-08 18:49:18.000000000' as Timestamp,
+          message: 'test',
+          unresolved: true,
+          robot_id: 'rc2' as RobotId,
+        },
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000 as AccountId,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 4 as PatchSetNum,
+          id: 'c2_1' as UrlEncodedCommentId,
+          line: 5,
+          updated: '2019-03-08 18:49:18.000000000' as Timestamp,
+          message: 'test',
+          unresolved: true,
+        },
+      ],
+      patchNum: 4 as PatchSetNum,
+      path: '/COMMIT_MSG',
+      line: 5,
+      rootId: 'rc2' as UrlEncodedCommentId,
+      commentSide: CommentSide.REVISION,
+    },
+  ];
+
+  setup(() => {
+    // Since pluginEndpoints are global, must reset state.
+    _testOnly_resetEndpoints();
+    navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
+
+    function getCommentsStub() {
+      return Promise.resolve({});
+    }
+    stub('gr-rest-api-interface', {
+      getConfig() {
+        return Promise.resolve({
+          ...createServerInfo(),
+          user: {
+            ...createUserConfig(),
+            anonymous_coward_name: 'test coward name',
+          },
+        });
+      },
+      getAccount() {
+        return Promise.resolve(undefined);
+      },
+      getDiffComments: (getCommentsStub as unknown) as RestApiService['getDiffComments'],
+      getDiffRobotComments: (getCommentsStub as unknown) as RestApiService['getDiffRobotComments'],
+      getDiffDrafts: (getCommentsStub as unknown) as RestApiService['getDiffDrafts'],
+      _fetchSharedCacheURL() {
+        return Promise.resolve({} as ParsedJSON);
+      },
+    });
+    element = fixture.instantiate();
+    element._changeNum = 1 as NumericChangeId;
+    sinon.stub(element.$.actions, 'reload').returns(Promise.resolve());
+    getPluginLoader().loadPlugins([]);
+    pluginApi.install(
+      plugin => {
+        plugin.registerDynamicCustomComponent(
+          'change-view-tab-header',
+          'gr-checks-change-view-tab-header-view'
+        );
+        plugin.registerDynamicCustomComponent(
+          'change-view-tab-content',
+          'gr-checks-view'
+        );
+      },
+      '0.1',
+      'http://some/plugins/url.html'
+    );
+  });
+
+  teardown(done => {
+    flush(() => {
+      done();
+    });
+  });
+
+  const getCustomCssValue = (cssParam: string) =>
+    getComputedStyleValue(cssParam, element);
+
+  test('_handleMessageAnchorTap', () => {
+    element._changeNum = 1 as NumericChangeId;
+    element._patchRange = {
+      basePatchNum: ParentPatchSetNum,
+      patchNum: 1 as PatchSetNum,
+    };
+    element._change = createChange();
+    const getUrlStub = sinon.stub(GerritNav, 'getUrlForChange');
+    const replaceStateStub = sinon.stub(history, 'replaceState');
+    element._handleMessageAnchorTap(
+      new CustomEvent('message-anchor-tap', {detail: {id: 'a12345'}})
+    );
+
+    assert.equal(getUrlStub.lastCall.args[4], '#message-a12345');
+    assert.isTrue(replaceStateStub.called);
+  });
+
+  test('_handleDiffAgainstBase', () => {
+    element._change = {
+      ...createChange(),
+      revisions: createRevisions(10),
+    };
+    element._patchRange = {
+      patchNum: 3 as PatchSetNum,
+      basePatchNum: 1 as PatchSetNum,
+    };
+    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+    element._handleDiffAgainstBase(new CustomEvent('') as CustomKeyboardEvent);
+    assert(navigateToChangeStub.called);
+    const args = navigateToChangeStub.getCall(0).args;
+    assert.equal(args[0], element._change);
+    assert.equal(args[1], 3 as PatchSetNum);
+  });
+
+  test('_handleDiffAgainstLatest', () => {
+    element._change = {
+      ...createChange(),
+      revisions: createRevisions(10),
+    };
+    element._patchRange = {
+      basePatchNum: 1 as PatchSetNum,
+      patchNum: 3 as PatchSetNum,
+    };
+    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+    element._handleDiffAgainstLatest(
+      new CustomEvent('') as CustomKeyboardEvent
+    );
+    assert(navigateToChangeStub.called);
+    const args = navigateToChangeStub.getCall(0).args;
+    assert.equal(args[0], element._change);
+    assert.equal(args[1], 10 as PatchSetNum);
+    assert.equal(args[2], 1 as PatchSetNum);
+  });
+
+  test('_handleDiffBaseAgainstLeft', () => {
+    element._change = {
+      ...createChange(),
+      revisions: createRevisions(10),
+    };
+    element._patchRange = {
+      patchNum: 3 as PatchSetNum,
+      basePatchNum: 1 as PatchSetNum,
+    };
+    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+    element._handleDiffBaseAgainstLeft(
+      new CustomEvent('') as CustomKeyboardEvent
+    );
+    assert(navigateToChangeStub.called);
+    const args = navigateToChangeStub.getCall(0).args;
+    assert.equal(args[0], element._change);
+    assert.equal(args[1], 1 as PatchSetNum);
+  });
+
+  test('_handleDiffRightAgainstLatest', () => {
+    element._change = {
+      ...createChange(),
+      revisions: createRevisions(10),
+    };
+    element._patchRange = {
+      basePatchNum: 1 as PatchSetNum,
+      patchNum: 3 as PatchSetNum,
+    };
+    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+    element._handleDiffRightAgainstLatest(
+      new CustomEvent('') as CustomKeyboardEvent
+    );
+    assert(navigateToChangeStub.called);
+    const args = navigateToChangeStub.getCall(0).args;
+    assert.equal(args[1], 10 as PatchSetNum);
+    assert.equal(args[2], 3 as PatchSetNum);
+  });
+
+  test('_handleDiffBaseAgainstLatest', () => {
+    element._change = {
+      ...createChange(),
+      revisions: createRevisions(10),
+    };
+    element._patchRange = {
+      basePatchNum: 1 as PatchSetNum,
+      patchNum: 3 as PatchSetNum,
+    };
+    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+    element._handleDiffBaseAgainstLatest(
+      new CustomEvent('') as CustomKeyboardEvent
+    );
+    assert(navigateToChangeStub.called);
+    const args = navigateToChangeStub.getCall(0).args;
+    assert.equal(args[1], 10 as PatchSetNum);
+    assert.isNotOk(args[2]);
+  });
+
+  suite('plugins adding to file tab', () => {
+    setup(done => {
+      element._changeNum = 1 as NumericChangeId;
+      // Resolving it here instead of during setup() as other tests depend
+      // on flush() not being called during setup.
+      flush(() => done());
+    });
+
+    test('plugin added tab shows up as a dynamic endpoint', () => {
+      assert(
+        element._dynamicTabHeaderEndpoints.includes(
+          'change-view-tab-header-url'
+        )
+      );
+      const primaryTabs = element.shadowRoot!.querySelector('#primaryTabs')!;
+      const paperTabs = primaryTabs.querySelectorAll<HTMLElement>('paper-tab');
+      // 4 Tabs are : Files, Comment Threads, Plugin, Findings
+      assert.equal(primaryTabs.querySelectorAll('paper-tab').length, 4);
+      assert.equal(paperTabs[2].dataset.name, 'change-view-tab-header-url');
+    });
+
+    test('_setActivePrimaryTab switched tab correctly', done => {
+      element._setActivePrimaryTab(
+        new CustomEvent('', {
+          detail: {tab: 'change-view-tab-header-url'},
+        })
+      );
+      flush(() => {
+        assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
+        done();
+      });
+    });
+
+    test('show-primary-tab switched primary tab correctly', done => {
+      element.dispatchEvent(
+        new CustomEvent('show-primary-tab', {
+          composed: true,
+          bubbles: true,
+          detail: {
+            tab: 'change-view-tab-header-url',
+          },
+        })
+      );
+      flush(() => {
+        assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
+        done();
+      });
+    });
+
+    test('param change should switch primary tab correctly', done => {
+      assert.equal(element._activeTabs[0], PrimaryTab.FILES);
+      const queryMap = new Map<string, string>();
+      queryMap.set('tab', PrimaryTab.FINDINGS);
+      // view is required
+      element.params = {
+        ...createAppElementChangeViewParams(),
+        ...element.params,
+        queryMap,
+      };
+      flush(() => {
+        assert.equal(element._activeTabs[0], PrimaryTab.FINDINGS);
+        done();
+      });
+    });
+
+    test('invalid param change should not switch primary tab', done => {
+      assert.equal(element._activeTabs[0], PrimaryTab.FILES);
+      const queryMap = new Map<string, string>();
+      queryMap.set('tab', 'random');
+      // view is required
+      element.params = {
+        ...createAppElementChangeViewParams(),
+        ...element.params,
+        queryMap,
+      };
+      flush(() => {
+        assert.equal(element._activeTabs[0], PrimaryTab.FILES);
+        done();
+      });
+    });
+
+    test('switching tab sets _selectedTabPluginEndpoint', done => {
+      const paperTabs = element.shadowRoot!.querySelector('#primaryTabs')!;
+      tap(paperTabs.querySelectorAll('paper-tab')[2]);
+      flush(() => {
+        assert.equal(
+          element._selectedTabPluginEndpoint,
+          'change-view-tab-content-url'
+        );
+        done();
+      });
+    });
+  });
+
+  suite('keyboard shortcuts', () => {
+    let clock: SinonFakeTimers;
+    setup(() => {
+      clock = sinon.useFakeTimers();
+    });
+
+    teardown(() => {
+      clock.restore();
+      sinon.restore();
+    });
+
+    test('t to add topic', () => {
+      const editStub = sinon.stub(element.$.metadata, 'editTopic');
+      pressAndReleaseKeyOn(element, 83, null, 't');
+      assert(editStub.called);
+    });
+
+    test('S should toggle the CL star', () => {
+      const starStub = sinon.stub(element.$.changeStar, 'toggleStar');
+      pressAndReleaseKeyOn(element, 83, null, 's');
+      assert(starStub.called);
+    });
+
+    test('toggle star is throttled', () => {
+      const starStub = sinon.stub(element.$.changeStar, 'toggleStar');
+      pressAndReleaseKeyOn(element, 83, null, 's');
+      assert(starStub.called);
+      pressAndReleaseKeyOn(element, 83, null, 's');
+      assert.equal(starStub.callCount, 1);
+      clock.tick(1000);
+      pressAndReleaseKeyOn(element, 83, null, 's');
+      assert.equal(starStub.callCount, 2);
+    });
+
+    test('U should navigate to root if no backPage set', () => {
+      const relativeNavStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
+      pressAndReleaseKeyOn(element, 85, null, 'u');
+      assert.isTrue(relativeNavStub.called);
+      assert.isTrue(
+        relativeNavStub.lastCall.calledWithExactly(GerritNav.getUrlForRoot())
+      );
+    });
+
+    test('U should navigate to backPage if set', () => {
+      const relativeNavStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
+      element.backPage = '/dashboard/self';
+      pressAndReleaseKeyOn(element, 85, null, 'u');
+      assert.isTrue(relativeNavStub.called);
+      assert.isTrue(
+        relativeNavStub.lastCall.calledWithExactly('/dashboard/self')
+      );
+    });
+
+    test('A fires an error event when not logged in', done => {
+      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
+      const loggedInErrorSpy = sinon.spy();
+      element.addEventListener('show-auth-required', loggedInErrorSpy);
+      pressAndReleaseKeyOn(element, 65, null, 'a');
+      flush(() => {
+        assert.isFalse(element.$.replyOverlay.opened);
+        assert.isTrue(loggedInErrorSpy.called);
+        done();
+      });
+    });
+
+    test('shift A does not open reply overlay', done => {
+      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+      pressAndReleaseKeyOn(element, 65, 'shift', 'a');
+      flush(() => {
+        assert.isFalse(element.$.replyOverlay.opened);
+        done();
+      });
+    });
+
+    test('A toggles overlay when logged in', done => {
+      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+      element._change = {
+        ...createChange(),
+        revisions: createRevisions(1),
+        messages: createChangeMessages(1),
+      };
+      element._change.labels = {};
+      sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+        Promise.resolve({
+          ...createChange(),
+          // element has latest info
+          revisions: createRevisions(1),
+          messages: createChangeMessages(1),
+          current_revision: 'rev1' as CommitId,
+        })
+      );
+
+      const openSpy = sinon.spy(element, '_openReplyDialog');
+
+      pressAndReleaseKeyOn(element, 65, null, 'a');
+      flush(() => {
+        assert.isTrue(element.$.replyOverlay.opened);
+        element.$.replyOverlay.close();
+        assert.isFalse(element.$.replyOverlay.opened);
+        assert(
+          openSpy.lastCall.calledWithExactly(
+            element.$.replyDialog.FocusTarget.ANY
+          ),
+          '_openReplyDialog should have been passed ANY'
+        );
+        assert.equal(openSpy.callCount, 1);
+        done();
+      });
+    });
+
+    test('fullscreen-overlay-opened hides content', () => {
+      element._loggedIn = true;
+      element._loading = false;
+      element._change = {
+        ...createChange(),
+        labels: {},
+        actions: {
+          abandon: {
+            enabled: true,
+            label: 'Abandon',
+            method: HttpMethod.POST,
+            title: 'Abandon',
+          },
+        },
+      };
+      const handlerSpy = sinon.spy(element, '_handleHideBackgroundContent');
+      element.$.replyDialog.dispatchEvent(
+        new CustomEvent('fullscreen-overlay-opened', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(handlerSpy.called);
+      assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
+      assert.equal(getComputedStyle(element.$.actions).display, 'flex');
+    });
+
+    test('fullscreen-overlay-closed shows content', () => {
+      element._loggedIn = true;
+      element._loading = false;
+      element._change = {
+        ...createChange(),
+        labels: {},
+        actions: {
+          abandon: {
+            enabled: true,
+            label: 'Abandon',
+            method: HttpMethod.POST,
+            title: 'Abandon',
+          },
+        },
+      };
+      const handlerSpy = sinon.spy(element, '_handleShowBackgroundContent');
+      element.$.replyDialog.dispatchEvent(
+        new CustomEvent('fullscreen-overlay-closed', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(handlerSpy.called);
+      assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
+    });
+
+    test('expand all messages when expand-diffs fired', () => {
+      const handleExpand = sinon.stub(element.$.fileList, 'expandAllDiffs');
+      element.$.fileListHeader.dispatchEvent(
+        new CustomEvent('expand-diffs', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(handleExpand.called);
+    });
+
+    test('collapse all messages when collapse-diffs fired', () => {
+      const handleCollapse = sinon.stub(element.$.fileList, 'collapseAllDiffs');
+      element.$.fileListHeader.dispatchEvent(
+        new CustomEvent('collapse-diffs', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(handleCollapse.called);
+    });
+
+    test('X should expand all messages', done => {
+      flush(() => {
+        const handleExpand = sinon.stub(
+          element.messagesList!,
+          'handleExpandCollapse'
+        );
+        pressAndReleaseKeyOn(element, 88, null, 'x');
+        assert(handleExpand.calledWith(true));
+        done();
+      });
+    });
+
+    test('Z should collapse all messages', done => {
+      flush(() => {
+        const handleExpand = sinon.stub(
+          element.messagesList!,
+          'handleExpandCollapse'
+        );
+        pressAndReleaseKeyOn(element, 90, null, 'z');
+        assert(handleExpand.calledWith(false));
+        done();
+      });
+    });
+
+    test('reload event from reply dialog is processed', () => {
+      const handleReloadStub = sinon.stub(element, '_reload');
+      element.$.replyDialog.dispatchEvent(
+        new CustomEvent('reload', {
+          detail: {clearPatchset: true},
+          bubbles: true,
+          composed: true,
+        })
+      );
+      assert.isTrue(handleReloadStub.called);
+    });
+
+    test('shift + R should fetch and navigate to the latest patch set', done => {
+      element._changeNum = TEST_NUMERIC_CHANGE_ID;
+      element._patchRange = {
+        basePatchNum: ParentPatchSetNum,
+        patchNum: 1 as PatchSetNum,
+      };
+      element._change = {
+        ...createChange(),
+        revisions: {
+          rev1: createRevision(),
+        },
+        current_revision: 'rev1' as CommitId,
+        status: ChangeStatus.NEW,
+        labels: {},
+        actions: {},
+      };
+
+      const reloadChangeStub = sinon.stub(element, '_reload');
+      pressAndReleaseKeyOn(element, 82, 'shift', 'r');
+      flush(() => {
+        assert.isTrue(reloadChangeStub.called);
+        done();
+      });
+    });
+
+    test('d should open download overlay', () => {
+      const stub = sinon
+        .stub(element.$.downloadOverlay, 'open')
+        .returns(Promise.resolve());
+      pressAndReleaseKeyOn(element, 68, null, 'd');
+      assert.isTrue(stub.called);
+    });
+
+    test(', should open diff preferences', () => {
+      const stub = sinon.stub(
+        element.$.fileList.$.diffPreferencesDialog,
+        'open'
+      );
+      element._loggedIn = false;
+      element.disableDiffPrefs = true;
+      pressAndReleaseKeyOn(element, 188, null, ',');
+      assert.isFalse(stub.called);
+
+      element._loggedIn = true;
+      pressAndReleaseKeyOn(element, 188, null, ',');
+      assert.isFalse(stub.called);
+
+      element.disableDiffPrefs = false;
+      pressAndReleaseKeyOn(element, 188, null, ',');
+      assert.isTrue(stub.called);
+    });
+
+    test('m should toggle diff mode', () => {
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      const setModeStub = sinon.stub(
+        element.$.fileListHeader,
+        'setDiffViewMode'
+      );
+      const e = {preventDefault: () => {}} as CustomKeyboardEvent;
+      flush();
+
+      element.viewState.diffMode = DiffViewMode.SIDE_BY_SIDE;
+      element._handleToggleDiffMode(e);
+      assert.isTrue(setModeStub.calledWith(DiffViewMode.UNIFIED));
+
+      element.viewState.diffMode = DiffViewMode.UNIFIED;
+      element._handleToggleDiffMode(e);
+      assert.isTrue(setModeStub.calledWith(DiffViewMode.SIDE_BY_SIDE));
+    });
+  });
+
+  suite('reloading drafts', () => {
+    let reloadStub: SinonStubbedMember<typeof element.$.commentAPI.reloadDrafts>;
+    const drafts: {[path: string]: UIDraft[]} = {
+      'testfile.txt': [
+        {
+          patch_set: 5 as PatchSetNum,
+          id: 'dd2982f5_c01c9e6a' as UrlEncodedCommentId,
+          line: 1,
+          updated: '2017-11-08 18:47:45.000000000' as Timestamp,
+          message: 'test',
+          unresolved: true,
+        },
+      ],
+    };
+    setup(() => {
+      // Fake computeDraftCount as its required for ChangeComments,
+      // see gr-comment-api#reloadDrafts.
+      reloadStub = sinon.stub(element.$.commentAPI, 'reloadDrafts').returns(
+        Promise.resolve({
+          drafts,
+          getAllThreadsForChange: () => [] as CommentThread[],
+          computeDraftCount: () => 1,
+        } as ChangeComments)
+      );
+      element._changeNum = 1 as NumericChangeId;
+    });
+
+    test('drafts are reloaded when reload-drafts fired', done => {
+      element.$.fileList.dispatchEvent(
+        new CustomEvent('reload-drafts', {
+          detail: {
+            resolve: () => {
+              assert.isTrue(reloadStub.called);
+              assert.deepEqual(element._diffDrafts, drafts);
+              done();
+            },
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+    });
+
+    test('drafts are reloaded when comment-refresh fired', () => {
+      element.dispatchEvent(
+        new CustomEvent('comment-refresh', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(reloadStub.called);
+    });
+  });
+
+  suite('_recomputeComments', () => {
+    setup(() => {
+      element._changeNum = TEST_NUMERIC_CHANGE_ID;
+      element._change = createChange();
+      flush();
+      // Fake computeDraftCount as its required for ChangeComments,
+      // see gr-comment-api#reloadDrafts.
+      sinon.stub(element.$.commentAPI, 'reloadDrafts').returns(
+        Promise.resolve({
+          drafts: {},
+          getAllThreadsForChange: () => THREADS,
+          computeDraftCount: () => 0,
+        } as ChangeComments)
+      );
+      element._change = createChange();
+      element._changeNum = element._change._number;
+    });
+
+    test('draft threads should be a new copy with correct states', done => {
+      element.$.fileList.dispatchEvent(
+        new CustomEvent('reload-drafts', {
+          detail: {
+            resolve: () => {
+              assert.equal(element._draftCommentThreads!.length, 2);
+              assert.equal(
+                element._draftCommentThreads![0].rootId,
+                THREADS[0].rootId
+              );
+              assert.notEqual(
+                element._draftCommentThreads![0].comments,
+                THREADS[0].comments
+              );
+              assert.notEqual(
+                element._draftCommentThreads![0].comments[0],
+                THREADS[0].comments[0]
+              );
+              assert.isTrue(
+                element
+                  ._draftCommentThreads![0].comments.slice(0, 2)
+                  .every(c => c.collapsed === true)
+              );
+
+              assert.isTrue(
+                element._draftCommentThreads![0].comments[2].collapsed === false
+              );
+              done();
+            },
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+    });
+  });
+
+  test('diff comments modified', () => {
+    const reloadThreadsSpy = sinon.spy(element, '_handleReloadCommentThreads');
+    return element._reloadComments().then(() => {
+      element.dispatchEvent(
+        new CustomEvent('diff-comments-modified', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(reloadThreadsSpy.called);
+    });
+  });
+
+  test('thread list modified', () => {
+    const reloadDiffSpy = sinon.spy(element, '_handleReloadDiffComments');
+    element._activeTabs = [PrimaryTab.COMMENT_THREADS, SecondaryTab.CHANGE_LOG];
+    flush();
+
+    return element._reloadComments().then(() => {
+      element.threadList!.dispatchEvent(
+        new CustomEvent('thread-list-modified', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(reloadDiffSpy.called);
+
+      let draftStub = sinon
+        .stub(element._changeComments!, 'computeDraftCount')
+        .returns(1);
+      assert.equal(
+        element._computeTotalCommentCounts(5, element._changeComments!),
+        '5 unresolved, 1 draft'
+      );
+      assert.equal(
+        element._computeTotalCommentCounts(0, element._changeComments!),
+        '1 draft'
+      );
+      draftStub.restore();
+      draftStub = sinon
+        .stub(element._changeComments!, 'computeDraftCount')
+        .returns(0);
+      assert.equal(
+        element._computeTotalCommentCounts(0, element._changeComments!),
+        ''
+      );
+      assert.equal(
+        element._computeTotalCommentCounts(1, element._changeComments!),
+        '1 unresolved'
+      );
+      draftStub.restore();
+      draftStub = sinon
+        .stub(element._changeComments!, 'computeDraftCount')
+        .returns(2);
+      assert.equal(
+        element._computeTotalCommentCounts(1, element._changeComments!),
+        '1 unresolved, 2 drafts'
+      );
+      draftStub.restore();
+    });
+  });
+
+  suite('thread list and change log tabs', () => {
+    setup(() => {
+      element._changeNum = TEST_NUMERIC_CHANGE_ID;
+      element._patchRange = {
+        basePatchNum: ParentPatchSetNum,
+        patchNum: 1 as PatchSetNum,
+      };
+      element._change = {
+        ...createChange(),
+        revisions: {
+          rev2: createRevision(2),
+          rev1: createRevision(1),
+          rev13: createRevision(13),
+          rev3: createRevision(3),
+        },
+        current_revision: 'rev3' as CommitId,
+        status: ChangeStatus.NEW,
+        labels: {
+          test: {
+            all: [],
+            default_value: 0,
+            values: {},
+            approved: {},
+          },
+        },
+      };
+      sinon.stub(element.$.relatedChanges, 'reload');
+      sinon.stub(element, '_reload').returns(Promise.resolve([]));
+      sinon.spy(element, '_paramsChanged');
+      element.params = createAppElementChangeViewParams();
+    });
+  });
+
+  suite('Findings comment tab', () => {
+    setup(done => {
+      element._changeNum = TEST_NUMERIC_CHANGE_ID;
+      element._change = {
+        ...createChange(),
+        revisions: {
+          rev2: createRevision(2),
+          rev1: createRevision(1),
+          rev13: createRevision(13),
+          rev3: createRevision(3),
+          rev4: createRevision(4),
+        },
+        current_revision: 'rev4' as CommitId,
+      };
+      element._commentThreads = THREADS;
+      const paperTabs = element.shadowRoot!.querySelector('#primaryTabs')!;
+      tap(paperTabs.querySelectorAll('paper-tab')[3]);
+      flush(() => {
+        done();
+      });
+    });
+
+    test('robot comments count per patchset', () => {
+      const count = element._robotCommentCountPerPatchSet(THREADS);
+      const expectedCount = {
+        2: 1,
+        3: 1,
+        4: 2,
+      };
+      assert.deepEqual(count, expectedCount);
+      assert.equal(
+        element._computeText(createRevision(2), THREADS),
+        'Patchset 2 (1 finding)'
+      );
+      assert.equal(
+        element._computeText(createRevision(4), THREADS),
+        'Patchset 4 (2 findings)'
+      );
+      assert.equal(
+        element._computeText(createRevision(5), THREADS),
+        'Patchset 5'
+      );
+    });
+
+    test('only robot comments are rendered', () => {
+      assert.equal(element._robotCommentThreads!.length, 2);
+      assert.equal(
+        (element._robotCommentThreads![0].comments[0] as UIRobot).robot_id,
+        'rc1'
+      );
+      assert.equal(
+        (element._robotCommentThreads![1].comments[0] as UIRobot).robot_id,
+        'rc2'
+      );
+    });
+
+    test('changing patchsets resets robot comments', done => {
+      element.set('_change.current_revision', 'rev3');
+      flush(() => {
+        assert.equal(element._robotCommentThreads!.length, 1);
+        done();
+      });
+    });
+
+    test('Show more button is hidden', () => {
+      assert.isNull(element.shadowRoot!.querySelector('.show-robot-comments'));
+    });
+
+    suite('robot comments show more button', () => {
+      setup(done => {
+        const arr = [];
+        for (let i = 0; i <= 30; i++) {
+          arr.push(...THREADS);
+        }
+        element._commentThreads = arr;
+        flush(() => {
+          done();
+        });
+      });
+
+      test('Show more button is rendered', () => {
+        assert.isOk(element.shadowRoot!.querySelector('.show-robot-comments'));
+        assert.equal(
+          element._robotCommentThreads!.length,
+          ROBOT_COMMENTS_LIMIT
+        );
+      });
+
+      test('Clicking show more button renders all comments', done => {
+        tap(element.shadowRoot!.querySelector('.show-robot-comments')!);
+        flush(() => {
+          assert.equal(element._robotCommentThreads!.length, 62);
+          done();
+        });
+      });
+    });
+  });
+
+  test('reply button is not visible when logged out', () => {
+    assert.equal(getComputedStyle(element.$.replyBtn).display, 'none');
+    element._loggedIn = true;
+    assert.notEqual(getComputedStyle(element.$.replyBtn).display, 'none');
+  });
+
+  test('download tap calls _handleOpenDownloadDialog', () => {
+    const openDialogStub = sinon.stub(element, '_handleOpenDownloadDialog');
+    element.$.actions.dispatchEvent(
+      new CustomEvent('download-tap', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+    assert.isTrue(openDialogStub.called);
+  });
+
+  test('fetches the server config on attached', done => {
+    flush(() => {
+      assert.equal(
+        element._serverConfig!.user.anonymous_coward_name,
+        'test coward name'
+      );
+      done();
+    });
+  });
+
+  test('_changeStatuses', () => {
+    element._loading = false;
+    element._change = {
+      ...createChange(),
+      revisions: {
+        rev2: createRevision(2),
+        rev1: createRevision(1),
+        rev13: createRevision(13),
+        rev3: createRevision(3),
+      },
+      current_revision: 'rev3' as CommitId,
+      status: ChangeStatus.MERGED,
+      work_in_progress: true,
+      labels: {
+        test: {
+          all: [],
+          default_value: 0,
+          values: {},
+          approved: {},
+        },
+      },
+    };
+    element._mergeable = true;
+    const expectedStatuses = ['Merged', 'WIP'];
+    assert.deepEqual(element._changeStatuses, expectedStatuses);
+    assert.equal(element._changeStatus, expectedStatuses.join(', '));
+    flush();
+    const statusChips = element.shadowRoot!.querySelectorAll(
+      'gr-change-status'
+    );
+    assert.equal(statusChips.length, 2);
+  });
+
+  test('diff preferences open when open-diff-prefs is fired', () => {
+    const overlayOpenStub = sinon.stub(element.$.fileList, 'openDiffPrefs');
+    element.$.fileListHeader.dispatchEvent(
+      new CustomEvent('open-diff-prefs', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+    assert.isTrue(overlayOpenStub.called);
+  });
+
+  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';
+    result = element._prepareCommitMsgForLinkify(commitMessage);
+    assert.equal(result, 'R=\u200Btest@google.com\nR=\u200Btest@google.com');
+
+    commitMessage = 'CC=test@google.com';
+    result = element._prepareCommitMsgForLinkify(commitMessage);
+    assert.equal(result, 'CC=\u200Btest@google.com');
+  });
+
+  test('_isSubmitEnabled', () => {
+    assert.isFalse(element._isSubmitEnabled({}));
+    assert.isFalse(element._isSubmitEnabled({submit: {}}));
+    assert.isTrue(element._isSubmitEnabled({submit: {enabled: true}}));
+  });
+
+  test('_reload is called when an approved label is removed', () => {
+    const vote: ApprovalInfo = {
+      ...createApproval(),
+      _account_id: 1 as AccountId,
+      name: 'bojack',
+      value: 1,
+    };
+    element._changeNum = TEST_NUMERIC_CHANGE_ID;
+    element._patchRange = {
+      basePatchNum: ParentPatchSetNum,
+      patchNum: 1 as PatchSetNum,
+    };
+    const change = {
+      ...createChange(),
+      owner: createAccountWithIdNameAndEmail(),
+      revisions: {
+        rev2: createRevision(2),
+        rev1: createRevision(1),
+        rev13: createRevision(13),
+        rev3: createRevision(3),
+      },
+      current_revision: 'rev3' as CommitId,
+      status: ChangeStatus.NEW,
+      labels: {
+        test: {
+          all: [vote],
+          default_value: 0,
+          values: {},
+          approved: {},
+        },
+      },
+    };
+    element._change = change;
+    flush();
+    const reloadStub = sinon.stub(element, '_reload');
+    element.splice('_change.labels.test.all', 0, 1);
+    assert.isFalse(reloadStub.called);
+    change.labels.test.all.push(vote);
+    change.labels.test.all.push(vote);
+    change.labels.test.approved = vote;
+    flush();
+    element.splice('_change.labels.test.all', 0, 2);
+    assert.isTrue(reloadStub.called);
+    assert.isTrue(reloadStub.calledOnce);
+  });
+
+  test('reply button has updated count when there are drafts', () => {
+    const getLabel = element._computeReplyButtonLabel;
+
+    assert.equal(getLabel(null, false), 'Reply');
+    assert.equal(getLabel(null, true), 'Start Review');
+
+    const changeRecord: ElementPropertyDeepChange<
+      GrChangeView,
+      '_diffDrafts'
+    > = {base: undefined, path: '', value: undefined};
+    assert.equal(getLabel(changeRecord, false), 'Reply');
+
+    changeRecord.base = {};
+    assert.equal(getLabel(changeRecord, false), 'Reply');
+
+    changeRecord.base = {
+      'file1.txt': [{}],
+      'file2.txt': [{}, {}],
+    };
+    assert.equal(getLabel(changeRecord, false), 'Reply (3)');
+    assert.equal(getLabel(changeRecord, true), 'Start Review (3)');
+  });
+
+  test('comment events properly update diff drafts', () => {
+    element._patchRange = {
+      basePatchNum: ParentPatchSetNum,
+      patchNum: 2 as PatchSetNum,
+    };
+    const draft: DraftInfo = {
+      __draft: true,
+      id: 'id1' as UrlEncodedCommentId,
+      path: '/foo/bar.txt',
+      message: 'hello',
+    };
+    element._handleCommentSave(new CustomEvent('', {detail: {comment: draft}}));
+    draft.patch_set = 2 as PatchSetNum;
+    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
+    draft.patch_set = undefined;
+    draft.message = 'hello, there';
+    element._handleCommentSave(new CustomEvent('', {detail: {comment: draft}}));
+    draft.patch_set = 2 as PatchSetNum;
+    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
+    const draft2: DraftInfo = {
+      __draft: true,
+      id: 'id2' as UrlEncodedCommentId,
+      path: '/foo/bar.txt',
+      message: 'hola',
+    };
+    element._handleCommentSave(
+      new CustomEvent('', {detail: {comment: draft2}})
+    );
+    draft2.patch_set = 2 as PatchSetNum;
+    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft, draft2]});
+    draft.patch_set = undefined;
+    element._handleCommentDiscard(
+      new CustomEvent('', {detail: {comment: draft}})
+    );
+    draft.patch_set = 2 as PatchSetNum;
+    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft2]});
+    element._handleCommentDiscard(
+      new CustomEvent('', {detail: {comment: draft2}})
+    );
+    assert.deepEqual(element._diffDrafts, {});
+  });
+
+  test('change num change', () => {
+    element._changeNum = undefined;
+    element._patchRange = {
+      basePatchNum: ParentPatchSetNum,
+      patchNum: 2 as PatchSetNum,
+    };
+    element._change = {
+      ...createChange(),
+      labels: {},
+    };
+    element.viewState.changeNum = null;
+    element.viewState.diffMode = DiffViewMode.UNIFIED;
+    assert.equal(element.viewState.numFilesShown, 200);
+    assert.equal(element._numFilesShown, 200);
+    element._numFilesShown = 150;
+    flush();
+    assert.equal(element.viewState.diffMode, DiffViewMode.UNIFIED);
+    assert.equal(element.viewState.numFilesShown, 150);
+
+    element._changeNum = 1 as NumericChangeId;
+    element.params = {
+      ...createAppElementChangeViewParams(),
+      changeNum: 1 as NumericChangeId,
+    };
+    flush();
+    assert.equal(element.viewState.diffMode, DiffViewMode.UNIFIED);
+    assert.equal(element.viewState.changeNum, 1);
+
+    element._changeNum = 2 as NumericChangeId;
+    element.params = {
+      ...createAppElementChangeViewParams(),
+      changeNum: 2 as NumericChangeId,
+    };
+    flush();
+    assert.equal(element.viewState.diffMode, DiffViewMode.UNIFIED);
+    assert.equal(element.viewState.changeNum, 2);
+    assert.equal(element.viewState.numFilesShown, 200);
+    assert.equal(element._numFilesShown, 200);
+  });
+
+  test('_setDiffViewMode is called with reset when new change is loaded', () => {
+    const setDiffViewModeStub = sinon.stub(element, '_setDiffViewMode');
+    element.viewState = {changeNum: 1 as NumericChangeId};
+    element._changeNum = 2 as NumericChangeId;
+    element._resetFileListViewState();
+    assert.isTrue(setDiffViewModeStub.calledWithExactly(true));
+  });
+
+  test('diffViewMode is propagated from file list header', () => {
+    element.viewState = {diffMode: DiffViewMode.UNIFIED};
+    element.$.fileListHeader.diffViewMode = DiffViewMode.SIDE_BY_SIDE;
+    assert.equal(element.viewState.diffMode, DiffViewMode.SIDE_BY_SIDE);
+  });
+
+  test('diffMode defaults to side by side without preferences', done => {
+    sinon
+      .stub(element.$.restAPI, 'getPreferences')
+      .returns(Promise.resolve(createPreferences()));
+    // No user prefs or diff view mode set.
+
+    element._setDiffViewMode()!.then(() => {
+      assert.equal(element.viewState.diffMode, DiffViewMode.SIDE_BY_SIDE);
+      done();
+    });
+  });
+
+  test('diffMode defaults to preference when not already set', done => {
+    sinon.stub(element.$.restAPI, 'getPreferences').returns(
+      Promise.resolve({
+        ...createPreferences(),
+        default_diff_view: DiffViewMode.UNIFIED,
+      })
+    );
+
+    element._setDiffViewMode()!.then(() => {
+      assert.equal(element.viewState.diffMode, DiffViewMode.UNIFIED);
+      done();
+    });
+  });
+
+  test('existing diffMode overrides preference', done => {
+    element.viewState.diffMode = DiffViewMode.SIDE_BY_SIDE;
+    sinon.stub(element.$.restAPI, 'getPreferences').returns(
+      Promise.resolve({
+        ...createPreferences(),
+        default_diff_view: DiffViewMode.UNIFIED,
+      })
+    );
+    element._setDiffViewMode()!.then(() => {
+      assert.equal(element.viewState.diffMode, DiffViewMode.SIDE_BY_SIDE);
+      done();
+    });
+  });
+
+  test('don’t reload entire page when patchRange changes', () => {
+    const reloadStub = sinon
+      .stub(element, '_reload')
+      .callsFake(() => Promise.resolve([]));
+    const reloadPatchDependentStub = sinon
+      .stub(element, '_reloadPatchNumDependentResources')
+      .callsFake(() => Promise.resolve([undefined, undefined]));
+    const relatedClearSpy = sinon.spy(element.$.relatedChanges, 'clear');
+    const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
+
+    const value: AppElementChangeViewParams = {
+      ...createAppElementChangeViewParams(),
+      view: GerritView.CHANGE,
+      patchNum: 1 as PatchSetNum,
+    };
+    element._paramsChanged(value);
+    assert.isTrue(reloadStub.calledOnce);
+    assert.isTrue(relatedClearSpy.calledOnce);
+
+    element._initialLoadComplete = true;
+
+    value.basePatchNum = 1 as PatchSetNum;
+    value.patchNum = 2 as PatchSetNum;
+    element._paramsChanged(value);
+    assert.isFalse(reloadStub.calledTwice);
+    assert.isTrue(reloadPatchDependentStub.calledOnce);
+    assert.isTrue(relatedClearSpy.calledOnce);
+    assert.isTrue(collapseStub.calledTwice);
+  });
+
+  test('reload entire page when patchRange doesnt change', () => {
+    const reloadStub = sinon
+      .stub(element, '_reload')
+      .callsFake(() => Promise.resolve([]));
+    const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
+    const value: AppElementChangeViewParams = createAppElementChangeViewParams();
+    element._paramsChanged(value);
+    assert.isTrue(reloadStub.calledOnce);
+    element._initialLoadComplete = true;
+    element._paramsChanged(value);
+    assert.isTrue(reloadStub.calledTwice);
+    assert.isTrue(collapseStub.calledTwice);
+  });
+
+  test('related changes are not updated after other action', done => {
+    sinon.stub(element, '_reload').callsFake(() => Promise.resolve([]));
+    sinon.stub(element.$.relatedChanges, 'reload');
+    element._reload(true).then(() => {
+      assert.isFalse(navigateToChangeStub.called);
+      done();
+    });
+  });
+
+  test('_computeMergedCommitInfo', () => {
+    const dummyRevs: {[revisionId: string]: RevisionInfo} = {
+      1: createRevision(1),
+      2: createRevision(2),
+    };
+    assert.deepEqual(
+      element._computeMergedCommitInfo('0' as CommitId, dummyRevs),
+      {}
+    );
+    assert.deepEqual(
+      element._computeMergedCommitInfo('1' as CommitId, dummyRevs),
+      dummyRevs[1].commit
+    );
+
+    // Regression test for issue 5337.
+    const commit = element._computeMergedCommitInfo('2' as CommitId, dummyRevs);
+    assert.notDeepEqual(commit, dummyRevs[2]);
+    assert.deepEqual(commit, dummyRevs[2].commit);
+  });
+
+  test('_computeCopyTextForTitle', () => {
+    const change: ChangeInfo = {
+      ...createChange(),
+      _number: 123 as NumericChangeId,
+      subject: 'test subject',
+      revisions: {
+        rev1: createRevision(1),
+        rev3: createRevision(3),
+      },
+      current_revision: 'rev3' as CommitId,
+    };
+    sinon.stub(GerritNav, 'getUrlForChange').returns('/change/123');
+    assert.equal(
+      element._computeCopyTextForTitle(change),
+      `123: test subject | http://${location.host}/change/123`
+    );
+  });
+
+  test('get latest revision', () => {
+    let change: ChangeInfo = {
+      ...createChange(),
+      revisions: {
+        rev1: createRevision(1),
+        rev3: createRevision(3),
+      },
+      current_revision: 'rev3' as CommitId,
+    };
+    assert.equal(element._getLatestRevisionSHA(change), 'rev3');
+    change = {
+      ...createChange(),
+      revisions: {
+        rev1: createRevision(1),
+      },
+    };
+    assert.equal(element._getLatestRevisionSHA(change), 'rev1');
+  });
+
+  test('show commit message edit button', () => {
+    const change = createChange();
+    const mergedChanged: ChangeInfo = {
+      ...createChange(),
+      status: ChangeStatus.MERGED,
+    };
+    assert.isTrue(element._computeHideEditCommitMessage(false, false, change));
+    assert.isTrue(element._computeHideEditCommitMessage(true, true, change));
+    assert.isTrue(element._computeHideEditCommitMessage(false, true, change));
+    assert.isFalse(element._computeHideEditCommitMessage(true, false, change));
+    assert.isTrue(
+      element._computeHideEditCommitMessage(true, false, mergedChanged)
+    );
+    assert.isTrue(
+      element._computeHideEditCommitMessage(true, false, change, true)
+    );
+    assert.isFalse(
+      element._computeHideEditCommitMessage(true, false, change, false)
+    );
+  });
+
+  test('_handleCommitMessageSave trims trailing whitespace', () => {
+    element._change = createChange();
+    // Response code is 500, because we want to avoid window reloading
+    const putStub = sinon
+      .stub(element.$.restAPI, 'putChangeCommitMessage')
+      .returns(Promise.resolve(new Response(null, {status: 500})));
+
+    const mockEvent = (content: string) => {
+      return new CustomEvent('', {detail: {content}});
+    };
+
+    element._handleCommitMessageSave(mockEvent('test \n  test '));
+    assert.equal(putStub.lastCall.args[1], 'test\n  test');
+
+    element._handleCommitMessageSave(mockEvent('  test\ntest'));
+    assert.equal(putStub.lastCall.args[1], '  test\ntest');
+
+    element._handleCommitMessageSave(mockEvent('\n\n\n\n\n\n\n\n'));
+    assert.equal(putStub.lastCall.args[1], '\n\n\n\n\n\n\n\n');
+  });
+
+  test('_computeChangeIdCommitMessageError', () => {
+    let commitMessage = 'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483';
+    let change: ChangeInfo = {
+      ...createChange(),
+      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483' as ChangeId,
+    };
+    assert.equal(
+      element._computeChangeIdCommitMessageError(commitMessage, change),
+      null
+    );
+
+    change = {
+      ...createChange(),
+      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484' as ChangeId,
+    };
+    assert.equal(
+      element._computeChangeIdCommitMessageError(commitMessage, change),
+      'mismatch'
+    );
+
+    commitMessage = 'This is the greatest change.';
+    assert.equal(
+      element._computeChangeIdCommitMessageError(commitMessage, change),
+      'missing'
+    );
+  });
+
+  test('multiple change Ids in commit message picks last', () => {
+    const commitMessage = [
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
+    ].join('\n');
+    let change: ChangeInfo = {
+      ...createChange(),
+      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483' as ChangeId,
+    };
+    assert.equal(
+      element._computeChangeIdCommitMessageError(commitMessage, change),
+      null
+    );
+    change = {
+      ...createChange(),
+      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484' as ChangeId,
+    };
+    assert.equal(
+      element._computeChangeIdCommitMessageError(commitMessage, change),
+      'mismatch'
+    );
+  });
+
+  test('does not count change Id that starts mid line', () => {
+    const commitMessage = [
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
+    ].join(' and ');
+    let change: ChangeInfo = {
+      ...createChange(),
+      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484' as ChangeId,
+    };
+    assert.equal(
+      element._computeChangeIdCommitMessageError(commitMessage, change),
+      null
+    );
+    change = {
+      ...createChange(),
+      change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483' as ChangeId,
+    };
+    assert.equal(
+      element._computeChangeIdCommitMessageError(commitMessage, change),
+      'mismatch'
+    );
+  });
+
+  test('_computeTitleAttributeWarning', () => {
+    let changeIdCommitMessageError = 'missing';
+    assert.equal(
+      element._computeTitleAttributeWarning(changeIdCommitMessageError),
+      'No Change-Id in commit message'
+    );
+
+    changeIdCommitMessageError = 'mismatch';
+    assert.equal(
+      element._computeTitleAttributeWarning(changeIdCommitMessageError),
+      'Change-Id mismatch'
+    );
+  });
+
+  test('_computeChangeIdClass', () => {
+    let changeIdCommitMessageError = 'missing';
+    assert.equal(element._computeChangeIdClass(changeIdCommitMessageError), '');
+
+    changeIdCommitMessageError = 'mismatch';
+    assert.equal(
+      element._computeChangeIdClass(changeIdCommitMessageError),
+      'warning'
+    );
+  });
+
+  test('topic is coalesced to null', done => {
+    sinon.stub(element, '_changeChanged');
+    sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+      Promise.resolve({
+        ...createChange(),
+        labels: {},
+        current_revision: 'foo' as CommitId,
+        revisions: {foo: createRevision()},
+      })
+    );
+
+    element._getChangeDetail().then(() => {
+      assert.isNull(element._change!.topic);
+      done();
+    });
+  });
+
+  test('commit sha is populated from getChangeDetail', done => {
+    sinon.stub(element, '_changeChanged');
+    sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+      Promise.resolve({
+        ...createChange(),
+        labels: {},
+        current_revision: 'foo' as CommitId,
+        revisions: {foo: createRevision()},
+      })
+    );
+
+    element._getChangeDetail().then(() => {
+      assert.equal('foo', element._commitInfo!.commit);
+      done();
+    });
+  });
+
+  test('edit is added to change', () => {
+    sinon.stub(element, '_changeChanged');
+    const changeRevision = createRevision();
+    sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+      Promise.resolve({
+        ...createChange(),
+        labels: {},
+        current_revision: 'foo' as CommitId,
+        revisions: {foo: {...changeRevision}},
+      })
+    );
+    const editCommit: CommitInfo = {
+      ...createCommit(),
+      commit: 'bar' as CommitId,
+    };
+    sinon.stub(element, '_getEdit').callsFake(() =>
+      Promise.resolve({
+        base_patch_set_number: 1 as PatchSetNum,
+        commit: {...editCommit},
+        base_revision: 'abc',
+        ref: 'some/ref' as GitRef,
+      })
+    );
+    element._patchRange = {};
+
+    return element._getChangeDetail().then(() => {
+      const revs = element._change!.revisions!;
+      assert.equal(Object.keys(revs).length, 2);
+      assert.deepEqual(revs['foo'], changeRevision);
+      assert.deepEqual(revs['bar'], {
+        ...createEditRevision(),
+        commit: editCommit,
+        fetch: undefined,
+      });
+    });
+  });
+
+  test('_getBasePatchNum', () => {
+    const _change: ChangeInfo = {
+      ...createChange(),
+      revisions: {
+        '98da160735fb81604b4c40e93c368f380539dd0e': createRevision(),
+      },
+    };
+    const _patchRange: ChangeViewPatchRange = {
+      basePatchNum: ParentPatchSetNum,
+    };
+    assert.equal(element._getBasePatchNum(_change, _patchRange), 'PARENT');
+
+    element._prefs = {
+      ...createPreferences(),
+      default_base_for_merges: DefaultBase.FIRST_PARENT,
+    };
+
+    const _change2: ChangeInfo = {
+      ...createChange(),
+      revisions: {
+        '98da160735fb81604b4c40e93c368f380539dd0e': {
+          ...createRevision(1),
+          commit: {
+            ...createCommit(),
+            parents: [
+              {
+                commit: '6e12bdf1176eb4ab24d8491ba3b6d0704409cde8' as CommitId,
+                subject: 'test',
+              },
+              {
+                commit: '22f7db4754b5d9816fc581f3d9a6c0ef8429c841' as CommitId,
+                subject: 'test3',
+              },
+            ],
+          },
+        },
+      },
+    };
+    assert.equal(element._getBasePatchNum(_change2, _patchRange), -1);
+
+    _patchRange.patchNum = 1 as PatchSetNum;
+    assert.equal(element._getBasePatchNum(_change2, _patchRange), 'PARENT');
+  });
+
+  test('_openReplyDialog called with `ANY` when coming from tap event', done => {
+    flush(() => {
+      const openStub = sinon.stub(element, '_openReplyDialog');
+      tap(element.$.replyBtn);
+      assert(
+        openStub.lastCall.calledWithExactly(
+          element.$.replyDialog.FocusTarget.ANY
+        ),
+        '_openReplyDialog should have been passed ANY'
+      );
+      assert.equal(openStub.callCount, 1);
+      done();
+    });
+  });
+
+  test(
+    '_openReplyDialog called with `BODY` when coming from message reply' +
+      'event',
+    done => {
+      flush(() => {
+        const openStub = sinon.stub(element, '_openReplyDialog');
+        element.messagesList!.dispatchEvent(
+          new CustomEvent('reply', {
+            detail: {message: {message: 'text'}},
+            composed: true,
+            bubbles: true,
+          })
+        );
+        assert(
+          openStub.lastCall.calledWithExactly(
+            element.$.replyDialog.FocusTarget.BODY
+          ),
+          '_openReplyDialog should have been passed BODY'
+        );
+        assert.equal(openStub.callCount, 1);
+        done();
+      });
+    }
+  );
+
+  test('reply dialog focus can be controlled', () => {
+    const FocusTarget = element.$.replyDialog.FocusTarget;
+    const openStub = sinon.stub(element, '_openReplyDialog');
+
+    const e = new CustomEvent('show-reply-dialog', {
+      detail: {value: {ccsOnly: false}},
+    });
+    element._handleShowReplyDialog(e);
+    assert(
+      openStub.lastCall.calledWithExactly(FocusTarget.REVIEWERS),
+      '_openReplyDialog should have been passed REVIEWERS'
+    );
+    assert.equal(openStub.callCount, 1);
+
+    e.detail.value = {ccsOnly: true};
+    element._handleShowReplyDialog(e);
+    assert(
+      openStub.lastCall.calledWithExactly(FocusTarget.CCS),
+      '_openReplyDialog should have been passed CCS'
+    );
+    assert.equal(openStub.callCount, 2);
+  });
+
+  test('getUrlParameter functionality', () => {
+    const locationStub = sinon.stub(element, '_getLocationSearch');
+
+    locationStub.returns('?test');
+    assert.equal(element._getUrlParameter('test'), 'test');
+    locationStub.returns('?test2=12&test=3');
+    assert.equal(element._getUrlParameter('test'), 'test');
+    locationStub.returns('');
+    assert.isNull(element._getUrlParameter('test'));
+    locationStub.returns('?');
+    assert.isNull(element._getUrlParameter('test'));
+    locationStub.returns('?test2');
+    assert.isNull(element._getUrlParameter('test'));
+  });
+
+  test('revert dialog opened with revert param', done => {
+    sinon
+      .stub(element.$.restAPI, 'getLoggedIn')
+      .callsFake(() => Promise.resolve(true));
+    const awaitPluginsLoadedStub = sinon
+      .stub(getPluginLoader(), 'awaitPluginsLoaded')
+      .callsFake(() => Promise.resolve());
+
+    element._patchRange = {
+      basePatchNum: ParentPatchSetNum,
+      patchNum: 2 as PatchSetNum,
+    };
+    element._change = {
+      ...createChange(),
+      revisions: {
+        rev1: createRevision(1),
+        rev2: createRevision(2),
+      },
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.MERGED,
+      labels: {},
+      actions: {},
+    };
+
+    sinon.stub(element, '_getUrlParameter').callsFake(param => {
+      assert.equal(param, 'revert');
+      return param;
+    });
+
+    sinon.stub(element.$.actions, 'showRevertDialog').callsFake(done);
+
+    element._maybeShowRevertDialog();
+    assert.isTrue(awaitPluginsLoadedStub.called);
+  });
+
+  suite('scroll related tests', () => {
+    test('document scrolling calls function to set scroll height', done => {
+      const originalHeight = document.body.scrollHeight;
+      const scrollStub = sinon.stub(element, '_handleScroll').callsFake(() => {
+        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};
+
+      sinon.stub(element, '_reload').callsFake(() => {
+        // 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({...createAppElementChangeViewParams()});
+    });
+
+    test('scrollTop is reset when new change is loaded', () => {
+      element._resetFileListViewState();
+      assert.equal(element.viewState.scrollTop, 0);
+    });
+  });
+
+  suite('reply dialog tests', () => {
+    setup(() => {
+      sinon.stub(element.$.replyDialog, '_draftChanged');
+      element._change = {
+        ...createChange(),
+        revisions: createRevisions(1),
+        messages: createChangeMessages(1),
+      };
+      element._change.labels = {};
+      sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+        Promise.resolve({
+          ...createChange(),
+          // element has latest info
+          revisions: {rev1: createRevision()},
+          messages: createChangeMessages(1),
+          current_revision: 'rev1' as CommitId,
+        })
+      );
+    });
+
+    test('show reply dialog on open-reply-dialog event', done => {
+      const openReplyDialogStub = sinon.stub(element, '_openReplyDialog');
+      element.dispatchEvent(
+        new CustomEvent('open-reply-dialog', {
+          composed: true,
+          bubbles: true,
+          detail: {},
+        })
+      );
+      flush(() => {
+        assert.isTrue(openReplyDialogStub.calledOnce);
+        done();
+      });
+    });
+
+    test('reply from comment adds quote text', () => {
+      const e = new CustomEvent('', {
+        detail: {message: {message: 'quote text'}},
+      });
+      element._handleMessageReply(e);
+      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+    });
+
+    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';
+      const e = new CustomEvent('', {
+        detail: {message: {message: 'quote text'}},
+      });
+      element._handleMessageReply(e);
+      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+    });
+
+    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';
+      const e = new CustomEvent('', {
+        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', () => {
+      const div = document.createElement('div');
+      element.$.replyDialog.draft = '> quote text\n\n some draft text';
+      element.$.replyDialog.quote = '> quote text\n\n';
+      const e = ({
+        target: div,
+        preventDefault: sinon.spy(),
+      } as unknown) as MouseEvent;
+      element._handleReplyTap(e);
+      assert.equal(
+        element.$.replyDialog.draft,
+        '> quote text\n\n some draft text'
+      );
+      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+    });
+  });
+
+  test('reply button is disabled until server config is loaded', done => {
+    assert.isTrue(element._replyDisabled);
+    // fetches the server config on attached
+    flush(() => {
+      assert.isFalse(element._replyDisabled);
+      done();
+    });
+  });
+
+  suite('commit message expand/collapse', () => {
+    setup(() => {
+      element._change = {
+        ...createChange(),
+        revisions: createRevisions(1),
+        messages: createChangeMessages(1),
+      };
+      element._change.labels = {};
+      sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+        Promise.resolve({
+          ...createChange(),
+          // new patchset was uploaded
+          revisions: createRevisions(2),
+          current_revision: getCurrentRevision(2),
+          messages: createChangeMessages(1),
+        })
+      );
+    });
+
+    test('commitCollapseToggle hidden for short commit message', () => {
+      element._latestCommitMessage = '';
+      assert.isTrue(element.$.commitCollapseToggle.hasAttribute('hidden'));
+    });
+
+    test('commitCollapseToggle shown for long commit message', () => {
+      element._latestCommitMessage = _.times(31, String).join('\n');
+      assert.isFalse(element.$.commitCollapseToggle.hasAttribute('hidden'));
+    });
+
+    test('commitCollapseToggle functions', () => {
+      element._latestCommitMessage = _.times(35, String).join('\n');
+      assert.isTrue(element._commitCollapsed);
+      assert.isTrue(element._commitCollapsible);
+      assert.isTrue(element.$.commitMessageEditor.hasAttribute('collapsed'));
+      tap(element.$.commitCollapseToggleButton);
+      assert.isFalse(element._commitCollapsed);
+      assert.isTrue(element._commitCollapsible);
+      assert.isFalse(element.$.commitMessageEditor.hasAttribute('collapsed'));
+    });
+  });
+
+  suite('related changes expand/collapse', () => {
+    let updateHeightSpy: SinonSpyMember<typeof element._updateRelatedChangeMaxHeight>;
+    setup(() => {
+      updateHeightSpy = sinon.spy(element, '_updateRelatedChangeMaxHeight');
+    });
+
+    test('relatedChangesToggle shown height greater than changeInfo height', () => {
+      assert.isFalse(
+        element.$.relatedChangesToggle.classList.contains('showToggle')
+      );
+      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+      sinon.stub(element, '_getScrollHeight').callsFake(() => 60);
+      sinon.stub(element, '_getLineHeight').callsFake(() => 5);
+      sinon.stub(window, 'matchMedia').callsFake(() => {
+        return {matches: true} as MediaQueryList;
+      });
+      element.$.relatedChanges.dispatchEvent(
+        new CustomEvent('new-section-loaded')
+      );
+      assert.isTrue(
+        element.$.relatedChangesToggle.classList.contains('showToggle')
+      );
+      assert.equal(updateHeightSpy.callCount, 1);
+    });
+
+    test('relatedChangesToggle hidden height less than changeInfo height', () => {
+      assert.isFalse(
+        element.$.relatedChangesToggle.classList.contains('showToggle')
+      );
+      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+      sinon.stub(element, '_getScrollHeight').callsFake(() => 40);
+      sinon.stub(element, '_getLineHeight').callsFake(() => 5);
+      sinon.stub(window, 'matchMedia').callsFake(() => {
+        return {matches: true} as MediaQueryList;
+      });
+      element.$.relatedChanges.dispatchEvent(
+        new CustomEvent('new-section-loaded')
+      );
+      assert.isFalse(
+        element.$.relatedChangesToggle.classList.contains('showToggle')
+      );
+      assert.equal(updateHeightSpy.callCount, 1);
+    });
+
+    test('relatedChangesToggle functions', () => {
+      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+      sinon.stub(window, 'matchMedia').callsFake(() => {
+        return {matches: false} as MediaQueryList;
+      });
+      assert.isTrue(element._relatedChangesCollapsed);
+      assert.isTrue(element.$.relatedChanges.classList.contains('collapsed'));
+      tap(element.$.relatedChangesToggleButton);
+      assert.isFalse(element._relatedChangesCollapsed);
+      assert.isFalse(element.$.relatedChanges.classList.contains('collapsed'));
+    });
+
+    test('_updateRelatedChangeMaxHeight without commit toggle', () => {
+      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
+      sinon.stub(window, 'matchMedia').callsFake(() => {
+        return {matches: false} as MediaQueryList;
+      });
+
+      // 50 (existing height) - 30 (extra height) = 20 (adjusted height).
+      // 20 (max existing height)  % 12 (line height) = 6 (remainder).
+      // 20 (adjusted height) - 8 (remainder) = 12 (max height to set).
+
+      element._updateRelatedChangeMaxHeight();
+      assert.equal(getCustomCssValue('--relation-chain-max-height'), '12px');
+      assert.equal(getCustomCssValue('--related-change-btn-top-padding'), '');
+    });
+
+    test('_updateRelatedChangeMaxHeight with commit toggle', () => {
+      element._latestCommitMessage = _.times(31, String).join('\n');
+      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
+      sinon.stub(window, 'matchMedia').callsFake(() => {
+        return {matches: false} as MediaQueryList;
+      });
+
+      // 50 (existing height) % 12 (line height) = 2 (remainder).
+      // 50 (existing height)  - 2 (remainder) = 48 (max height to set).
+
+      element._updateRelatedChangeMaxHeight();
+      assert.equal(getCustomCssValue('--relation-chain-max-height'), '48px');
+      assert.equal(
+        getCustomCssValue('--related-change-btn-top-padding'),
+        '2px'
+      );
+    });
+
+    test('_updateRelatedChangeMaxHeight in small screen mode', () => {
+      element._latestCommitMessage = _.times(31, String).join('\n');
+      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
+      sinon.stub(window, 'matchMedia').callsFake(() => {
+        return {matches: true} as MediaQueryList;
+      });
+
+      element._updateRelatedChangeMaxHeight();
+
+      // 400 (new height) % 12 (line height) = 4 (remainder).
+      // 400 (new height) - 4 (remainder) = 396.
+
+      assert.equal(getCustomCssValue('--relation-chain-max-height'), '396px');
+    });
+
+    test('_updateRelatedChangeMaxHeight in medium screen mode', () => {
+      element._latestCommitMessage = _.times(31, String).join('\n');
+      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
+      const matchMediaStub = sinon.stub(window, 'matchMedia').callsFake(() => {
+        if (matchMediaStub.lastCall.args[0] === '(max-width: 75em)') {
+          return {matches: true} as MediaQueryList;
+        } else {
+          return {matches: false} as MediaQueryList;
+        }
+      });
+
+      // 100 (new height) % 12 (line height) = 4 (remainder).
+      // 100 (new height) - 4 (remainder) = 96.
+      element._updateRelatedChangeMaxHeight();
+      assert.equal(getCustomCssValue('--relation-chain-max-height'), '96px');
+    });
+
+    suite('update checks', () => {
+      let startUpdateCheckTimerSpy: SinonSpyMember<typeof element._startUpdateCheckTimer>;
+      let asyncStub: SinonStubbedMember<typeof element.async>;
+      setup(() => {
+        startUpdateCheckTimerSpy = sinon.spy(element, '_startUpdateCheckTimer');
+        asyncStub = sinon.stub(element, 'async').callsFake(f => {
+          // Only fire the async callback one time.
+          if (asyncStub.callCount > 1) {
+            return 1;
+          }
+          f.call(element);
+          return 1;
+        });
+        element._change = {
+          ...createChange(),
+          revisions: createRevisions(1),
+          messages: createChangeMessages(1),
+        };
+      });
+
+      test('_startUpdateCheckTimer negative delay', () => {
+        const getChangeDetailStub = sinon
+          .stub(element.$.restAPI, 'getChangeDetail')
+          .callsFake(() =>
+            Promise.resolve({
+              ...createChange(),
+              // element has latest info
+              revisions: {rev1: createRevision()},
+              messages: createChangeMessages(1),
+              current_revision: 'rev1' as CommitId,
+            })
+          );
+
+        element._serverConfig = {
+          ...createServerInfo(),
+          change: {...createChangeConfig(), update_delay: -1},
+        };
+
+        assert.isTrue(startUpdateCheckTimerSpy.called);
+        assert.isFalse(getChangeDetailStub.called);
+      });
+
+      test('_startUpdateCheckTimer up-to-date', async () => {
+        const getChangeDetailStub = sinon
+          .stub(element.$.restAPI, 'getChangeDetail')
+          .callsFake(() =>
+            Promise.resolve({
+              ...createChange(),
+              // element has latest info
+              revisions: {rev1: createRevision()},
+              messages: createChangeMessages(1),
+              current_revision: 'rev1' as CommitId,
+            })
+          );
+
+        element._serverConfig = {
+          ...createServerInfo(),
+          change: {...createChangeConfig(), update_delay: 12345},
+        };
+        await flush();
+
+        assert.equal(startUpdateCheckTimerSpy.callCount, 2);
+        assert.isTrue(getChangeDetailStub.called);
+        assert.equal(asyncStub.lastCall.args[1], 12345 * 1000);
+      });
+
+      test('_startUpdateCheckTimer out-of-date shows an alert', done => {
+        sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+          Promise.resolve({
+            ...createChange(),
+            // new patchset was uploaded
+            revisions: createRevisions(2),
+            current_revision: getCurrentRevision(2),
+            messages: createChangeMessages(1),
+          })
+        );
+
+        element.addEventListener('show-alert', e => {
+          assert.equal(e.detail.message, 'A newer patch set has been uploaded');
+          done();
+        });
+        element._serverConfig = {
+          ...createServerInfo(),
+          change: {...createChangeConfig(), update_delay: 12345},
+        };
+
+        assert.equal(startUpdateCheckTimerSpy.callCount, 1);
+      });
+
+      test('_startUpdateCheckTimer respects _loading', async () => {
+        sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+          Promise.resolve({
+            ...createChange(),
+            // new patchset was uploaded
+            revisions: createRevisions(2),
+            current_revision: getCurrentRevision(2),
+            messages: createChangeMessages(1),
+          })
+        );
+
+        element._loading = true;
+        element._serverConfig = {
+          ...createServerInfo(),
+          change: {...createChangeConfig(), update_delay: 12345},
+        };
+        await flush();
+
+        // No toast, instead a second call to _startUpdateCheckTimer().
+        assert.equal(startUpdateCheckTimerSpy.callCount, 2);
+      });
+
+      test('_startUpdateCheckTimer new status shows an alert', done => {
+        sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+          Promise.resolve({
+            ...createChange(),
+            // element has latest info
+            revisions: {rev1: createRevision()},
+            messages: createChangeMessages(1),
+            current_revision: 'rev1' as CommitId,
+            status: ChangeStatus.MERGED,
+          })
+        );
+
+        element.addEventListener('show-alert', e => {
+          assert.equal(e.detail.message, 'This change has been merged');
+          done();
+        });
+        element._serverConfig = {
+          ...createServerInfo(),
+          change: {...createChangeConfig(), update_delay: 12345},
+        };
+      });
+
+      test('_startUpdateCheckTimer new messages shows an alert', done => {
+        sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+          Promise.resolve({
+            ...createChange(),
+            revisions: {rev1: createRevision()},
+            // element has new message
+            messages: createChangeMessages(2),
+            current_revision: 'rev1' as CommitId,
+          })
+        );
+        element.addEventListener('show-alert', e => {
+          assert.equal(
+            e.detail.message,
+            'There are new messages on this change'
+          );
+          done();
+        });
+        element._serverConfig = {
+          ...createServerInfo(),
+          change: {...createChangeConfig(), update_delay: 12345},
+        };
+      });
+    });
+
+    test('canStartReview computation', () => {
+      const change1: ChangeInfo = createChange();
+      const change2: ChangeInfo = {
+        ...createChange(),
+        actions: {
+          ready: {
+            enabled: true,
+          },
+        },
+      };
+      const change3: ChangeInfo = {
+        ...createChange(),
+        actions: {
+          ready: {
+            label: 'Ready for Review',
+          },
+        },
+      };
+      assert.isFalse(element._computeCanStartReview(change1));
+      assert.isTrue(element._computeCanStartReview(change2));
+      assert.isFalse(element._computeCanStartReview(change3));
+    });
+  });
+
+  test('header class computation', () => {
+    assert.equal(element._computeHeaderClass(), 'header');
+    assert.equal(element._computeHeaderClass(true), 'header editMode');
+  });
+
+  test('_maybeScrollToMessage', done => {
+    flush(() => {
+      const scrollStub = sinon.stub(element.messagesList!, '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');
+      done();
+    });
+  });
+
+  test('topic update reloads related changes', () => {
+    const reloadStub = sinon.stub(element.$.relatedChanges, 'reload');
+    element.dispatchEvent(new CustomEvent('topic-changed'));
+    assert.isTrue(reloadStub.calledOnce);
+  });
+
+  test('_computeEditMode', () => {
+    const callCompute = (
+      range: PatchRange,
+      params: AppElementChangeViewParams
+    ) =>
+      element._computeEditMode(
+        {base: range, path: '', value: range},
+        {base: params, path: '', value: params}
+      );
+    assert.isTrue(
+      callCompute(
+        {basePatchNum: ParentPatchSetNum, patchNum: 1 as PatchSetNum},
+        {...createAppElementChangeViewParams(), edit: true}
+      )
+    );
+    assert.isFalse(
+      callCompute(
+        {basePatchNum: ParentPatchSetNum, patchNum: 1 as PatchSetNum},
+        createAppElementChangeViewParams()
+      )
+    );
+    assert.isFalse(
+      callCompute(
+        {basePatchNum: EditPatchSetNum, patchNum: 1 as PatchSetNum},
+        createAppElementChangeViewParams()
+      )
+    );
+    assert.isTrue(
+      callCompute(
+        {basePatchNum: 1 as PatchSetNum, patchNum: EditPatchSetNum},
+        createAppElementChangeViewParams()
+      )
+    );
+  });
+
+  test('_processEdit', () => {
+    element._patchRange = {};
+    const change: ParsedChangeInfo = {
+      ...createChange(),
+      current_revision: 'foo' as CommitId,
+      revisions: {
+        foo: {...createRevision(), actions: {cherrypick: {enabled: true}}},
+      },
+    };
+    let mockChange;
+
+    // With no edit, mockChange should be unmodified.
+    element._processEdit((mockChange = _.cloneDeep(change)), false);
+    assert.deepEqual(mockChange, change);
+
+    const editCommit: CommitInfo = {
+      ...createCommit(),
+      commit: 'bar' as CommitId,
+    };
+    // When edit is not based on the latest PS, current_revision should be
+    // unmodified.
+    const edit: EditInfo = {
+      ref: 'ref/test/abc' as GitRef,
+      base_revision: 'abc',
+      base_patch_set_number: 1 as PatchSetNum,
+      commit: {...editCommit},
+      fetch: {},
+    };
+    element._processEdit((mockChange = _.cloneDeep(change)), edit);
+    assert.notDeepEqual(mockChange, change);
+    assert.equal(mockChange.revisions.bar._number, EditPatchSetNum);
+    assert.equal(mockChange.current_revision, change.current_revision);
+    assert.deepEqual(mockChange.revisions.bar.commit, editCommit);
+    assert.notOk(mockChange.revisions.bar.actions);
+
+    edit.base_revision = 'foo';
+    element._processEdit((mockChange = _.cloneDeep(change)), edit);
+    assert.notDeepEqual(mockChange, change);
+    assert.equal(mockChange.current_revision, 'bar');
+    assert.deepEqual(
+      mockChange.revisions.bar.actions,
+      mockChange.revisions.foo.actions
+    );
+
+    // If _patchRange.patchNum is defined, do not load edit.
+    element._patchRange.patchNum = 5 as PatchSetNum;
+    change.current_revision = 'baz' as CommitId;
+    element._processEdit((mockChange = _.cloneDeep(change)), edit);
+    assert.equal(element._patchRange.patchNum, 5 as PatchSetNum);
+    assert.notOk(mockChange.revisions.bar.actions);
+  });
+
+  test('file-action-tap handling', () => {
+    element._patchRange = {
+      basePatchNum: ParentPatchSetNum,
+      patchNum: 1 as PatchSetNum,
+    };
+    element._change = {
+      ...createChange(),
+    };
+    const fileList = element.$.fileList;
+    const Actions = GrEditConstants.Actions;
+    element.$.fileListHeader.editMode = true;
+    flush();
+    const controls = element.$.fileListHeader.shadowRoot!.querySelector(
+      '#editControls'
+    ) as GrEditControls;
+    const openDeleteDialogStub = sinon.stub(controls, 'openDeleteDialog');
+    const openRenameDialogStub = sinon.stub(controls, 'openRenameDialog');
+    const openRestoreDialogStub = sinon.stub(controls, 'openRestoreDialog');
+    const getEditUrlForDiffStub = sinon.stub(GerritNav, 'getEditUrlForDiff');
+    const navigateToRelativeUrlStub = sinon.stub(
+      GerritNav,
+      'navigateToRelativeUrl'
+    );
+
+    // Delete
+    fileList.dispatchEvent(
+      new CustomEvent('file-action-tap', {
+        detail: {action: Actions.DELETE.id, path: 'foo'},
+        bubbles: true,
+        composed: true,
+      })
+    );
+    flush();
+
+    assert.isTrue(openDeleteDialogStub.called);
+    assert.equal(openDeleteDialogStub.lastCall.args[0], 'foo');
+
+    // Restore
+    fileList.dispatchEvent(
+      new CustomEvent('file-action-tap', {
+        detail: {action: Actions.RESTORE.id, path: 'foo'},
+        bubbles: true,
+        composed: true,
+      })
+    );
+    flush();
+
+    assert.isTrue(openRestoreDialogStub.called);
+    assert.equal(openRestoreDialogStub.lastCall.args[0], 'foo');
+
+    // Rename
+    fileList.dispatchEvent(
+      new CustomEvent('file-action-tap', {
+        detail: {action: Actions.RENAME.id, path: 'foo'},
+        bubbles: true,
+        composed: true,
+      })
+    );
+    flush();
+
+    assert.isTrue(openRenameDialogStub.called);
+    assert.equal(openRenameDialogStub.lastCall.args[0], 'foo');
+
+    // Open
+    fileList.dispatchEvent(
+      new CustomEvent('file-action-tap', {
+        detail: {action: Actions.OPEN.id, path: 'foo'},
+        bubbles: true,
+        composed: true,
+      })
+    );
+    flush();
+
+    assert.isTrue(getEditUrlForDiffStub.called);
+    assert.equal(getEditUrlForDiffStub.lastCall.args[1], 'foo');
+    assert.equal(getEditUrlForDiffStub.lastCall.args[2], 1 as PatchSetNum);
+    assert.isTrue(navigateToRelativeUrlStub.called);
+  });
+
+  test('_selectedRevision updates when patchNum is changed', () => {
+    const revision1: RevisionInfo = createRevision(1);
+    const revision2: RevisionInfo = createRevision(2);
+    sinon.stub(element.$.restAPI, 'getChangeDetail').returns(
+      Promise.resolve({
+        ...createChange(),
+        revisions: {
+          aaa: revision1,
+          bbb: revision2,
+        },
+        labels: {},
+        actions: {},
+        current_revision: 'bbb' as CommitId,
+      })
+    );
+    sinon.stub(element, '_getEdit').returns(Promise.resolve(false));
+    sinon
+      .stub(element, '_getPreferences')
+      .returns(Promise.resolve(createPreferences()));
+    element._patchRange = {patchNum: 2 as PatchSetNum};
+    return element._getChangeDetail().then(() => {
+      assert.strictEqual(element._selectedRevision, revision2);
+
+      element.set('_patchRange.patchNum', '1');
+      assert.strictEqual(element._selectedRevision, revision1);
+    });
+  });
+
+  test('_selectedRevision is assigned when patchNum is edit', () => {
+    const revision1 = createRevision(1);
+    const revision2 = createRevision(2);
+    const revision3 = createEditRevision();
+    sinon.stub(element.$.restAPI, 'getChangeDetail').returns(
+      Promise.resolve({
+        ...createChange(),
+        revisions: {
+          aaa: revision1,
+          bbb: revision2,
+          ccc: revision3,
+        },
+        labels: {},
+        actions: {},
+        current_revision: 'ccc' as CommitId,
+      })
+    );
+    sinon.stub(element, '_getEdit').returns(Promise.resolve(undefined));
+    sinon
+      .stub(element, '_getPreferences')
+      .returns(Promise.resolve(createPreferences()));
+    element._patchRange = {patchNum: EditPatchSetNum};
+    return element._getChangeDetail().then(() => {
+      assert.strictEqual(element._selectedRevision, revision3);
+    });
+  });
+
+  test('_sendShowChangeEvent', () => {
+    const change = {...createChange(), labels: {}};
+    element._change = {...change};
+    element._patchRange = {patchNum: 4 as PatchSetNum};
+    element._mergeable = true;
+    const showStub = sinon.stub(element.$.jsAPI, 'handleEvent');
+    element._sendShowChangeEvent();
+    assert.isTrue(showStub.calledOnce);
+    assert.equal(showStub.lastCall.args[0], EventType.SHOW_CHANGE);
+    assert.deepEqual(showStub.lastCall.args[1], {
+      change,
+      patchNum: 4,
+      info: {mergeable: true},
+    });
+  });
+
+  suite('_handleEditTap', () => {
+    let fireEdit: () => void;
+
+    setup(() => {
+      fireEdit = () => {
+        element.$.actions.dispatchEvent(new CustomEvent('edit-tap'));
+      };
+      navigateToChangeStub.restore();
+
+      element._change = {
+        ...createChange(),
+        revisions: {rev1: createRevision()},
+      };
+    });
+
+    test('edit exists in revisions', done => {
+      sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
+        assert.equal(args.length, 2);
+        assert.equal(args[1], EditPatchSetNum); // patchNum
+        done();
+      });
+
+      element.set('_change.revisions.rev2', {
+        _number: SPECIAL_PATCH_SET_NUM.EDIT,
+      });
+      flush();
+
+      fireEdit();
+    });
+
+    test('no edit exists in revisions, non-latest patchset', done => {
+      sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
+        assert.equal(args.length, 4);
+        assert.equal(args[1], 1 as PatchSetNum); // patchNum
+        assert.equal(args[3], true); // opt_isEdit
+        done();
+      });
+
+      element.set('_change.revisions.rev2', {_number: 2});
+      element._patchRange = {patchNum: 1 as PatchSetNum};
+      flush();
+
+      fireEdit();
+    });
+
+    test('no edit exists in revisions, latest patchset', done => {
+      sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
+        assert.equal(args.length, 4);
+        // No patch should be specified when patchNum == latest.
+        assert.isNotOk(args[1]); // patchNum
+        assert.equal(args[3], true); // opt_isEdit
+        done();
+      });
+
+      element.set('_change.revisions.rev2', {_number: 2});
+      element._patchRange = {patchNum: 2 as PatchSetNum};
+      flush();
+
+      fireEdit();
+    });
+  });
+
+  test('_handleStopEditTap', done => {
+    element._change = {
+      ...createChange(),
+    };
+    sinon.stub(element.$.metadata, '_computeLabelNames');
+    navigateToChangeStub.restore();
+    sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
+      assert.equal(args.length, 2);
+      assert.equal(args[1], 1 as PatchSetNum); // patchNum
+      done();
+    });
+
+    element._patchRange = {patchNum: 1 as PatchSetNum};
+    element.$.actions.dispatchEvent(
+      new CustomEvent('stop-edit-tap', {bubbles: false})
+    );
+  });
+
+  suite('plugin endpoints', () => {
+    test('endpoint params', done => {
+      element._change = {...createChange(), labels: {}};
+      element._selectedRevision = createRevision();
+      let hookEl: HTMLElement;
+      let plugin: PluginApi;
+      pluginApi.install(
+        p => {
+          plugin = p;
+          plugin
+            .hook('change-view-integration')
+            .getLastAttached()
+            .then(el => (hookEl = el));
+        },
+        '0.1',
+        'http://some/plugins/url.html'
+      );
+      flush(() => {
+        assert.strictEqual((hookEl as any).plugin, plugin);
+        assert.strictEqual((hookEl as any).change, element._change);
+        assert.strictEqual((hookEl as any).revision, element._selectedRevision);
+        done();
+      });
+    });
+  });
+
+  suite('_getMergeability', () => {
+    let getMergeableStub: SinonStubbedMember<RestApiService['getMergeable']>;
+    setup(() => {
+      element._change = {...createChange(), labels: {}};
+      getMergeableStub = sinon
+        .stub(element.$.restAPI, 'getMergeable')
+        .returns(Promise.resolve({...createMergeable(), mergeable: true}));
+    });
+
+    test('merged change', () => {
+      element._mergeable = null;
+      element._change!.status = ChangeStatus.MERGED;
+      return element._getMergeability().then(() => {
+        assert.isFalse(element._mergeable);
+        assert.isFalse(getMergeableStub.called);
+      });
+    });
+
+    test('abandoned change', () => {
+      element._mergeable = null;
+      element._change!.status = ChangeStatus.ABANDONED;
+      return element._getMergeability().then(() => {
+        assert.isFalse(element._mergeable);
+        assert.isFalse(getMergeableStub.called);
+      });
+    });
+
+    test('open change', () => {
+      element._mergeable = null;
+      return element._getMergeability().then(() => {
+        assert.isTrue(element._mergeable);
+        assert.isTrue(getMergeableStub.called);
+      });
+    });
+  });
+
+  test('_paramsChanged sets in projectLookup', () => {
+    sinon.stub(element.$.relatedChanges, 'reload');
+    sinon.stub(element, '_reload').returns(Promise.resolve([]));
+    const setStub = sinon.stub(element.$.restAPI, 'setInProjectLookup');
+    element._paramsChanged({
+      view: GerritNav.View.CHANGE,
+      changeNum: 101 as NumericChangeId,
+      project: TEST_PROJECT_NAME,
+    });
+    assert.isTrue(setStub.calledOnce);
+    assert.isTrue(
+      setStub.calledWith(101 as NumericChangeId, TEST_PROJECT_NAME)
+    );
+  });
+
+  test('_handleToggleStar called when star is tapped', () => {
+    element._change = {
+      ...createChange(),
+      owner: {_account_id: 1 as AccountId},
+      starred: false,
+    };
+    element._loggedIn = true;
+    const stub = sinon.stub(element, '_handleToggleStar');
+    flush();
+
+    tap(element.$.changeStar.shadowRoot!.querySelector('button')!);
+    assert.isTrue(stub.called);
+  });
+
+  suite('gr-reporting tests', () => {
+    setup(() => {
+      element._patchRange = {
+        basePatchNum: ParentPatchSetNum,
+        patchNum: 1 as PatchSetNum,
+      };
+      sinon.stub(element, '_getChangeDetail').returns(Promise.resolve(false));
+      sinon.stub(element, '_getProjectConfig').returns(Promise.resolve());
+      sinon.stub(element, '_reloadComments').returns(Promise.resolve());
+      sinon.stub(element, '_getMergeability').returns(Promise.resolve());
+      sinon.stub(element, '_getLatestCommitMessage').returns(Promise.resolve());
+    });
+
+    test("don't report changedDisplayed on reply", done => {
+      const changeDisplayStub = sinon.stub(
+        element.reporting,
+        'changeDisplayed'
+      );
+      const changeFullyLoadedStub = sinon.stub(
+        element.reporting,
+        'changeFullyLoaded'
+      );
+      element._handleReplySent();
+      flush(() => {
+        assert.isFalse(changeDisplayStub.called);
+        assert.isFalse(changeFullyLoadedStub.called);
+        done();
+      });
+    });
+
+    test('report changedDisplayed on _paramsChanged', done => {
+      const changeDisplayStub = sinon.stub(
+        element.reporting,
+        'changeDisplayed'
+      );
+      const changeFullyLoadedStub = sinon.stub(
+        element.reporting,
+        'changeFullyLoaded'
+      );
+      element._paramsChanged({
+        ...createAppElementChangeViewParams(),
+        changeNum: 101 as NumericChangeId,
+        project: TEST_PROJECT_NAME,
+      });
+      flush(() => {
+        assert.isTrue(changeDisplayStub.called);
+        assert.isTrue(changeFullyLoadedStub.called);
+        done();
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
index ae9254a..e05bac0 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
@@ -31,6 +31,7 @@
   BranchInfo,
   RepoName,
   BranchName,
+  CommitId,
 } from '../../../types/common';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {customElement, property, observe} from '@polymer/decorators';
@@ -105,7 +106,7 @@
   commitMessage?: string;
 
   @property({type: String})
-  commitNum?: string;
+  commitNum?: CommitId;
 
   @property({type: String})
   message?: string;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
index 00b9906..db0e1ff 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
@@ -37,6 +37,10 @@
   value: NumericChangeId;
 }
 
+export interface ConfirmRebaseEventDetail {
+  base: string | null;
+}
+
 export interface GrConfirmRebaseDialog {
   $: {
     restAPI: RestApiService & Element;
@@ -187,9 +191,10 @@
   _handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('confirm', {detail: {base: this._getSelectedBase()}})
-    );
+    const detail: ConfirmRebaseEventDetail = {
+      base: this._getSelectedBase(),
+    };
+    this.dispatchEvent(new CustomEvent('confirm', {detail}));
     this._text = '';
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
index beaf0f8..3facde1 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
@@ -25,15 +25,21 @@
 import {customElement, property} from '@polymer/decorators';
 import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
 import {ChangeInfo, CommitId} from '../../../types/common';
+import {fireAlert} from '../../../utils/event-util';
 
 const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
 const CHANGE_SUBJECT_LIMIT = 50;
 
 // TODO(dhruvsri): clean up repeated definitions after moving to js modules
-const REVERT_TYPES = {
-  REVERT_SINGLE_CHANGE: 1,
-  REVERT_SUBMISSION: 2,
-};
+export enum RevertType {
+  REVERT_SINGLE_CHANGE = 1,
+  REVERT_SUBMISSION = 2,
+}
+
+export interface ConfirmRevertEventDetail {
+  revertType: RevertType;
+  message?: string;
+}
 
 export interface GrConfirmRevertDialog {
   $: {
@@ -66,7 +72,7 @@
   _message?: string;
 
   @property({type: Number})
-  _revertType = REVERT_TYPES.REVERT_SINGLE_CHANGE;
+  _revertType = RevertType.REVERT_SINGLE_CHANGE;
 
   @property({type: Boolean})
   _showRevertSubmission = false;
@@ -88,11 +94,11 @@
   _revertMessages: string[] = [];
 
   _computeIfSingleRevert(revertType: number) {
-    return revertType === REVERT_TYPES.REVERT_SINGLE_CHANGE;
+    return revertType === RevertType.REVERT_SINGLE_CHANGE;
   }
 
   _computeIfRevertSubmission(revertType: number) {
-    return revertType === REVERT_TYPES.REVERT_SUBMISSION;
+    return revertType === RevertType.REVERT_SUBMISSION;
   }
 
   _modifyRevertMsg(change: ChangeInfo, commitMessage: string, message: string) {
@@ -119,13 +125,7 @@
     const originalTitle = (commitMessage || '').split('\n')[0];
     const revertTitle = `Revert "${originalTitle}"`;
     if (!commitHash) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: ERR_COMMIT_NOT_FOUND},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, ERR_COMMIT_NOT_FOUND);
       return;
     }
     const revertCommitText = `This reverts commit ${commitHash}.`;
@@ -135,7 +135,7 @@
       'Reason for revert: <INSERT REASONING HERE>\n';
     // This is to give plugins a chance to update message
     this._message = this._modifyRevertMsg(change, commitMessage, message);
-    this._revertType = REVERT_TYPES.REVERT_SINGLE_CHANGE;
+    this._revertType = RevertType.REVERT_SINGLE_CHANGE;
     this._showRevertSubmission = false;
     this._revertMessages[this._revertType] = this._message;
     this._originalRevertMessages[this._revertType] = this._message;
@@ -163,13 +163,7 @@
     // Follow the same convention of the revert
     const commitHash = change.current_revision;
     if (!commitHash) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: ERR_COMMIT_NOT_FOUND},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, ERR_COMMIT_NOT_FOUND);
       return;
     }
     if (!changes || changes.length <= 1) return;
@@ -190,7 +184,7 @@
       message,
       commitMessage
     );
-    this._revertType = REVERT_TYPES.REVERT_SUBMISSION;
+    this._revertType = RevertType.REVERT_SUBMISSION;
     this._revertMessages[this._revertType] = this._message;
     this._originalRevertMessages[this._revertType] = this._message;
     this._showRevertSubmission = true;
@@ -199,17 +193,17 @@
   _handleRevertSingleChangeClicked() {
     this._showErrorMessage = false;
     if (this._message)
-      this._revertMessages[REVERT_TYPES.REVERT_SUBMISSION] = this._message;
-    this._message = this._revertMessages[REVERT_TYPES.REVERT_SINGLE_CHANGE];
-    this._revertType = REVERT_TYPES.REVERT_SINGLE_CHANGE;
+      this._revertMessages[RevertType.REVERT_SUBMISSION] = this._message;
+    this._message = this._revertMessages[RevertType.REVERT_SINGLE_CHANGE];
+    this._revertType = RevertType.REVERT_SINGLE_CHANGE;
   }
 
   _handleRevertSubmissionClicked() {
     this._showErrorMessage = false;
-    this._revertType = REVERT_TYPES.REVERT_SUBMISSION;
+    this._revertType = RevertType.REVERT_SUBMISSION;
     if (this._message)
-      this._revertMessages[REVERT_TYPES.REVERT_SINGLE_CHANGE] = this._message;
-    this._message = this._revertMessages[REVERT_TYPES.REVERT_SUBMISSION];
+      this._revertMessages[RevertType.REVERT_SINGLE_CHANGE] = this._message;
+    this._message = this._revertMessages[RevertType.REVERT_SUBMISSION];
   }
 
   _handleConfirmTap(e: MouseEvent) {
@@ -219,9 +213,13 @@
       this._showErrorMessage = true;
       return;
     }
+    const detail: ConfirmRevertEventDetail = {
+      revertType: this._revertType,
+      message: this._message,
+    };
     this.dispatchEvent(
       new CustomEvent('confirm', {
-        detail: {revertType: this._revertType, message: this._message},
+        detail,
         composed: true,
         bubbles: false,
       })
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts
index 9754e89..ac52664 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts
@@ -24,6 +24,7 @@
 import {customElement, property} from '@polymer/decorators';
 import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
 import {ChangeInfo} from '../../../types/common';
+import {fireAlert} from '../../../utils/event-util';
 
 const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
 const CHANGE_SUBJECT_LIMIT = 50;
@@ -86,13 +87,7 @@
     // Follow the same convention of the revert
     const commitHash = change.current_revision;
     if (!commitHash) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: ERR_COMMIT_NOT_FOUND},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, ERR_COMMIT_NOT_FOUND);
       return;
     }
     const revertTitle = `Revert submission ${change.submission_id}`;
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
index eb8d4cb..27d9756 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
@@ -188,7 +188,7 @@
       if (patchNumEquals(rev._number, patchNum)) {
         const parentLength =
           rev.commit && rev.commit.parents ? rev.commit.parents.length : 0;
-        return parentLength === 0;
+        return parentLength === 0 || parentLength > 1;
       }
     }
     return false;
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.js
index 213c202..7401026 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.js
@@ -174,21 +174,33 @@
   test('_computeHidePatchFile', () => {
     const patchNum = '1';
 
-    const change1 = {
+    const changeWithNoParent = {
       revisions: {
         r1: {_number: 1, commit: {parents: []}},
       },
     };
-    assert.isTrue(element._computeHidePatchFile(change1, patchNum));
+    assert.isTrue(element._computeHidePatchFile(changeWithNoParent, patchNum));
 
-    const change2 = {
+    const changeWithOneParent = {
       revisions: {
         r1: {_number: 1, commit: {parents: [
           {commit: 'p1'},
         ]}},
       },
     };
-    assert.isFalse(element._computeHidePatchFile(change2, patchNum));
+    assert.isFalse(
+        element._computeHidePatchFile(changeWithOneParent, patchNum));
+
+    const changeWithMultipleParents = {
+      revisions: {
+        r1: {_number: 1, commit: {parents: [
+          {commit: 'p1'},
+          {commit: 'p2'},
+        ]}},
+      },
+    };
+    assert.isTrue(
+        element._computeHidePatchFile(changeWithMultipleParents, patchNum));
   });
 });
 
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
index e37dc6b..c9a7058 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -46,10 +46,10 @@
   PatchSetNum,
   CommitInfo,
   ServerInfo,
-  DiffPreferencesInfo,
   RevisionInfo,
   NumericChangeId,
 } from '../../../types/common';
+import {DiffPreferencesInfo} from '../../../types/diff';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {GrDiffModeSelector} from '../../diff/gr-diff-mode-selector/gr-diff-mode-selector';
 import {DiffViewMode} from '../../../constants/constants';
@@ -171,13 +171,12 @@
 
   @computed('loggedIn', 'change', 'account')
   get _descriptionReadOnly(): boolean {
-    // Polymer 2: check for undefined
     if (
       this.loggedIn === undefined ||
       this.change === undefined ||
       this.account === undefined
     ) {
-      return false;
+      return true;
     }
 
     return !(
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
index d691e87..3469b3a 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
@@ -19,8 +19,8 @@
 import './gr-file-list-header.js';
 import {FilesExpandedState} from '../gr-file-list-constants.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {generateChange} from '../../../test/test-utils.js';
 import 'lodash/lodash.js';
+import {createRevisions} from '../../../test/test-data-generators.js';
 
 const basicFixture = fixtureFromElement('gr-file-list-header');
 
@@ -269,7 +269,7 @@
 
     test('patch specific elements', () => {
       element.editMode = true;
-      element.allPatchSets = generateChange({revisionsCount: 2}).revisions;
+      element.allPatchSets = createRevisions(2);
       flush();
 
       assert.isFalse(isVisible(element.$.diffPrefsContainer));
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
deleted file mode 100644
index bf45eb6..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ /dev/null
@@ -1,1614 +0,0 @@
-/**
- * @license
- * 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.
- */
-import '../../../styles/shared-styles.js';
-import '../../diff/gr-diff-cursor/gr-diff-cursor.js';
-import '../../diff/gr-diff-host/gr-diff-host.js';
-import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js';
-import '../../edit/gr-edit-file-controls/gr-edit-file-controls.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-cursor-manager/gr-cursor-manager.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-linked-text/gr-linked-text.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-select/gr-select.js';
-import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
-import '../../shared/gr-copy-clipboard/gr-copy-clipboard.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-file-list_html.js';
-import {asyncForeach} from '../../../utils/async-util.js';
-import {KeyboardShortcutMixin, Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {FilesExpandedState} from '../gr-file-list-constants.js';
-import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {appContext} from '../../../services/app-context.js';
-import {SpecialFilePath} from '../../../constants/constants.js';
-import {descendedFromClass} from '../../../utils/dom-util.js';
-import {
-  addUnmodifiedFiles,
-  computeDisplayPath,
-  computeTruncatedPath,
-  isMagicPath,
-  specialFilePathCompare,
-} from '../../../utils/path-list-util.js';
-
-const WARN_SHOW_ALL_THRESHOLD = 1000;
-const LOADING_DEBOUNCE_INTERVAL = 100;
-
-const SIZE_BAR_MAX_WIDTH = 61;
-const SIZE_BAR_GAP_WIDTH = 1;
-const SIZE_BAR_MIN_WIDTH = 1.5;
-
-const RENDER_TIMING_LABEL = 'FileListRenderTime';
-const RENDER_AVG_TIMING_LABEL = 'FileListRenderTimePerFile';
-const EXPAND_ALL_TIMING_LABEL = 'ExpandAllDiffs';
-const EXPAND_ALL_AVG_TIMING_LABEL = 'ExpandAllPerDiff';
-
-const FileStatus = {
-  A: 'Added',
-  C: 'Copied',
-  D: 'Deleted',
-  M: 'Modified',
-  R: 'Renamed',
-  W: 'Rewritten',
-  U: 'Unchanged',
-};
-
-const FILE_ROW_CLASS = 'file-row';
-
-/**
- * Type for FileInfo
- *
- * This should match with the type returned from `files` API plus
- * additional info like `__path`.
- *
- * @typedef {Object} FileInfo
- * @property {string} __path
- * @property {?string} old_path
- * @property {number} size
- * @property {number} size_delta - fallback to 0 if not present in api
- * @property {number} lines_deleted - fallback to 0 if not present in api
- * @property {number} lines_inserted - fallback to 0 if not present in api
- */
-
-/**
- * @extends PolymerElement
- */
-class GrFileList extends KeyboardShortcutMixin(
-    GestureEventListeners(
-        LegacyElementMixin(PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-file-list'; }
-  /**
-   * Fired when a draft refresh should get triggered
-   *
-   * @event reload-drafts
-   */
-
-  static get properties() {
-    return {
-    /** @type {?} */
-      patchRange: Object,
-      patchNum: String,
-      changeNum: String,
-      /** @type {?} */
-      changeComments: Object,
-      drafts: Object,
-      revisions: Array,
-      projectConfig: Object,
-      selectedIndex: {
-        type: Number,
-        notify: true,
-      },
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-      /** @type {?} */
-      change: Object,
-      diffViewMode: {
-        type: String,
-        notify: true,
-        observer: '_updateDiffPreferences',
-      },
-      editMode: {
-        type: Boolean,
-        observer: '_editModeChanged',
-      },
-      filesExpanded: {
-        type: String,
-        value: FilesExpandedState.NONE,
-        notify: true,
-      },
-      _filesByPath: Object,
-
-      /** @type {!Array<FileInfo>} */
-      _files: {
-        type: Array,
-        observer: '_filesChanged',
-        value() { return []; },
-      },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-      },
-      _reviewed: {
-        type: Array,
-        value() { return []; },
-      },
-      diffPrefs: {
-        type: Object,
-        notify: true,
-        observer: '_updateDiffPreferences',
-      },
-      /** @type {?} */
-      _userPrefs: Object,
-      _showInlineDiffs: Boolean,
-      numFilesShown: {
-        type: Number,
-        notify: true,
-      },
-      /** @type {?} */
-      _patchChange: {
-        type: Object,
-        computed: '_calculatePatchChange(_files)',
-      },
-      fileListIncrement: Number,
-      _hideChangeTotals: {
-        type: Boolean,
-        computed: '_shouldHideChangeTotals(_patchChange)',
-      },
-      _hideBinaryChangeTotals: {
-        type: Boolean,
-        computed: '_shouldHideBinaryChangeTotals(_patchChange)',
-      },
-
-      _shownFiles: {
-        type: Array,
-        computed: '_computeFilesShown(numFilesShown, _files)',
-      },
-
-      /**
-       * The amount of files added to the shown files list the last time it was
-       * updated. This is used for reporting the average render time.
-       */
-      _reportinShownFilesIncrement: Number,
-
-      /** @type {!Array<Gerrit.FileRange>} */
-      _expandedFiles: {
-        type: Array,
-        value() { return []; },
-      },
-      _displayLine: Boolean,
-      _loading: {
-        type: Boolean,
-        observer: '_loadingChanged',
-      },
-      /** @type {Gerrit.LayoutStats|undefined} */
-      _sizeBarLayout: {
-        type: Object,
-        computed: '_computeSizeBarLayout(_shownFiles.*)',
-      },
-
-      _showSizeBars: {
-        type: Boolean,
-        value: true,
-        computed: '_computeShowSizeBars(_userPrefs)',
-      },
-
-      /** @type {Function} */
-      _cancelForEachDiff: Function,
-
-      _showDynamicColumns: {
-        type: Boolean,
-        computed: '_computeShowDynamicColumns(_dynamicHeaderEndpoints, ' +
-                '_dynamicContentEndpoints, _dynamicSummaryEndpoints)',
-      },
-      _showPrependedDynamicColumns: {
-        type: Boolean,
-        computed: '_computeShowPrependedDynamicColumns(' +
-        '_dynamicPrependedHeaderEndpoints, _dynamicPrependedContentEndpoints)',
-      },
-      /** @type {Array<string>} */
-      _dynamicHeaderEndpoints: {
-        type: Array,
-      },
-      /** @type {Array<string>} */
-      _dynamicContentEndpoints: {
-        type: Array,
-      },
-      /** @type {Array<string>} */
-      _dynamicSummaryEndpoints: {
-        type: Array,
-      },
-      /** @type {Array<string>} */
-      _dynamicPrependedHeaderEndpoints: {
-        type: Array,
-      },
-      /** @type {Array<string>} */
-      _dynamicPrependedContentEndpoints: {
-        type: Array,
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_expandedFilesChanged(_expandedFiles.splices)',
-      '_computeFiles(_filesByPath, changeComments, patchRange, _reviewed, ' +
-        '_loading)',
-    ];
-  }
-
-  get keyBindings() {
-    return {
-      esc: '_handleEscKey',
-    };
-  }
-
-  keyboardShortcuts() {
-    return {
-      [Shortcut.LEFT_PANE]: '_handleLeftPane',
-      [Shortcut.RIGHT_PANE]: '_handleRightPane',
-      [Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff',
-      [Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs',
-      [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
-        '_handleToggleHideAllCommentThreads',
-      [Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext',
-      [Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev',
-      [Shortcut.NEXT_LINE]: '_handleCursorNext',
-      [Shortcut.PREV_LINE]: '_handleCursorPrev',
-      [Shortcut.NEW_COMMENT]: '_handleNewComment',
-      [Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile',
-      [Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile',
-      [Shortcut.OPEN_FILE]: '_handleOpenFile',
-      [Shortcut.NEXT_CHUNK]: '_handleNextChunk',
-      [Shortcut.PREV_CHUNK]: '_handlePrevChunk',
-      [Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
-      [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
-
-      // Final two are actually handled by gr-comment-thread.
-      [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
-      [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
-    };
-  }
-
-  constructor() {
-    super();
-    this.reporting = appContext.reportingService;
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('keydown',
-        e => this._scopedKeydownHandler(e));
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    getPluginLoader().awaitPluginsLoaded()
-        .then(() => {
-          this._dynamicHeaderEndpoints = getPluginEndpoints()
-              .getDynamicEndpoints('change-view-file-list-header');
-          this._dynamicContentEndpoints = getPluginEndpoints()
-              .getDynamicEndpoints('change-view-file-list-content');
-          this._dynamicPrependedHeaderEndpoints = getPluginEndpoints()
-              .getDynamicEndpoints('change-view-file-list-header-prepend');
-          this._dynamicPrependedContentEndpoints = getPluginEndpoints()
-              .getDynamicEndpoints('change-view-file-list-content-prepend');
-          this._dynamicSummaryEndpoints = getPluginEndpoints()
-              .getDynamicEndpoints('change-view-file-list-summary');
-
-          if (this._dynamicHeaderEndpoints.length !==
-          this._dynamicContentEndpoints.length) {
-            console.warn(
-                'Different number of dynamic file-list header and content.');
-          }
-          if (this._dynamicPrependedHeaderEndpoints.length !==
-        this._dynamicPrependedContentEndpoints.length) {
-            console.warn(
-                'Different number of dynamic file-list header and content.');
-          }
-          if (this._dynamicHeaderEndpoints.length !==
-          this._dynamicSummaryEndpoints.length) {
-            console.warn(
-                'Different number of dynamic file-list headers and summary.');
-          }
-        });
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-    this._cancelDiffs();
-  }
-
-  /**
-   * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
-   * events must be scoped to a component level (e.g. `enter`) in order to not
-   * override native browser functionality.
-   *
-   * Context: Issue 7277
-   */
-  _scopedKeydownHandler(e) {
-    if (e.keyCode === 13) {
-      // Enter.
-      this._handleOpenFile(e);
-    }
-  }
-
-  reload() {
-    if (!this.changeNum || !this.patchRange.patchNum) {
-      return Promise.resolve();
-    }
-
-    this._loading = true;
-
-    this.collapseAllDiffs();
-    const promises = [];
-
-    promises.push(this._getFiles().then(filesByPath => {
-      this._filesByPath = filesByPath;
-    }));
-    promises.push(this._getLoggedIn()
-        .then(loggedIn => this._loggedIn = loggedIn)
-        .then(loggedIn => {
-          if (!loggedIn) { return; }
-
-          return this._getReviewedFiles().then(reviewed => {
-            this._reviewed = reviewed;
-          });
-        }));
-
-    promises.push(this._getDiffPreferences().then(prefs => {
-      this.diffPrefs = prefs;
-    }));
-
-    promises.push(this._getPreferences().then(prefs => {
-      this._userPrefs = prefs;
-    }));
-
-    return Promise.all(promises).then(() => {
-      this._loading = false;
-      this._detectChromiteButler();
-      this.reporting.fileListDisplayed();
-    });
-  }
-
-  _detectChromiteButler() {
-    const hasButler = !!document.getElementById('butler-suggested-owners');
-    if (hasButler) {
-      this.reporting.reportExtension('butler');
-    }
-  }
-
-  get diffs() {
-    const diffs = this.root.querySelectorAll('gr-diff-host');
-    // It is possible that a bogus diff element is hanging around invisibly
-    // from earlier with a different patch set choice and associated with a
-    // different entry in the files array. So filter on visible items only.
-    return Array.from(diffs).filter(
-        el => !!el && !!el.style && el.style.display !== 'none');
-  }
-
-  openDiffPrefs() {
-    this.$.diffPreferencesDialog.open();
-  }
-
-  _calculatePatchChange(files) {
-    const magicFilesExcluded = files.filter(files =>
-      !isMagicPath(files.__path)
-    );
-
-    return magicFilesExcluded.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;
-      const size_delta_deleted =
-          obj.binary && obj.size_delta < 0 ? obj.size_delta : 0;
-
-      return {
-        inserted: acc.inserted + inserted,
-        deleted: acc.deleted + deleted,
-        size_delta_inserted: acc.size_delta_inserted + size_delta_inserted,
-        size_delta_deleted: acc.size_delta_deleted + size_delta_deleted,
-        total_size: acc.total_size + total_size,
-      };
-    }, {inserted: 0, deleted: 0, size_delta_inserted: 0,
-      size_delta_deleted: 0, total_size: 0});
-  }
-
-  _getDiffPreferences() {
-    return this.$.restAPI.getDiffPreferences();
-  }
-
-  _getPreferences() {
-    return this.$.restAPI.getPreferences();
-  }
-
-  _toggleFileExpanded(file) {
-    // Is the path in the list of expanded diffs? IF so remove it, otherwise
-    // add it to the list.
-    const pathIndex = this._expandedFiles.findIndex(f => f.path === file.path);
-    if (pathIndex === -1) {
-      this.push('_expandedFiles', file);
-    } else {
-      this.splice('_expandedFiles', pathIndex, 1);
-    }
-  }
-
-  _toggleFileExpandedByIndex(index) {
-    this._toggleFileExpanded(this._computeFileRange(this._files[index]));
-  }
-
-  _updateDiffPreferences() {
-    if (!this.diffs.length) { return; }
-    // Re-render all expanded diffs sequentially.
-    this.reporting.time(EXPAND_ALL_TIMING_LABEL);
-    this._renderInOrder(this._expandedFiles, this.diffs,
-        this._expandedFiles.length);
-  }
-
-  _forEachDiff(fn) {
-    const diffs = this.diffs;
-    for (let i = 0; i < diffs.length; i++) {
-      fn(diffs[i]);
-    }
-  }
-
-  expandAllDiffs() {
-    this._showInlineDiffs = true;
-
-    // Find the list of paths that are in the file list, but not in the
-    // expanded list.
-    const newFiles = [];
-    let path;
-    for (let i = 0; i < this._shownFiles.length; i++) {
-      path = this._shownFiles[i].__path;
-      if (!this._expandedFiles.some(f => f.path === path)) {
-        newFiles.push(this._computeFileRange(this._shownFiles[i]));
-      }
-    }
-
-    this.splice(...['_expandedFiles', 0, 0].concat(newFiles));
-  }
-
-  collapseAllDiffs() {
-    this._showInlineDiffs = false;
-    this._expandedFiles = [];
-    this.filesExpanded = this._computeExpandedFiles(
-        this._expandedFiles.length, this._files.length);
-    this.$.diffCursor.handleDiffUpdate();
-  }
-
-  /**
-   * Computes a string with the number of comments and unresolved comments.
-   *
-   * @param {!Object} changeComments
-   * @param {!Object} patchRange
-   * @param {string} path
-   * @return {string}
-   */
-  _computeCommentsString(changeComments, patchRange, path) {
-    if ([changeComments, patchRange, path].includes(undefined)) {
-      return '';
-    }
-    const unresolvedCount =
-        changeComments.computeUnresolvedNum({
-          patchNum: patchRange.basePatchNum,
-          path,
-        }) +
-        changeComments.computeUnresolvedNum({
-          patchNum: patchRange.patchNum,
-          path,
-        });
-    const commentCount =
-        changeComments.computeCommentCount({
-          patchNum: patchRange.basePatchNum,
-          path,
-        }) +
-        changeComments.computeCommentCount({
-          patchNum: patchRange.patchNum,
-          path,
-        });
-    const commentString = GrCountStringFormatter.computePluralString(
-        commentCount, 'comment');
-    const unresolvedString = GrCountStringFormatter.computeString(
-        unresolvedCount, 'unresolved');
-
-    return commentString +
-        // Add a space if both comments and unresolved
-        (commentString && unresolvedString ? ' ' : '') +
-        // Add parentheses around unresolved if it exists.
-        (unresolvedString ? `(${unresolvedString})` : '');
-  }
-
-  /**
-   * Computes a string with the number of drafts.
-   *
-   * @param {!Object} changeComments
-   * @param {!Object} patchRange
-   * @param {string} path
-   * @return {string}
-   */
-  _computeDraftsString(changeComments, patchRange, path) {
-    if ([changeComments, patchRange, path].includes(undefined)) {
-      return '';
-    }
-    const draftCount =
-        changeComments.computeDraftCount({
-          patchNum: patchRange.basePatchNum,
-          path,
-        }) +
-        changeComments.computeDraftCount({
-          patchNum: patchRange.patchNum,
-          path,
-        });
-    return GrCountStringFormatter.computePluralString(draftCount, 'draft');
-  }
-
-  /**
-   * Computes a shortened string with the number of drafts.
-   *
-   * @param {!Object} changeComments
-   * @param {!Object} patchRange
-   * @param {string} path
-   * @return {string}
-   */
-  _computeDraftsStringMobile(changeComments, patchRange, path) {
-    if ([changeComments, patchRange, path].includes(undefined)) {
-      return '';
-    }
-    const draftCount =
-        changeComments.computeDraftCount({
-          patchNum: patchRange.basePatchNum,
-          path,
-        }) +
-        changeComments.computeDraftCount({
-          patchNum: patchRange.patchNum,
-          path,
-        });
-    return GrCountStringFormatter.computeShortString(draftCount, 'd');
-  }
-
-  /**
-   * Computes a shortened string with the number of comments.
-   *
-   * @param {!Object} changeComments
-   * @param {!Object} patchRange
-   * @param {string} path
-   * @return {string}
-   */
-  _computeCommentsStringMobile(changeComments, patchRange, path) {
-    if ([changeComments, patchRange, path].includes(undefined)) {
-      return '';
-    }
-    const commentCount =
-        changeComments.computeCommentCount({
-          patchNum: patchRange.basePatchNum,
-          path,
-        }) +
-        changeComments.computeCommentCount({
-          patchNum: patchRange.patchNum,
-          path,
-        });
-    return GrCountStringFormatter.computeShortString(commentCount, 'c');
-  }
-
-  /**
-   * @param {string} path
-   * @param {boolean=} opt_reviewed
-   */
-  _reviewFile(path, opt_reviewed) {
-    if (this.editMode) { return; }
-    const index = this._files.findIndex(file => file.__path === path);
-    const reviewed = opt_reviewed || !this._files[index].isReviewed;
-
-    this.set(['_files', index, 'isReviewed'], reviewed);
-    if (index < this._shownFiles.length) {
-      this.notifyPath(`_shownFiles.${index}.isReviewed`);
-    }
-
-    this._saveReviewedState(path, reviewed);
-  }
-
-  _saveReviewedState(path, reviewed) {
-    return this.$.restAPI.saveFileReviewed(this.changeNum,
-        this.patchRange.patchNum, path, reviewed);
-  }
-
-  _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
-  }
-
-  _getReviewedFiles() {
-    if (this.editMode) { return Promise.resolve([]); }
-    return this.$.restAPI.getReviewedFiles(this.changeNum,
-        this.patchRange.patchNum);
-  }
-
-  _getFiles() {
-    return this.$.restAPI.getChangeOrEditFiles(
-        this.changeNum, this.patchRange);
-  }
-
-  /**
-   *
-   * @returns {!Array<FileInfo>}
-   */
-  _normalizeChangeFilesResponse(response) {
-    if (!response) { return []; }
-    const paths = Object.keys(response).sort(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;
-      info.size_delta = info.size_delta || 0;
-      files.push(info);
-    }
-    return files;
-  }
-
-  /**
-   * Returns true if the event e is a click on an element.
-   *
-   * The click is: mouse click or pressing Enter or Space key
-   * P.S> Screen readers sends click event as well
-   */
-  _isClickEvent(e) {
-    if (e.type === 'click') {
-      return true;
-    }
-    const isSpaceOrEnter = (e.key === 'Enter' || e.key === ' ');
-    return e.type === 'keydown' && isSpaceOrEnter;
-  }
-
-  _fileActionClick(e, fileAction) {
-    if (this._isClickEvent(e)) {
-      const fileRow = this._getFileRowFromEvent(e);
-      if (!fileRow) {
-        return;
-      }
-      // Prevent default actions (e.g. scrolling for space key)
-      e.preventDefault();
-      // Prevent _handleFileListClick handler call
-      e.stopPropagation();
-      this.$.fileCursor.setCursor(fileRow.element);
-      fileAction(fileRow.file);
-    }
-  }
-
-  _reviewedClick(e) {
-    this._fileActionClick(e,
-        file => this._reviewFile(file.path));
-  }
-
-  _expandedClick(e) {
-    this._fileActionClick(e,
-        file => this._toggleFileExpanded(file));
-  }
-
-  /**
-   * Handle all events from the file list dom-repeat so event handleers don't
-   * have to get registered for potentially very long lists.
-   */
-  _handleFileListClick(e) {
-    const fileRow = this._getFileRowFromEvent(e);
-    if (!fileRow) {
-      return;
-    }
-    const file = fileRow.file;
-    const path = file.path;
-    // If a path cannot be interpreted from the click target (meaning it's not
-    // somewhere in the row, e.g. diff content) or if the user clicked the
-    // link, defer to the native behavior.
-    if (!path || descendedFromClass(e.target, 'pathLink')) { return; }
-
-    // Disregard the event if the click target is in the edit controls.
-    if (descendedFromClass(e.target, 'editFileControls')) { return; }
-
-    e.preventDefault();
-    this.$.fileCursor.setCursor(fileRow.element);
-    this._toggleFileExpanded(file);
-  }
-
-  _getFileRowFromEvent(e) {
-    // Traverse upwards to find the row element if the target is not the row.
-    let row = e.target;
-    while (!row.classList.contains(FILE_ROW_CLASS) && row.parentElement) {
-      row = row.parentElement;
-    }
-
-    // No action needed for item without a valid file
-    if (!row.dataset['file']) {
-      return null;
-    }
-
-    return {
-      file: JSON.parse(row.dataset['file']),
-      element: row,
-    };
-  }
-
-  /**
-   * Generates file range from file info object.
-   *
-   * @param {FileInfo} file
-   * @returns {Gerrit.FileRange}
-   */
-  _computeFileRange(file) {
-    const fileData = {
-      path: file.__path,
-    };
-    if (file.old_path) {
-      fileData.basePath = file.old_path;
-    }
-    return fileData;
-  }
-
-  _handleLeftPane(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
-      return;
-    }
-
-    e.preventDefault();
-    this.$.diffCursor.moveLeft();
-  }
-
-  _handleRightPane(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
-      return;
-    }
-
-    e.preventDefault();
-    this.$.diffCursor.moveRight();
-  }
-
-  _handleToggleInlineDiff(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e) ||
-        this.$.fileCursor.index === -1) { return; }
-
-    e.preventDefault();
-    this._toggleFileExpandedByIndex(this.$.fileCursor.index);
-  }
-
-  _handleToggleAllInlineDiffs(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-    e.preventDefault();
-    this._toggleInlineDiffs();
-  }
-
-  _handleToggleHideAllCommentThreads(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
-    this.toggleClass('hideComments');
-  }
-
-  _handleCursorNext(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-
-    if (this._showInlineDiffs) {
-      e.preventDefault();
-      this.$.diffCursor.moveDown();
-      this._displayLine = true;
-    } else {
-      // Down key
-      if (this.getKeyboardEvent(e).keyCode === 40) { return; }
-      e.preventDefault();
-      this.$.fileCursor.next();
-      this.selectedIndex = this.$.fileCursor.index;
-    }
-  }
-
-  _handleCursorPrev(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-
-    if (this._showInlineDiffs) {
-      e.preventDefault();
-      this.$.diffCursor.moveUp();
-      this._displayLine = true;
-    } else {
-      // Up key
-      if (this.getKeyboardEvent(e).keyCode === 38) { return; }
-      e.preventDefault();
-      this.$.fileCursor.previous();
-      this.selectedIndex = this.$.fileCursor.index;
-    }
-  }
-
-  _handleNewComment(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-    e.preventDefault();
-    this.$.diffCursor.createCommentInPlace();
-  }
-
-  _handleOpenLastFile(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);
-  }
-
-  _handleOpenFirstFile(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);
-  }
-
-  _handleOpenFile(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-    e.preventDefault();
-
-    if (this._showInlineDiffs) {
-      this._openCursorFile();
-      return;
-    }
-
-    this._openSelectedFile();
-  }
-
-  _handleNextChunk(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
-        this._noDiffsExpanded()) {
-      return;
-    }
-
-    e.preventDefault();
-    if (this.isModifierPressed(e, 'shiftKey')) {
-      this.$.diffCursor.moveToNextCommentThread();
-    } else {
-      this.$.diffCursor.moveToNextChunk();
-    }
-  }
-
-  _handlePrevChunk(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
-        this._noDiffsExpanded()) {
-      return;
-    }
-
-    e.preventDefault();
-    if (this.isModifierPressed(e, 'shiftKey')) {
-      this.$.diffCursor.moveToPreviousCommentThread();
-    } else {
-      this.$.diffCursor.moveToPreviousChunk();
-    }
-  }
-
-  _handleToggleFileReviewed(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
-    if (!this._files[this.$.fileCursor.index]) { return; }
-    this._reviewFile(this._files[this.$.fileCursor.index].__path);
-  }
-
-  _handleToggleLeftPane(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-    e.preventDefault();
-    this._forEachDiff(diff => {
-      diff.toggleLeftDiff();
-    });
-  }
-
-  _toggleInlineDiffs() {
-    if (this._showInlineDiffs) {
-      this.collapseAllDiffs();
-    } else {
-      this.expandAllDiffs();
-    }
-  }
-
-  _openCursorFile() {
-    const diff = this.$.diffCursor.getTargetDiffElement();
-    GerritNav.navigateToDiff(this.change, diff.path,
-        diff.patchRange.patchNum, this.patchRange.basePatchNum);
-  }
-
-  /**
-   * @param {number=} opt_index
-   */
-  _openSelectedFile(opt_index) {
-    if (opt_index != null) {
-      this.$.fileCursor.setCursorAtIndex(opt_index);
-    }
-    if (!this._files[this.$.fileCursor.index]) { return; }
-    GerritNav.navigateToDiff(this.change,
-        this._files[this.$.fileCursor.index].__path, this.patchRange.patchNum,
-        this.patchRange.basePatchNum);
-  }
-
-  _addDraftAtTarget() {
-    const diff = this.$.diffCursor.getTargetDiffElement();
-    const target = this.$.diffCursor.getTargetLineElement();
-    if (diff && target) {
-      diff.addDraftAtLine(target);
-    }
-  }
-
-  _shouldHideChangeTotals(_patchChange) {
-    return _patchChange.inserted === 0 && _patchChange.deleted === 0;
-  }
-
-  _shouldHideBinaryChangeTotals(_patchChange) {
-    return _patchChange.size_delta_inserted === 0 &&
-        _patchChange.size_delta_deleted === 0;
-  }
-
-  _computeFileStatus(status) {
-    return status || 'M';
-  }
-
-  _computeDiffURL(change, patchRange, path, editMode) {
-    // Polymer 2: check for undefined
-    if ([change, patchRange, path, editMode]
-        .some(arg => arg === undefined)) {
-      return;
-    }
-    if (editMode && path !== SpecialFilePath.MERGE_LIST) {
-      return GerritNav.getEditUrlForDiff(change, path, patchRange.patchNum,
-          patchRange.basePatchNum);
-    }
-    return GerritNav.getUrlForDiff(change, path, patchRange.patchNum,
-        patchRange.basePatchNum);
-  }
-
-  _formatBytes(bytes) {
-    if (bytes == 0) return '+/-0 B';
-    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(size, delta) {
-    const oldSize = size - delta;
-
-    if (oldSize === 0) { return ''; }
-
-    const percentage = Math.round(Math.abs(delta * 100 / oldSize));
-    return '(' + (delta > 0 ? '+' : '-') + percentage + '%)';
-  }
-
-  _computeBinaryClass(delta) {
-    if (delta === 0) { return; }
-    return delta >= 0 ? 'added' : 'removed';
-  }
-
-  /**
-   * @param {string} baseClass
-   * @param {string} path
-   */
-  _computeClass(baseClass, path) {
-    const classes = [];
-    if (baseClass) {
-      classes.push(baseClass);
-    }
-    if (path === SpecialFilePath.COMMIT_MESSAGE ||
-      path === SpecialFilePath.MERGE_LIST) {
-      classes.push('invisible');
-    }
-    return classes.join(' ');
-  }
-
-  _computeStatusClass(file) {
-    const classStr = this._computeClass('status', file.__path);
-    return `${classStr} ${this._computeFileStatus(file.status)}`;
-  }
-
-  _computePathClass(path, expandedFilesRecord) {
-    return this._isFileExpanded(path, expandedFilesRecord) ? 'expanded' : '';
-  }
-
-  _computeShowHideIcon(path, expandedFilesRecord) {
-    return this._isFileExpanded(path, expandedFilesRecord) ?
-      'gr-icons:expand-less' : 'gr-icons:expand-more';
-  }
-
-  _computeFiles(filesByPath, changeComments, patchRange, reviewed, loading) {
-    // Polymer 2: check for undefined
-    if ([
-      filesByPath,
-      changeComments,
-      patchRange,
-      reviewed,
-      loading,
-    ].includes(undefined)) {
-      return;
-    }
-
-    // Await all promises resolving from reload. @See Issue 9057
-    if (loading || !changeComments) { return; }
-
-    const commentedPaths = changeComments.getPaths(patchRange);
-    const files = {...filesByPath};
-    addUnmodifiedFiles(files, commentedPaths);
-    const reviewedSet = new Set(reviewed || []);
-    for (const filePath in files) {
-      if (!files.hasOwnProperty(filePath)) { continue; }
-      files[filePath].isReviewed = reviewedSet.has(filePath);
-    }
-
-    this._files = this._normalizeChangeFilesResponse(files);
-  }
-
-  _computeFilesShown(numFilesShown, files) {
-    // Polymer 2: check for undefined
-    if ([numFilesShown, files].includes(undefined)) {
-      return undefined;
-    }
-
-    const previousNumFilesShown = this._shownFiles ?
-      this._shownFiles.length : 0;
-
-    const filesShown = files.slice(0, numFilesShown);
-    this.dispatchEvent(new CustomEvent('files-shown-changed', {
-      detail: {length: filesShown.length},
-      composed: true, bubbles: true,
-    }));
-
-    // Start the timer for the rendering work hwere because this is where the
-    // _shownFiles property is being set, and _shownFiles is used in the
-    // dom-repeat binding.
-    this.reporting.time(RENDER_TIMING_LABEL);
-
-    // How many more files are being shown (if it's an increase).
-    this._reportinShownFilesIncrement =
-        Math.max(0, filesShown.length - previousNumFilesShown);
-
-    return filesShown;
-  }
-
-  _updateDiffCursor() {
-    // Overwrite the cursor's list of diffs:
-    this.$.diffCursor.splice(
-        ...['diffs', 0, this.$.diffCursor.diffs.length].concat(this.diffs));
-  }
-
-  _filesChanged() {
-    if (this._files && this._files.length > 0) {
-      flush();
-      this.$.fileCursor.stops = Array.from(
-          this.root.querySelectorAll(`.${FILE_ROW_CLASS}`));
-      this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
-    }
-  }
-
-  _incrementNumFilesShown() {
-    this.numFilesShown += this.fileListIncrement;
-  }
-
-  _computeFileListControlClass(numFilesShown, files) {
-    return numFilesShown >= files.length ? 'invisible' : '';
-  }
-
-  _computeIncrementText(numFilesShown, files) {
-    if (!files) { return ''; }
-    const text =
-        Math.min(this.fileListIncrement, files.length - numFilesShown);
-    return 'Show ' + text + ' more';
-  }
-
-  _computeShowAllText(files) {
-    if (!files) { return ''; }
-    return 'Show all ' + files.length + ' files';
-  }
-
-  _computeWarnShowAll(files) {
-    return files.length > WARN_SHOW_ALL_THRESHOLD;
-  }
-
-  _computeShowAllWarning(files) {
-    if (!this._computeWarnShowAll(files)) { return ''; }
-    return 'Warning: showing all ' + files.length +
-        ' files may take several seconds.';
-  }
-
-  _showAllFiles() {
-    this.numFilesShown = this._files.length;
-  }
-
-  /**
-   * Get a descriptive label for use in the status indicator's tooltip and
-   * ARIA label.
-   *
-   * @param {string} status
-   * @return {string}
-   */
-  _computeFileStatusLabel(status) {
-    const statusCode = this._computeFileStatus(status);
-    return FileStatus.hasOwnProperty(statusCode) ?
-      FileStatus[statusCode] : 'Status Unknown';
-  }
-
-  /**
-   * Converts any boolean-like variable to the string 'true' or 'false'
-   *
-   * This method is useful when you bind aria-checked attribute to a boolean
-   * value. The aria-checked attribute is string attribute. Binding directly
-   * to boolean variable causes problem on gerrit-CI.
-   *
-   * @param {object} val
-   * @return {string} 'true' if val is true-like, otherwise false
-   */
-  _booleanToString(val) {
-    return val ? 'true' : 'false';
-  }
-
-  _isFileExpanded(path, expandedFilesRecord) {
-    return expandedFilesRecord.base.some(f => f.path === path);
-  }
-
-  _isFileExpandedStr(path, expandedFilesRecord) {
-    return this._booleanToString(
-        this._isFileExpanded(path, expandedFilesRecord));
-  }
-
-  _computeExpandedFiles(expandedCount, totalCount) {
-    if (expandedCount === 0) {
-      return FilesExpandedState.NONE;
-    } else if (expandedCount === totalCount) {
-      return FilesExpandedState.ALL;
-    }
-    return FilesExpandedState.SOME;
-  }
-
-  /**
-   * Handle splices to the list of expanded file paths. If there are any new
-   * entries in the expanded list, then render each diff corresponding in
-   * order by waiting for the previous diff to finish before starting the next
-   * one.
-   *
-   * @param {!Array} record The splice record in the expanded paths list.
-   */
-  _expandedFilesChanged(record) {
-    // Clear content for any diffs that are not open so if they get re-opened
-    // the stale content does not flash before it is cleared and reloaded.
-    const collapsedDiffs = this.diffs.filter(diff =>
-      this._expandedFiles.findIndex(f => f.path === diff.path) === -1);
-    this._clearCollapsedDiffs(collapsedDiffs);
-
-    if (!record) { return; } // Happens after "Collapse all" clicked.
-
-    this.filesExpanded = this._computeExpandedFiles(
-        this._expandedFiles.length, this._files.length);
-
-    // Find the paths introduced by the new index splices:
-    const newFiles = record.indexSplices
-        .map(splice => splice.object.slice(
-            splice.index, splice.index + splice.addedCount))
-        .reduce((acc, paths) => acc.concat(paths), []);
-
-    // Required so that the newly created diff view is included in this.diffs.
-    flush();
-
-    this.reporting.time(EXPAND_ALL_TIMING_LABEL);
-
-    if (newFiles.length) {
-      this._renderInOrder(newFiles, this.diffs, newFiles.length);
-    }
-
-    this._updateDiffCursor();
-    this.$.diffCursor.reInitAndUpdateStops();
-  }
-
-  _clearCollapsedDiffs(collapsedDiffs) {
-    for (const diff of collapsedDiffs) {
-      diff.cancel();
-      diff.clearDiffContent();
-    }
-  }
-
-  /**
-   * Given an array of paths and a NodeList of diff elements, render the diff
-   * for each path in order, awaiting the previous render to complete before
-   * continuing.
-   *
-   * @param  {!Array<Gerrit.FileRange>} files
-   * @param  {!NodeList<!Object>} diffElements (GrDiffHostElement)
-   * @param  {number} initialCount The total number of paths in the pass. This
-   *   is used to generate log messages.
-   * @return {!Promise}
-   */
-  _renderInOrder(files, diffElements, initialCount) {
-    let iter = 0;
-
-    for (const file of files) {
-      const path = file.path;
-      const diffElem = this._findDiffByPath(path, diffElements);
-      if (diffElem) {
-        diffElem.prefetchDiff();
-      }
-    }
-
-    return (new Promise(resolve => {
-      this.dispatchEvent(new CustomEvent('reload-drafts', {
-        detail: {resolve},
-        composed: true, bubbles: true,
-      }));
-    })).then(() => asyncForeach(files, (file, cancel) => {
-      const path = file.path;
-      this._cancelForEachDiff = cancel;
-
-      iter++;
-      console.info('Expanding diff', iter, 'of', initialCount, ':',
-          path);
-      const diffElem = this._findDiffByPath(path, diffElements);
-      if (!diffElem) {
-        console.warn(`Did not find <gr-diff-host> element for ${path}`);
-        return Promise.resolve();
-      }
-      diffElem.comments = this.changeComments.getCommentsBySideForFile(
-          file, this.patchRange, this.projectConfig);
-      const promises = [diffElem.reload()];
-      if (this._loggedIn && !this.diffPrefs.manual_review) {
-        promises.push(this._reviewFile(path, true));
-      }
-      return Promise.all(promises);
-    }).then(() => {
-      this._cancelForEachDiff = null;
-      this._nextRenderParams = null;
-      console.info('Finished expanding', initialCount, 'diff(s)');
-      this.reporting.timeEndWithAverage(EXPAND_ALL_TIMING_LABEL,
-          EXPAND_ALL_AVG_TIMING_LABEL, initialCount);
-      /* Block diff cursor from auto scrolling after files are done rendering.
-       * This prevents the bug where the screen jumps to the first diff chunk
-       * after files are done being rendered after the user has already begun
-       * scrolling.
-       * This also however results in the fact that the cursor does not auto
-       * focus on the first diff chunk on a small screen. This is however, a use
-       * case we are willing to not support for now.
-
-       * Using handleDiffUpdate resulted in diffCursor.row being set which
-       * prevented the issue of scrolling to top when we expand the second
-       * file individually.
-       */
-      this.$.diffCursor.reInitAndUpdateStops();
-    }));
-  }
-
-  /** Cancel the rendering work of every diff in the list */
-  _cancelDiffs() {
-    if (this._cancelForEachDiff) { this._cancelForEachDiff(); }
-    this._forEachDiff(d => d.cancel());
-  }
-
-  /**
-   * In the given NodeList of diff elements, find the diff for the given path.
-   *
-   * @param  {string} path
-   * @param  {!NodeList<!Object>} diffElements (GrDiffElement)
-   * @return {!Object|undefined} (GrDiffElement)
-   */
-  _findDiffByPath(path, diffElements) {
-    for (let i = 0; i < diffElements.length; i++) {
-      if (diffElements[i].path === path) {
-        return diffElements[i];
-      }
-    }
-  }
-
-  /**
-   * Reset the comments of a modified thread
-   *
-   * @param  {string} rootId
-   * @param  {string} path
-   */
-  reloadCommentsForThreadWithRootId(rootId, path) {
-    // Don't bother continuing if we already know that the path that contains
-    // the updated comment thread is not expanded.
-    if (!this._expandedFiles.some(f => f.path === path)) { return; }
-    const diff = this.diffs.find(d => d.path === path);
-
-    const threadEl = diff.getThreadEls().find(t => t.rootId === rootId);
-    if (!threadEl) { return; }
-
-    const newComments = this.changeComments.getCommentsForThread(rootId);
-
-    // If newComments is null, it means that a single draft was
-    // removed from a thread in the thread view, and the thread should
-    // no longer exist. Remove the existing thread element in the diff
-    // view.
-    if (!newComments) {
-      threadEl.fireRemoveSelf();
-      return;
-    }
-
-    // Comments are not returned with the commentSide attribute from
-    // the api, but it's necessary to be stored on the diff's
-    // comments due to use in the _handleCommentUpdate function.
-    // The comment thread already has a side associated with it, so
-    // set the comment's side to match.
-    threadEl.comments = newComments.map(c => Object.assign(
-        c, {__commentSide: threadEl.commentSide}
-    ));
-    flush();
-  }
-
-  _handleEscKey(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-    e.preventDefault();
-    this._displayLine = false;
-  }
-
-  /**
-   * Update the loading class for the file list rows. The update is inside a
-   * debouncer so that the file list doesn't flash gray when the API requests
-   * are reasonably fast.
-   *
-   * @param {boolean} loading
-   */
-  _loadingChanged(loading) {
-    this.debounce('loading-change', () => {
-      // Only show set the loading if there have been files loaded to show. In
-      // this way, the gray loading style is not shown on initial loads.
-      this.classList.toggle('loading', loading && !!this._files.length);
-    }, LOADING_DEBOUNCE_INTERVAL);
-  }
-
-  _editModeChanged(editMode) {
-    this.classList.toggle('editMode', editMode);
-  }
-
-  _computeReviewedClass(isReviewed) {
-    return isReviewed ? 'isReviewed' : '';
-  }
-
-  _computeReviewedText(isReviewed) {
-    return isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
-  }
-
-  /**
-   * Given a file path, return whether that path should have visible size bars
-   * and be included in the size bars calculation.
-   *
-   * @param {string} path
-   * @return {boolean}
-   */
-  _showBarsForPath(path) {
-    return path !== SpecialFilePath.COMMIT_MESSAGE &&
-      path !== SpecialFilePath.MERGE_LIST;
-  }
-
-  /**
-   * Compute size bar layout values from the file list.
-   *
-   * @return {Gerrit.LayoutStats|undefined}
-   *
-   */
-  _computeSizeBarLayout(shownFilesRecord) {
-    if (!shownFilesRecord || !shownFilesRecord.base) { return undefined; }
-    const stats = {
-      maxInserted: 0,
-      maxDeleted: 0,
-      maxAdditionWidth: 0,
-      maxDeletionWidth: 0,
-      deletionOffset: 0,
-    };
-    shownFilesRecord.base
-        .filter(f => this._showBarsForPath(f.__path))
-        .forEach(f => {
-          if (f.lines_inserted) {
-            stats.maxInserted = Math.max(stats.maxInserted, f.lines_inserted);
-          }
-          if (f.lines_deleted) {
-            stats.maxDeleted = Math.max(stats.maxDeleted, f.lines_deleted);
-          }
-        });
-    const ratio = stats.maxInserted / (stats.maxInserted + stats.maxDeleted);
-    if (!isNaN(ratio)) {
-      stats.maxAdditionWidth =
-          (SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH) * ratio;
-      stats.maxDeletionWidth =
-          SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH - stats.maxAdditionWidth;
-      stats.deletionOffset = stats.maxAdditionWidth + SIZE_BAR_GAP_WIDTH;
-    }
-    return stats;
-  }
-
-  /**
-   * Get the width of the addition bar for a file.
-   *
-   * @param {Object} file
-   * @param {Gerrit.LayoutStats} stats
-   * @return {number}
-   */
-  _computeBarAdditionWidth(file, stats) {
-    if (stats.maxInserted === 0 ||
-        !file.lines_inserted ||
-        !this._showBarsForPath(file.__path)) {
-      return 0;
-    }
-    const width =
-        stats.maxAdditionWidth * file.lines_inserted / stats.maxInserted;
-    return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
-  }
-
-  /**
-   * Get the x-offset of the addition bar for a file.
-   *
-   * @param {Object} file
-   * @param {Gerrit.LayoutStats} stats
-   * @return {number}
-   */
-  _computeBarAdditionX(file, stats) {
-    return stats.maxAdditionWidth -
-        this._computeBarAdditionWidth(file, stats);
-  }
-
-  /**
-   * Get the width of the deletion bar for a file.
-   *
-   * @param {Object} file
-   * @param {Gerrit.LayoutStats} stats
-   * @return {number}
-   */
-  _computeBarDeletionWidth(file, stats) {
-    if (stats.maxDeleted === 0 ||
-        !file.lines_deleted ||
-        !this._showBarsForPath(file.__path)) {
-      return 0;
-    }
-    const width =
-        stats.maxDeletionWidth * file.lines_deleted / stats.maxDeleted;
-    return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
-  }
-
-  /**
-   * Get the x-offset of the deletion bar for a file.
-   *
-   * @param {Gerrit.LayoutStats} stats
-   *
-   * @return {number}
-   */
-  _computeBarDeletionX(stats) {
-    return stats.deletionOffset;
-  }
-
-  _computeShowSizeBars(userPrefs) {
-    return !!userPrefs.size_bar_in_change_table;
-  }
-
-  _computeSizeBarsClass(showSizeBars, path) {
-    let hideClass = '';
-    if (!showSizeBars) {
-      hideClass = 'hide';
-    } else if (!this._showBarsForPath(path)) {
-      hideClass = 'invisible';
-    }
-    return `sizeBars desktop ${hideClass}`;
-  }
-
-  /**
-   * Shows registered dynamic columns iff the 'header', 'content' and
-   * 'summary' endpoints are registered the exact same number of times.
-   * Ideally, there should be a better way to enforce the expectation of the
-   * dependencies between dynamic endpoints.
-   */
-  _computeShowDynamicColumns(
-      headerEndpoints, contentEndpoints, summaryEndpoints) {
-    return headerEndpoints && contentEndpoints && summaryEndpoints &&
-           headerEndpoints.length &&
-           headerEndpoints.length === contentEndpoints.length &&
-           headerEndpoints.length === summaryEndpoints.length;
-  }
-
-  /**
-   * Shows registered dynamic prepended columns iff the 'header', 'content'
-   * endpoints are registered the exact same number of times.
-   */
-  _computeShowPrependedDynamicColumns(
-      headerEndpoints, contentEndpoints) {
-    return headerEndpoints && contentEndpoints &&
-           headerEndpoints.length &&
-           headerEndpoints.length === contentEndpoints.length;
-  }
-
-  /**
-   * Returns true if none of the inline diffs have been expanded.
-   *
-   * @return {boolean}
-   */
-  _noDiffsExpanded() {
-    return this.filesExpanded === FilesExpandedState.NONE;
-  }
-
-  /**
-   * Method to call via binding when each file list row is rendered. This
-   * allows approximate detection of when the dom-repeat has completed
-   * rendering.
-   *
-   * @param {number} index The index of the row being rendered.
-   * @return {string} an empty string.
-   */
-  _reportRenderedRow(index) {
-    if (index === this._shownFiles.length - 1) {
-      this.async(() => {
-        this.reporting.timeEndWithAverage(RENDER_TIMING_LABEL,
-            RENDER_AVG_TIMING_LABEL, this._reportinShownFilesIncrement);
-      }, 1);
-    }
-    return '';
-  }
-
-  _reviewedTitle(reviewed) {
-    if (reviewed) {
-      return 'Mark as not reviewed (shortcut: r)';
-    }
-
-    return 'Mark as reviewed (shortcut: r)';
-  }
-
-  _handleReloadingDiffPreference() {
-    this._getDiffPreferences().then(prefs => {
-      this.diffPrefs = prefs;
-    });
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeDisplayPath(path) {
-    return computeDisplayPath(path);
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeTruncatedPath(path) {
-    return computeTruncatedPath(path);
-  }
-}
-
-customElements.define(GrFileList.is, GrFileList);
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
new file mode 100644
index 0000000..7759b7b
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -0,0 +1,1903 @@
+/**
+ * @license
+ * 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.
+ */
+import '../../../styles/shared-styles';
+import '../../diff/gr-diff-cursor/gr-diff-cursor';
+import '../../diff/gr-diff-host/gr-diff-host';
+import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
+import '../../edit/gr-edit-file-controls/gr-edit-file-controls';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-cursor-manager/gr-cursor-manager';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-linked-text/gr-linked-text';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-file-list_html';
+import {asyncForeach} from '../../../utils/async-util';
+import {
+  KeyboardShortcutMixin,
+  Modifier,
+  Shortcut,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {FilesExpandedState} from '../gr-file-list-constants';
+import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {appContext} from '../../../services/app-context';
+import {DiffViewMode, SpecialFilePath} from '../../../constants/constants';
+import {descendedFromClass} from '../../../utils/dom-util';
+import {
+  addUnmodifiedFiles,
+  computeDisplayPath,
+  computeTruncatedPath,
+  isMagicPath,
+  specialFilePathCompare,
+} from '../../../utils/path-list-util';
+import {customElement, observe, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  ConfigInfo,
+  ElementPropertyDeepChange,
+  FileInfo,
+  FileNameToFileInfoMap,
+  NumericChangeId,
+  PatchRange,
+  PreferencesInfo,
+  RevisionInfo,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import {DiffPreferencesInfo} from '../../../types/diff';
+import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
+import {GrDiffPreferencesDialog} from '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {GrDiffCursor} from '../../diff/gr-diff-cursor/gr-diff-cursor';
+import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
+import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
+import {UIDraft} from '../../../utils/comment-util';
+import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {PatchSetFile} from '../../../types/types';
+import {CustomKeyboardEvent} from '../../../types/events';
+
+export const DEFAULT_NUM_FILES_SHOWN = 200;
+
+const WARN_SHOW_ALL_THRESHOLD = 1000;
+const LOADING_DEBOUNCE_INTERVAL = 100;
+
+const SIZE_BAR_MAX_WIDTH = 61;
+const SIZE_BAR_GAP_WIDTH = 1;
+const SIZE_BAR_MIN_WIDTH = 1.5;
+
+const RENDER_TIMING_LABEL = 'FileListRenderTime';
+const RENDER_AVG_TIMING_LABEL = 'FileListRenderTimePerFile';
+const EXPAND_ALL_TIMING_LABEL = 'ExpandAllDiffs';
+const EXPAND_ALL_AVG_TIMING_LABEL = 'ExpandAllPerDiff';
+
+const FileStatus = {
+  A: 'Added',
+  C: 'Copied',
+  D: 'Deleted',
+  M: 'Modified',
+  R: 'Renamed',
+  W: 'Rewritten',
+  U: 'Unchanged',
+};
+
+const FILE_ROW_CLASS = 'file-row';
+
+export interface GrFileList {
+  $: {
+    restAPI: RestApiService & Element;
+    diffPreferencesDialog: GrDiffPreferencesDialog;
+    diffCursor: GrDiffCursor;
+    fileCursor: GrCursorManager;
+  };
+}
+
+interface ReviewedFileInfo extends FileInfo {
+  isReviewed?: boolean;
+}
+interface NormalizedFileInfo extends ReviewedFileInfo {
+  __path: string;
+}
+
+interface PatchChange {
+  inserted: number;
+  deleted: number;
+  size_delta_inserted: number;
+  size_delta_deleted: number;
+  total_size: number;
+}
+
+function createDefaultPatchChange(): PatchChange {
+  // Use function instead of const to prevent unexpected changes in the default
+  // values.
+  return {
+    inserted: 0,
+    deleted: 0,
+    size_delta_inserted: 0,
+    size_delta_deleted: 0,
+    total_size: 0,
+  };
+}
+
+interface SizeBarLayout {
+  maxInserted: number;
+  maxDeleted: number;
+  maxAdditionWidth: number;
+  maxDeletionWidth: number;
+  deletionOffset: number;
+}
+
+function createDefaultSizeBarLayout(): SizeBarLayout {
+  // Use function instead of const to prevent unexpected changes in the default
+  // values.
+  return {
+    maxInserted: 0,
+    maxDeleted: 0,
+    maxAdditionWidth: 0,
+    maxDeletionWidth: 0,
+    deletionOffset: 0,
+  };
+}
+
+interface FileRow {
+  file: PatchSetFile;
+  element: HTMLElement;
+}
+
+export type FileNameToReviewedFileInfoMap = {[name: string]: ReviewedFileInfo};
+
+/**
+ * Type for FileInfo
+ *
+ * This should match with the type returned from `files` API plus
+ * additional info like `__path`.
+ *
+ * @typedef {Object} FileInfo
+ * @property {string} __path
+ * @property {?string} old_path
+ * @property {number} size
+ * @property {number} size_delta - fallback to 0 if not present in api
+ * @property {number} lines_deleted - fallback to 0 if not present in api
+ * @property {number} lines_inserted - fallback to 0 if not present in api
+ */
+
+@customElement('gr-file-list')
+export class GrFileList extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when a draft refresh should get triggered
+   *
+   * @event reload-drafts
+   */
+
+  @property({type: Object})
+  patchRange?: PatchRange;
+
+  @property({type: String})
+  patchNum?: string;
+
+  @property({type: Number})
+  changeNum?: NumericChangeId;
+
+  @property({type: Object})
+  changeComments?: ChangeComments;
+
+  @property({type: Object})
+  drafts?: {[path: string]: UIDraft[]};
+
+  @property({type: Array})
+  revisions?: {[revisionId: string]: RevisionInfo};
+
+  @property({type: Object})
+  projectConfig?: ConfigInfo;
+
+  @property({type: Number, notify: true})
+  selectedIndex = -1;
+
+  @property({type: Object})
+  keyEventTarget = document.body;
+
+  @property({type: Object})
+  change?: ParsedChangeInfo;
+
+  @property({type: String, notify: true, observer: '_updateDiffPreferences'})
+  diffViewMode?: DiffViewMode;
+
+  @property({type: Boolean, observer: '_editModeChanged'})
+  editMode?: boolean;
+
+  @property({type: String, notify: true})
+  filesExpanded = FilesExpandedState.NONE;
+
+  @property({type: Object})
+  _filesByPath?: FileNameToFileInfoMap;
+
+  @property({type: Array, observer: '_filesChanged'})
+  _files: NormalizedFileInfo[] = [];
+
+  @property({type: Boolean})
+  _loggedIn = false;
+
+  @property({type: Array})
+  _reviewed?: string[] = [];
+
+  @property({type: Object, notify: true, observer: '_updateDiffPreferences'})
+  diffPrefs?: DiffPreferencesInfo;
+
+  @property({type: Object})
+  _userPrefs?: PreferencesInfo;
+
+  @property({type: Boolean})
+  _showInlineDiffs?: boolean;
+
+  @property({type: Number, notify: true})
+  numFilesShown: number = DEFAULT_NUM_FILES_SHOWN;
+
+  @property({type: Object, computed: '_calculatePatchChange(_files)'})
+  _patchChange: PatchChange = createDefaultPatchChange();
+
+  @property({type: Number})
+  fileListIncrement: number = DEFAULT_NUM_FILES_SHOWN;
+
+  @property({type: Boolean, computed: '_shouldHideChangeTotals(_patchChange)'})
+  _hideChangeTotals = true;
+
+  @property({
+    type: Boolean,
+    computed: '_shouldHideBinaryChangeTotals(_patchChange)',
+  })
+  _hideBinaryChangeTotals = true;
+
+  @property({
+    type: Array,
+    computed: '_computeFilesShown(numFilesShown, _files)',
+  })
+  _shownFiles: NormalizedFileInfo[] = [];
+
+  @property({type: Number})
+  _reportinShownFilesIncrement = 0;
+
+  @property({type: Array})
+  _expandedFiles: PatchSetFile[] = [];
+
+  @property({type: Boolean})
+  _displayLine?: boolean;
+
+  @property({type: Boolean, observer: '_loadingChanged'})
+  _loading?: boolean;
+
+  @property({type: Object, computed: '_computeSizeBarLayout(_shownFiles.*)'})
+  _sizeBarLayout: SizeBarLayout = createDefaultSizeBarLayout();
+
+  @property({type: Boolean, computed: '_computeShowSizeBars(_userPrefs)'})
+  _showSizeBars = true;
+
+  private _cancelForEachDiff?: () => void;
+
+  @property({
+    type: Boolean,
+    computed:
+      '_computeShowDynamicColumns(_dynamicHeaderEndpoints, ' +
+      '_dynamicContentEndpoints, _dynamicSummaryEndpoints)',
+  })
+  _showDynamicColumns = false;
+
+  @property({
+    type: Boolean,
+    computed:
+      '_computeShowPrependedDynamicColumns(' +
+      '_dynamicPrependedHeaderEndpoints, _dynamicPrependedContentEndpoints)',
+  })
+  _showPrependedDynamicColumns = false;
+
+  @property({type: Array})
+  _dynamicHeaderEndpoints?: string[];
+
+  @property({type: Array})
+  _dynamicContentEndpoints?: string[];
+
+  @property({type: Array})
+  _dynamicSummaryEndpoints?: string[];
+
+  @property({type: Array})
+  _dynamicPrependedHeaderEndpoints?: string[];
+
+  @property({type: Array})
+  _dynamicPrependedContentEndpoints?: string[];
+
+  private readonly reporting = appContext.reportingService;
+
+  get keyBindings() {
+    return {
+      esc: '_handleEscKey',
+    };
+  }
+
+  keyboardShortcuts() {
+    return {
+      [Shortcut.LEFT_PANE]: '_handleLeftPane',
+      [Shortcut.RIGHT_PANE]: '_handleRightPane',
+      [Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff',
+      [Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs',
+      [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
+        '_handleToggleHideAllCommentThreads',
+      [Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext',
+      [Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev',
+      [Shortcut.NEXT_LINE]: '_handleCursorNext',
+      [Shortcut.PREV_LINE]: '_handleCursorPrev',
+      [Shortcut.NEW_COMMENT]: '_handleNewComment',
+      [Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile',
+      [Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile',
+      [Shortcut.OPEN_FILE]: '_handleOpenFile',
+      [Shortcut.NEXT_CHUNK]: '_handleNextChunk',
+      [Shortcut.PREV_CHUNK]: '_handlePrevChunk',
+      [Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
+      [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
+
+      // Final two are actually handled by gr-comment-thread.
+      [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
+      [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
+    };
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('keydown', e => this._scopedKeydownHandler(e));
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => {
+        this._dynamicHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-file-list-header'
+        );
+        this._dynamicContentEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-file-list-content'
+        );
+        this._dynamicPrependedHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-file-list-header-prepend'
+        );
+        this._dynamicPrependedContentEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-file-list-content-prepend'
+        );
+        this._dynamicSummaryEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-file-list-summary'
+        );
+
+        if (
+          this._dynamicHeaderEndpoints.length !==
+          this._dynamicContentEndpoints.length
+        ) {
+          console.warn(
+            'Different number of dynamic file-list header and content.'
+          );
+        }
+        if (
+          this._dynamicPrependedHeaderEndpoints.length !==
+          this._dynamicPrependedContentEndpoints.length
+        ) {
+          console.warn(
+            'Different number of dynamic file-list header and content.'
+          );
+        }
+        if (
+          this._dynamicHeaderEndpoints.length !==
+          this._dynamicSummaryEndpoints.length
+        ) {
+          console.warn(
+            'Different number of dynamic file-list headers and summary.'
+          );
+        }
+      });
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this._cancelDiffs();
+  }
+
+  /**
+   * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
+   * events must be scoped to a component level (e.g. `enter`) in order to not
+   * override native browser functionality.
+   *
+   * Context: Issue 7277
+   */
+  _scopedKeydownHandler(e: KeyboardEvent) {
+    if (e.keyCode === 13) {
+      // TODO(TS): e is not an instance of CustomKeyboardEvent.
+      // However, to fix it we should fix keyboard-shortcut-mixin first
+      // The keyboard-shortcut-mixin will be updated in a separate change
+      this._handleOpenFile((e as unknown) as CustomKeyboardEvent);
+    }
+  }
+
+  reload() {
+    if (!this.changeNum || !this.patchRange?.patchNum) {
+      return Promise.resolve();
+    }
+    const changeNum = this.changeNum;
+    const patchRange = this.patchRange;
+
+    this._loading = true;
+
+    this.collapseAllDiffs();
+    const promises = [];
+
+    promises.push(
+      this.$.restAPI
+        .getChangeOrEditFiles(changeNum, patchRange)
+        .then(filesByPath => {
+          this._filesByPath = filesByPath;
+        })
+    );
+    promises.push(
+      this._getLoggedIn()
+        .then(loggedIn => (this._loggedIn = loggedIn))
+        .then(loggedIn => {
+          if (!loggedIn) {
+            return;
+          }
+
+          return this._getReviewedFiles(changeNum, patchRange).then(
+            reviewed => {
+              this._reviewed = reviewed;
+            }
+          );
+        })
+    );
+
+    promises.push(
+      this._getDiffPreferences().then(prefs => {
+        this.diffPrefs = prefs;
+      })
+    );
+
+    promises.push(
+      this._getPreferences().then(prefs => {
+        this._userPrefs = prefs;
+      })
+    );
+
+    return Promise.all(promises).then(() => {
+      this._loading = false;
+      this._detectChromiteButler();
+      this.reporting.fileListDisplayed();
+    });
+  }
+
+  _detectChromiteButler() {
+    const hasButler = !!document.getElementById('butler-suggested-owners');
+    if (hasButler) {
+      this.reporting.reportExtension('butler');
+    }
+  }
+
+  get diffs(): GrDiffHost[] {
+    const diffs = this.root!.querySelectorAll('gr-diff-host');
+    // It is possible that a bogus diff element is hanging around invisibly
+    // from earlier with a different patch set choice and associated with a
+    // different entry in the files array. So filter on visible items only.
+    return Array.from(diffs).filter(
+      el => !!el && !!el.style && el.style.display !== 'none'
+    );
+  }
+
+  openDiffPrefs() {
+    this.$.diffPreferencesDialog.open();
+  }
+
+  _calculatePatchChange(files: NormalizedFileInfo[]): PatchChange {
+    const magicFilesExcluded = files.filter(
+      files => !isMagicPath(files.__path)
+    );
+
+    return magicFilesExcluded.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;
+      const size_delta_deleted =
+        obj.binary && obj.size_delta < 0 ? obj.size_delta : 0;
+
+      return {
+        inserted: acc.inserted + inserted,
+        deleted: acc.deleted + deleted,
+        size_delta_inserted: acc.size_delta_inserted + size_delta_inserted,
+        size_delta_deleted: acc.size_delta_deleted + size_delta_deleted,
+        total_size: acc.total_size + total_size,
+      };
+    }, createDefaultPatchChange());
+  }
+
+  _getDiffPreferences() {
+    return this.$.restAPI.getDiffPreferences();
+  }
+
+  _getPreferences() {
+    return this.$.restAPI.getPreferences();
+  }
+
+  private _toggleFileExpanded(file: PatchSetFile) {
+    // Is the path in the list of expanded diffs? IF so remove it, otherwise
+    // add it to the list.
+    const pathIndex = this._expandedFiles.findIndex(f => f.path === file.path);
+    if (pathIndex === -1) {
+      this.push('_expandedFiles', file);
+    } else {
+      this.splice('_expandedFiles', pathIndex, 1);
+    }
+  }
+
+  _toggleFileExpandedByIndex(index: number) {
+    this._toggleFileExpanded(this._computePatchSetFile(this._files[index]));
+  }
+
+  _updateDiffPreferences() {
+    if (!this.diffs.length) {
+      return;
+    }
+    // Re-render all expanded diffs sequentially.
+    this.reporting.time(EXPAND_ALL_TIMING_LABEL);
+    this._renderInOrder(
+      this._expandedFiles,
+      this.diffs,
+      this._expandedFiles.length
+    );
+  }
+
+  _forEachDiff(fn: (host: GrDiffHost) => void) {
+    const diffs = this.diffs;
+    for (let i = 0; i < diffs.length; i++) {
+      fn(diffs[i]);
+    }
+  }
+
+  expandAllDiffs() {
+    this._showInlineDiffs = true;
+
+    // Find the list of paths that are in the file list, but not in the
+    // expanded list.
+    const newFiles: PatchSetFile[] = [];
+    let path: string;
+    for (let i = 0; i < this._shownFiles.length; i++) {
+      path = this._shownFiles[i].__path;
+      if (!this._expandedFiles.some(f => f.path === path)) {
+        newFiles.push(this._computePatchSetFile(this._shownFiles[i]));
+      }
+    }
+
+    this.splice('_expandedFiles', 0, 0, ...newFiles);
+  }
+
+  collapseAllDiffs() {
+    this._showInlineDiffs = false;
+    this._expandedFiles = [];
+    this.filesExpanded = this._computeExpandedFiles(
+      this._expandedFiles.length,
+      this._files.length
+    );
+    this.$.diffCursor.handleDiffUpdate();
+  }
+
+  /**
+   * Computes a string with the number of comments and unresolved comments.
+   */
+  _computeCommentsString(
+    changeComments?: ChangeComments,
+    patchRange?: PatchRange,
+    path?: string
+  ) {
+    if (
+      changeComments === undefined ||
+      patchRange === undefined ||
+      path === undefined
+    ) {
+      return '';
+    }
+    const unresolvedCount =
+      changeComments.computeUnresolvedNum({
+        patchNum: patchRange.basePatchNum,
+        path,
+      }) +
+      changeComments.computeUnresolvedNum({
+        patchNum: patchRange.patchNum,
+        path,
+      });
+    const commentThreadCount =
+      changeComments.computeCommentThreadCount({
+        patchNum: patchRange.basePatchNum,
+        path,
+      }) +
+      changeComments.computeCommentThreadCount({
+        patchNum: patchRange.patchNum,
+        path,
+      });
+    const commentString = GrCountStringFormatter.computePluralString(
+      commentThreadCount,
+      'comment'
+    );
+    const unresolvedString = GrCountStringFormatter.computeString(
+      unresolvedCount,
+      'unresolved'
+    );
+
+    return (
+      commentString +
+      // Add a space if both comments and unresolved
+      (commentString && unresolvedString ? ' ' : '') +
+      // Add parentheses around unresolved if it exists.
+      (unresolvedString ? `(${unresolvedString})` : '')
+    );
+  }
+
+  /**
+   * Computes a string with the number of drafts.
+   */
+  _computeDraftsString(
+    changeComments?: ChangeComments,
+    patchRange?: PatchRange,
+    path?: string
+  ) {
+    if (
+      changeComments === undefined ||
+      patchRange === undefined ||
+      path === undefined
+    ) {
+      return '';
+    }
+    const draftCount =
+      changeComments.computeDraftCount({
+        patchNum: patchRange.basePatchNum,
+        path,
+      }) +
+      changeComments.computeDraftCount({
+        patchNum: patchRange.patchNum,
+        path,
+      });
+    return GrCountStringFormatter.computePluralString(draftCount, 'draft');
+  }
+
+  /**
+   * Computes a shortened string with the number of drafts.
+   */
+  _computeDraftsStringMobile(
+    changeComments?: ChangeComments,
+    patchRange?: PatchRange,
+    path?: string
+  ) {
+    if (
+      changeComments === undefined ||
+      patchRange === undefined ||
+      path === undefined
+    ) {
+      return '';
+    }
+    const draftCount =
+      changeComments.computeDraftCount({
+        patchNum: patchRange.basePatchNum,
+        path,
+      }) +
+      changeComments.computeDraftCount({
+        patchNum: patchRange.patchNum,
+        path,
+      });
+    return GrCountStringFormatter.computeShortString(draftCount, 'd');
+  }
+
+  /**
+   * Computes a shortened string with the number of comments.
+   */
+  _computeCommentsStringMobile(
+    changeComments?: ChangeComments,
+    patchRange?: PatchRange,
+    path?: string
+  ) {
+    if (
+      changeComments === undefined ||
+      patchRange === undefined ||
+      path === undefined
+    ) {
+      return '';
+    }
+    const commentThreadCount =
+      changeComments.computeCommentThreadCount({
+        patchNum: patchRange.basePatchNum,
+        path,
+      }) +
+      changeComments.computeCommentThreadCount({
+        patchNum: patchRange.patchNum,
+        path,
+      });
+    return GrCountStringFormatter.computeShortString(commentThreadCount, 'c');
+  }
+
+  private _reviewFile(path: string, reviewed?: boolean) {
+    if (this.editMode) {
+      return Promise.resolve();
+    }
+    const index = this._files.findIndex(file => file.__path === path);
+    reviewed = reviewed || !this._files[index].isReviewed;
+
+    this.set(['_files', index, 'isReviewed'], reviewed);
+    if (index < this._shownFiles.length) {
+      this.notifyPath(`_shownFiles.${index}.isReviewed`);
+    }
+
+    return this._saveReviewedState(path, reviewed);
+  }
+
+  _saveReviewedState(path: string, reviewed: boolean) {
+    if (!this.changeNum || !this.patchRange) {
+      throw new Error('changeNum and patchRange must be set');
+    }
+
+    return this.$.restAPI.saveFileReviewed(
+      this.changeNum,
+      this.patchRange.patchNum,
+      path,
+      reviewed
+    );
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  _getReviewedFiles(changeNum: NumericChangeId, patchRange: PatchRange) {
+    if (this.editMode) {
+      return Promise.resolve([]);
+    }
+    return this.$.restAPI.getReviewedFiles(changeNum, patchRange.patchNum);
+  }
+
+  _normalizeChangeFilesResponse(
+    response: FileNameToReviewedFileInfoMap
+  ): NormalizedFileInfo[] {
+    const paths = Object.keys(response).sort(specialFilePathCompare);
+    const files: NormalizedFileInfo[] = [];
+    for (let i = 0; i < paths.length; i++) {
+      // TODO(TS): make copy instead of as NormalizedFileInfo
+      const info = response[paths[i]] as NormalizedFileInfo;
+      info.__path = paths[i];
+      info.lines_inserted = info.lines_inserted || 0;
+      info.lines_deleted = info.lines_deleted || 0;
+      info.size_delta = info.size_delta || 0;
+      files.push(info);
+    }
+    return files;
+  }
+
+  /**
+   * Returns true if the event e is a click on an element.
+   *
+   * The click is: mouse click or pressing Enter or Space key
+   * P.S> Screen readers sends click event as well
+   */
+  _isClickEvent(e: MouseEvent | KeyboardEvent) {
+    if (e.type === 'click') {
+      return true;
+    }
+    const ke = e as KeyboardEvent;
+    const isSpaceOrEnter = ke.key === 'Enter' || ke.key === ' ';
+    return ke.type === 'keydown' && isSpaceOrEnter;
+  }
+
+  _fileActionClick(
+    e: MouseEvent | KeyboardEvent,
+    fileAction: (file: PatchSetFile) => void
+  ) {
+    if (this._isClickEvent(e)) {
+      const fileRow = this._getFileRowFromEvent(e);
+      if (!fileRow) {
+        return;
+      }
+      // Prevent default actions (e.g. scrolling for space key)
+      e.preventDefault();
+      // Prevent _handleFileListClick handler call
+      e.stopPropagation();
+      this.$.fileCursor.setCursor(fileRow.element);
+      fileAction(fileRow.file);
+    }
+  }
+
+  _reviewedClick(e: MouseEvent | KeyboardEvent) {
+    this._fileActionClick(e, file => this._reviewFile(file.path));
+  }
+
+  _expandedClick(e: MouseEvent | KeyboardEvent) {
+    this._fileActionClick(e, file => this._toggleFileExpanded(file));
+  }
+
+  /**
+   * Handle all events from the file list dom-repeat so event handleers don't
+   * have to get registered for potentially very long lists.
+   */
+  _handleFileListClick(e: MouseEvent) {
+    if (!e.target) {
+      return;
+    }
+    const fileRow = this._getFileRowFromEvent(e);
+    if (!fileRow) {
+      return;
+    }
+    const file = fileRow.file;
+    const path = file.path;
+    // If a path cannot be interpreted from the click target (meaning it's not
+    // somewhere in the row, e.g. diff content) or if the user clicked the
+    // link, defer to the native behavior.
+    if (!path || descendedFromClass(e.target as Element, 'pathLink')) {
+      return;
+    }
+
+    // Disregard the event if the click target is in the edit controls.
+    if (descendedFromClass(e.target as Element, 'editFileControls')) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.fileCursor.setCursor(fileRow.element);
+    this._toggleFileExpanded(file);
+  }
+
+  _getFileRowFromEvent(e: Event): FileRow | null {
+    // Traverse upwards to find the row element if the target is not the row.
+    let row = e.target as HTMLElement;
+    while (!row.classList.contains(FILE_ROW_CLASS) && row.parentElement) {
+      row = row.parentElement;
+    }
+
+    // No action needed for item without a valid file
+    if (!row.dataset['file']) {
+      return null;
+    }
+
+    return {
+      file: JSON.parse(row.dataset['file']) as PatchSetFile,
+      element: row,
+    };
+  }
+
+  /**
+   * Generates file range from file info object.
+   */
+  _computePatchSetFile(file: NormalizedFileInfo): PatchSetFile {
+    const fileData: PatchSetFile = {
+      path: file.__path,
+    };
+    if (file.old_path) {
+      fileData.basePath = file.old_path;
+    }
+    return fileData;
+  }
+
+  _handleLeftPane(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.diffCursor.moveLeft();
+  }
+
+  _handleRightPane(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.diffCursor.moveRight();
+  }
+
+  _handleToggleInlineDiff(e: CustomKeyboardEvent) {
+    if (
+      this.shouldSuppressKeyboardShortcut(e) ||
+      this.modifierPressed(e) ||
+      this.$.fileCursor.index === -1
+    ) {
+      return;
+    }
+
+    e.preventDefault();
+    this._toggleFileExpandedByIndex(this.$.fileCursor.index);
+  }
+
+  _handleToggleAllInlineDiffs(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this._toggleInlineDiffs();
+  }
+
+  _handleToggleHideAllCommentThreads(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this.toggleClass('hideComments');
+  }
+
+  _handleCursorNext(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    if (this._showInlineDiffs) {
+      e.preventDefault();
+      this.$.diffCursor.moveDown();
+      this._displayLine = true;
+    } else {
+      // Down key
+      if (this.getKeyboardEvent(e).keyCode === 40) {
+        return;
+      }
+      e.preventDefault();
+      this.$.fileCursor.next();
+      this.selectedIndex = this.$.fileCursor.index;
+    }
+  }
+
+  _handleCursorPrev(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    if (this._showInlineDiffs) {
+      e.preventDefault();
+      this.$.diffCursor.moveUp();
+      this._displayLine = true;
+    } else {
+      // Up key
+      if (this.getKeyboardEvent(e).keyCode === 38) {
+        return;
+      }
+      e.preventDefault();
+      this.$.fileCursor.previous();
+      this.selectedIndex = this.$.fileCursor.index;
+    }
+  }
+
+  _handleNewComment(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+    e.preventDefault();
+    this.classList.remove('hideComments');
+    this.$.diffCursor.createCommentInPlace();
+  }
+
+  _handleOpenLastFile(e: CustomKeyboardEvent) {
+    // 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);
+  }
+
+  _handleOpenFirstFile(e: CustomKeyboardEvent) {
+    // Check for meta key to avoid overriding native chrome shortcut.
+    if (
+      this.shouldSuppressKeyboardShortcut(e) ||
+      this.getKeyboardEvent(e).metaKey
+    ) {
+      return;
+    }
+
+    e.preventDefault();
+    this._openSelectedFile(0);
+  }
+
+  _handleOpenFile(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+    e.preventDefault();
+
+    if (this._showInlineDiffs) {
+      this._openCursorFile();
+      return;
+    }
+
+    this._openSelectedFile();
+  }
+
+  _handleNextChunk(e: CustomKeyboardEvent) {
+    if (
+      this.shouldSuppressKeyboardShortcut(e) ||
+      (this.modifierPressed(e) &&
+        !this.isModifierPressed(e, Modifier.SHIFT_KEY)) ||
+      this._noDiffsExpanded()
+    ) {
+      return;
+    }
+
+    e.preventDefault();
+    if (this.isModifierPressed(e, Modifier.SHIFT_KEY)) {
+      this.$.diffCursor.moveToNextCommentThread();
+    } else {
+      this.$.diffCursor.moveToNextChunk();
+    }
+  }
+
+  _handlePrevChunk(e: CustomKeyboardEvent) {
+    if (
+      this.shouldSuppressKeyboardShortcut(e) ||
+      (this.modifierPressed(e) &&
+        !this.isModifierPressed(e, Modifier.SHIFT_KEY)) ||
+      this._noDiffsExpanded()
+    ) {
+      return;
+    }
+
+    e.preventDefault();
+    if (this.isModifierPressed(e, Modifier.SHIFT_KEY)) {
+      this.$.diffCursor.moveToPreviousCommentThread();
+    } else {
+      this.$.diffCursor.moveToPreviousChunk();
+    }
+  }
+
+  _handleToggleFileReviewed(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    if (!this._files[this.$.fileCursor.index]) {
+      return;
+    }
+    this._reviewFile(this._files[this.$.fileCursor.index].__path);
+  }
+
+  _handleToggleLeftPane(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this._forEachDiff(diff => {
+      diff.toggleLeftDiff();
+    });
+  }
+
+  _toggleInlineDiffs() {
+    if (this._showInlineDiffs) {
+      this.collapseAllDiffs();
+    } else {
+      this.expandAllDiffs();
+    }
+  }
+
+  _openCursorFile() {
+    const diff = this.$.diffCursor.getTargetDiffElement();
+    if (
+      !this.change ||
+      !diff ||
+      !this.patchRange ||
+      !diff.path ||
+      !diff.patchRange
+    ) {
+      throw new Error('change, diff and patchRange must be all set and valid');
+    }
+    GerritNav.navigateToDiff(
+      this.change,
+      diff.path,
+      diff.patchRange.patchNum,
+      this.patchRange.basePatchNum
+    );
+  }
+
+  _openSelectedFile(index?: number) {
+    if (index !== undefined) {
+      this.$.fileCursor.setCursorAtIndex(index);
+    }
+    if (!this._files[this.$.fileCursor.index]) {
+      return;
+    }
+    if (!this.change || !this.patchRange) {
+      throw new Error('change and patchRange must be set');
+    }
+    GerritNav.navigateToDiff(
+      this.change,
+      this._files[this.$.fileCursor.index].__path,
+      this.patchRange.patchNum,
+      this.patchRange.basePatchNum
+    );
+  }
+
+  _addDraftAtTarget() {
+    const diff = this.$.diffCursor.getTargetDiffElement();
+    const target = this.$.diffCursor.getTargetLineElement();
+    if (diff && target) {
+      diff.addDraftAtLine(target);
+    }
+  }
+
+  _shouldHideChangeTotals(_patchChange: PatchChange): boolean {
+    return _patchChange.inserted === 0 && _patchChange.deleted === 0;
+  }
+
+  _shouldHideBinaryChangeTotals(_patchChange: PatchChange) {
+    return (
+      _patchChange.size_delta_inserted === 0 &&
+      _patchChange.size_delta_deleted === 0
+    );
+  }
+
+  _computeFileStatus(
+    status?: keyof typeof FileStatus
+  ): keyof typeof FileStatus {
+    return status || 'M';
+  }
+
+  _computeDiffURL(
+    change?: ParsedChangeInfo,
+    patchRange?: PatchRange,
+    path?: string,
+    editMode?: boolean
+  ) {
+    // Polymer 2: check for undefined
+    if (
+      change === undefined ||
+      patchRange === undefined ||
+      path === undefined ||
+      editMode === undefined
+    ) {
+      return;
+    }
+    if (editMode && path !== SpecialFilePath.MERGE_LIST) {
+      return GerritNav.getEditUrlForDiff(change, path, patchRange.patchNum);
+    }
+    return GerritNav.getUrlForDiff(
+      change,
+      path,
+      patchRange.patchNum,
+      patchRange.basePatchNum
+    );
+  }
+
+  _formatBytes(bytes?: number) {
+    if (!bytes) return '+/-0 B';
+    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 ? '+' : '';
+    const value = parseFloat(
+      (bytes / Math.pow(bits, exponent)).toFixed(decimals)
+    );
+    return `${prepend}${value} ${sizes[exponent]}`;
+  }
+
+  _formatPercentage(size?: number, delta?: number) {
+    if (size === undefined || delta === undefined) {
+      return '';
+    }
+    const oldSize = size - delta;
+
+    if (oldSize === 0) {
+      return '';
+    }
+
+    const percentage = Math.round(Math.abs((delta * 100) / oldSize));
+    return `(${delta > 0 ? '+' : '-'}${percentage}%)`;
+  }
+
+  _computeBinaryClass(delta?: number) {
+    if (!delta) {
+      return;
+    }
+    return delta > 0 ? 'added' : 'removed';
+  }
+
+  _computeClass(baseClass?: string, path?: string) {
+    const classes = [];
+    if (baseClass) {
+      classes.push(baseClass);
+    }
+    if (
+      path === SpecialFilePath.COMMIT_MESSAGE ||
+      path === SpecialFilePath.MERGE_LIST
+    ) {
+      classes.push('invisible');
+    }
+    return classes.join(' ');
+  }
+
+  _computeStatusClass(file?: NormalizedFileInfo) {
+    if (!file) return '';
+    const classStr = this._computeClass('status', file.__path);
+    return `${classStr} ${this._computeFileStatus(file.status)}`;
+  }
+
+  _computePathClass(
+    path: string | undefined,
+    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
+  ) {
+    return this._isFileExpanded(path, expandedFilesRecord) ? 'expanded' : '';
+  }
+
+  _computeShowHideIcon(
+    path: string | undefined,
+    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
+  ) {
+    return this._isFileExpanded(path, expandedFilesRecord)
+      ? 'gr-icons:expand-less'
+      : 'gr-icons:expand-more';
+  }
+
+  @observe(
+    '_filesByPath',
+    'changeComments',
+    'patchRange',
+    '_reviewed',
+    '_loading'
+  )
+  _computeFiles(
+    filesByPath?: FileNameToFileInfoMap,
+    changeComments?: ChangeComments,
+    patchRange?: PatchRange,
+    reviewed?: string[],
+    loading?: boolean
+  ) {
+    // Polymer 2: check for undefined
+    if (
+      filesByPath === undefined ||
+      changeComments === undefined ||
+      patchRange === undefined ||
+      reviewed === undefined ||
+      loading === undefined
+    ) {
+      return;
+    }
+
+    // Await all promises resolving from reload. @See Issue 9057
+    if (loading || !changeComments) {
+      return;
+    }
+
+    const commentedPaths = changeComments.getPaths(patchRange);
+    const files: FileNameToReviewedFileInfoMap = {...filesByPath};
+    addUnmodifiedFiles(files, commentedPaths);
+    const reviewedSet = new Set(reviewed || []);
+    for (const filePath in files) {
+      if (!hasOwnProperty(files, filePath)) {
+        continue;
+      }
+      files[filePath].isReviewed = reviewedSet.has(filePath);
+    }
+
+    this._files = this._normalizeChangeFilesResponse(files);
+  }
+
+  _computeFilesShown(
+    numFilesShown: number,
+    files: NormalizedFileInfo[]
+  ): NormalizedFileInfo[] | undefined {
+    // Polymer 2: check for undefined
+    if (numFilesShown === undefined || files === undefined) return undefined;
+
+    const previousNumFilesShown = this._shownFiles
+      ? this._shownFiles.length
+      : 0;
+
+    const filesShown = files.slice(0, numFilesShown);
+    this.dispatchEvent(
+      new CustomEvent('files-shown-changed', {
+        detail: {length: filesShown.length},
+        composed: true,
+        bubbles: true,
+      })
+    );
+
+    // Start the timer for the rendering work hwere because this is where the
+    // _shownFiles property is being set, and _shownFiles is used in the
+    // dom-repeat binding.
+    this.reporting.time(RENDER_TIMING_LABEL);
+
+    // How many more files are being shown (if it's an increase).
+    this._reportinShownFilesIncrement = Math.max(
+      0,
+      filesShown.length - previousNumFilesShown
+    );
+
+    return filesShown;
+  }
+
+  _updateDiffCursor() {
+    // Overwrite the cursor's list of diffs:
+    this.$.diffCursor.splice(
+      'diffs',
+      0,
+      this.$.diffCursor.diffs.length,
+      ...this.diffs
+    );
+  }
+
+  _filesChanged() {
+    if (this._files && this._files.length > 0) {
+      flush();
+      this.$.fileCursor.stops = Array.from(
+        this.root!.querySelectorAll(`.${FILE_ROW_CLASS}`)
+      );
+      this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
+    }
+  }
+
+  _incrementNumFilesShown() {
+    this.numFilesShown += this.fileListIncrement;
+  }
+
+  _computeFileListControlClass(
+    numFilesShown?: number,
+    files?: NormalizedFileInfo[]
+  ) {
+    if (numFilesShown === undefined || files === undefined) return 'invisible';
+    return numFilesShown >= files.length ? 'invisible' : '';
+  }
+
+  _computeIncrementText(numFilesShown?: number, files?: NormalizedFileInfo[]) {
+    if (numFilesShown === undefined || files === undefined) return '';
+    const text = Math.min(this.fileListIncrement, files.length - numFilesShown);
+    return `Show ${text} more`;
+  }
+
+  _computeShowAllText(files: NormalizedFileInfo[]) {
+    if (!files) {
+      return '';
+    }
+    return `Show all ${files.length} files`;
+  }
+
+  _computeWarnShowAll(files: NormalizedFileInfo[]) {
+    return files.length > WARN_SHOW_ALL_THRESHOLD;
+  }
+
+  _computeShowAllWarning(files: NormalizedFileInfo[]) {
+    if (!this._computeWarnShowAll(files)) {
+      return '';
+    }
+    return `Warning: showing all ${files.length} files may take several seconds.`;
+  }
+
+  _showAllFiles() {
+    this.numFilesShown = this._files.length;
+  }
+
+  /**
+   * Get a descriptive label for use in the status indicator's tooltip and
+   * ARIA label.
+   */
+  _computeFileStatusLabel(status?: keyof typeof FileStatus) {
+    const statusCode = this._computeFileStatus(status);
+    return hasOwnProperty(FileStatus, statusCode)
+      ? FileStatus[statusCode]
+      : 'Status Unknown';
+  }
+
+  /**
+   * Converts any boolean-like variable to the string 'true' or 'false'
+   *
+   * This method is useful when you bind aria-checked attribute to a boolean
+   * value. The aria-checked attribute is string attribute. Binding directly
+   * to boolean variable causes problem on gerrit-CI.
+   *
+   * @return 'true' if val is true-like, otherwise false
+   */
+  _booleanToString(val?: unknown) {
+    return val ? 'true' : 'false';
+  }
+
+  _isFileExpanded(
+    path: string | undefined,
+    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
+  ) {
+    return expandedFilesRecord.base.some(f => f.path === path);
+  }
+
+  _isFileExpandedStr(
+    path: string | undefined,
+    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
+  ) {
+    return this._booleanToString(
+      this._isFileExpanded(path, expandedFilesRecord)
+    );
+  }
+
+  private _computeExpandedFiles(
+    expandedCount: number,
+    totalCount: number
+  ): FilesExpandedState {
+    if (expandedCount === 0) {
+      return FilesExpandedState.NONE;
+    } else if (expandedCount === totalCount) {
+      return FilesExpandedState.ALL;
+    }
+    return FilesExpandedState.SOME;
+  }
+
+  /**
+   * Handle splices to the list of expanded file paths. If there are any new
+   * entries in the expanded list, then render each diff corresponding in
+   * order by waiting for the previous diff to finish before starting the next
+   * one.
+   *
+   * @param record The splice record in the expanded paths list.
+   */
+  @observe('_expandedFiles.splices')
+  _expandedFilesChanged(record?: PolymerSpliceChange<PatchSetFile[]>) {
+    // Clear content for any diffs that are not open so if they get re-opened
+    // the stale content does not flash before it is cleared and reloaded.
+    const collapsedDiffs = this.diffs.filter(
+      diff => this._expandedFiles.findIndex(f => f.path === diff.path) === -1
+    );
+    this._clearCollapsedDiffs(collapsedDiffs);
+
+    if (!record) {
+      return;
+    } // Happens after "Collapse all" clicked.
+
+    this.filesExpanded = this._computeExpandedFiles(
+      this._expandedFiles.length,
+      this._files.length
+    );
+
+    // Find the paths introduced by the new index splices:
+    const newFiles = record.indexSplices
+      .map(splice =>
+        splice.object.slice(splice.index, splice.index + splice.addedCount)
+      )
+      .reduce((acc, paths) => acc.concat(paths), []);
+
+    // Required so that the newly created diff view is included in this.diffs.
+    flush();
+
+    this.reporting.time(EXPAND_ALL_TIMING_LABEL);
+
+    if (newFiles.length) {
+      this._renderInOrder(newFiles, this.diffs, newFiles.length);
+    }
+
+    this._updateDiffCursor();
+    this.$.diffCursor.reInitAndUpdateStops();
+  }
+
+  private _clearCollapsedDiffs(collapsedDiffs: GrDiffHost[]) {
+    for (const diff of collapsedDiffs) {
+      diff.cancel();
+      diff.clearDiffContent();
+    }
+  }
+
+  /**
+   * Given an array of paths and a NodeList of diff elements, render the diff
+   * for each path in order, awaiting the previous render to complete before
+   * continuing.
+   *
+   * @param initialCount The total number of paths in the pass. This
+   * is used to generate log messages.
+   */
+  private _renderInOrder(
+    files: PatchSetFile[],
+    diffElements: GrDiffHost[],
+    initialCount: number
+  ) {
+    let iter = 0;
+
+    for (const file of files) {
+      const path = file.path;
+      const diffElem = this._findDiffByPath(path, diffElements);
+      if (diffElem) {
+        diffElem.prefetchDiff();
+      }
+    }
+
+    return new Promise(resolve => {
+      this.dispatchEvent(
+        new CustomEvent('reload-drafts', {
+          detail: {resolve},
+          composed: true,
+          bubbles: true,
+        })
+      );
+    }).then(() =>
+      asyncForeach(files, (file, cancel) => {
+        const path = file.path;
+        this._cancelForEachDiff = cancel;
+
+        iter++;
+        console.info('Expanding diff', iter, 'of', initialCount, ':', path);
+        const diffElem = this._findDiffByPath(path, diffElements);
+        if (!diffElem) {
+          console.warn(`Did not find <gr-diff-host> element for ${path}`);
+          return Promise.resolve();
+        }
+        if (!this.changeComments || !this.patchRange || !this.diffPrefs) {
+          throw new Error(
+            'changeComments, patchRange and diffPrefs must be set'
+          );
+        }
+        diffElem.threads = this.changeComments.getThreadsBySideForFile(
+          file,
+          this.patchRange,
+          this.projectConfig
+        );
+        const promises: Array<Promise<unknown>> = [diffElem.reload()];
+        if (this._loggedIn && !this.diffPrefs.manual_review) {
+          promises.push(this._reviewFile(path, true));
+        }
+        return Promise.all(promises);
+      }).then(() => {
+        this._cancelForEachDiff = undefined;
+        console.info('Finished expanding', initialCount, 'diff(s)');
+        this.reporting.timeEndWithAverage(
+          EXPAND_ALL_TIMING_LABEL,
+          EXPAND_ALL_AVG_TIMING_LABEL,
+          initialCount
+        );
+        /* Block diff cursor from auto scrolling after files are done rendering.
+       * This prevents the bug where the screen jumps to the first diff chunk
+       * after files are done being rendered after the user has already begun
+       * scrolling.
+       * This also however results in the fact that the cursor does not auto
+       * focus on the first diff chunk on a small screen. This is however, a use
+       * case we are willing to not support for now.
+
+       * Using handleDiffUpdate resulted in diffCursor.row being set which
+       * prevented the issue of scrolling to top when we expand the second
+       * file individually.
+       */
+        this.$.diffCursor.reInitAndUpdateStops();
+      })
+    );
+  }
+
+  /** Cancel the rendering work of every diff in the list */
+  _cancelDiffs() {
+    if (this._cancelForEachDiff) {
+      this._cancelForEachDiff();
+    }
+    this._forEachDiff(d => d.cancel());
+  }
+
+  /**
+   * In the given NodeList of diff elements, find the diff for the given path.
+   */
+  private _findDiffByPath(path: string, diffElements: GrDiffHost[]) {
+    for (let i = 0; i < diffElements.length; i++) {
+      if (diffElements[i].path === path) {
+        return diffElements[i];
+      }
+    }
+    return undefined;
+  }
+
+  /**
+   * Reset the comments of a modified thread
+   */
+  reloadCommentsForThreadWithRootId(rootId: UrlEncodedCommentId, path: string) {
+    // Don't bother continuing if we already know that the path that contains
+    // the updated comment thread is not expanded.
+    if (!this._expandedFiles.some(f => f.path === path)) {
+      return;
+    }
+    const diff = this.diffs.find(d => d.path === path);
+
+    if (!diff) {
+      throw new Error("Can't find diff by path");
+    }
+
+    const threadEl = diff.getThreadEls().find(t => t.rootId === rootId);
+    if (!threadEl) {
+      return;
+    }
+
+    if (!this.changeComments) {
+      throw new Error('changeComments must be set');
+    }
+
+    const newComments = this.changeComments.getCommentsForThread(rootId);
+
+    // If newComments is null, it means that a single draft was
+    // removed from a thread in the thread view, and the thread should
+    // no longer exist. Remove the existing thread element in the diff
+    // view.
+    if (!newComments) {
+      threadEl.fireRemoveSelf();
+      return;
+    }
+
+    // Comments are not returned with the commentSide attribute from
+    // the api, but it's necessary to be stored on the diff's
+    // comments due to use in the _handleCommentUpdate function.
+    // The comment thread already has a side associated with it, so
+    // set the comment's side to match.
+    threadEl.comments = newComments.map(c =>
+      Object.assign(c, {__commentSide: threadEl.commentSide})
+    );
+    flush();
+  }
+
+  _handleEscKey(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+    e.preventDefault();
+    this._displayLine = false;
+  }
+
+  /**
+   * Update the loading class for the file list rows. The update is inside a
+   * debouncer so that the file list doesn't flash gray when the API requests
+   * are reasonably fast.
+   */
+  _loadingChanged(loading?: boolean) {
+    this.debounce(
+      'loading-change',
+      () => {
+        // Only show set the loading if there have been files loaded to show. In
+        // this way, the gray loading style is not shown on initial loads.
+        this.classList.toggle('loading', loading && !!this._files.length);
+      },
+      LOADING_DEBOUNCE_INTERVAL
+    );
+  }
+
+  _editModeChanged(editMode?: boolean) {
+    this.classList.toggle('editMode', editMode);
+  }
+
+  _computeReviewedClass(isReviewed?: boolean) {
+    return isReviewed ? 'isReviewed' : '';
+  }
+
+  _computeReviewedText(isReviewed?: boolean) {
+    return isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
+  }
+
+  /**
+   * Given a file path, return whether that path should have visible size bars
+   * and be included in the size bars calculation.
+   */
+  _showBarsForPath(path?: string) {
+    return (
+      path !== SpecialFilePath.COMMIT_MESSAGE &&
+      path !== SpecialFilePath.MERGE_LIST
+    );
+  }
+
+  /**
+   * Compute size bar layout values from the file list.
+   */
+  _computeSizeBarLayout(
+    shownFilesRecord?: ElementPropertyDeepChange<GrFileList, '_shownFiles'>
+  ) {
+    const stats: SizeBarLayout = createDefaultSizeBarLayout();
+    if (!shownFilesRecord || !shownFilesRecord.base) {
+      return stats;
+    }
+    shownFilesRecord.base
+      .filter(f => this._showBarsForPath(f.__path))
+      .forEach(f => {
+        if (f.lines_inserted) {
+          stats.maxInserted = Math.max(stats.maxInserted, f.lines_inserted);
+        }
+        if (f.lines_deleted) {
+          stats.maxDeleted = Math.max(stats.maxDeleted, f.lines_deleted);
+        }
+      });
+    const ratio = stats.maxInserted / (stats.maxInserted + stats.maxDeleted);
+    if (!isNaN(ratio)) {
+      stats.maxAdditionWidth =
+        (SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH) * ratio;
+      stats.maxDeletionWidth =
+        SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH - stats.maxAdditionWidth;
+      stats.deletionOffset = stats.maxAdditionWidth + SIZE_BAR_GAP_WIDTH;
+    }
+    return stats;
+  }
+
+  /**
+   * Get the width of the addition bar for a file.
+   */
+  _computeBarAdditionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+    if (
+      !file ||
+      !stats ||
+      stats.maxInserted === 0 ||
+      !file.lines_inserted ||
+      !this._showBarsForPath(file.__path)
+    ) {
+      return 0;
+    }
+    const width =
+      (stats.maxAdditionWidth * file.lines_inserted) / stats.maxInserted;
+    return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
+  }
+
+  /**
+   * Get the x-offset of the addition bar for a file.
+   */
+  _computeBarAdditionX(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+    if (!file || !stats) return;
+    return stats.maxAdditionWidth - this._computeBarAdditionWidth(file, stats);
+  }
+
+  /**
+   * Get the width of the deletion bar for a file.
+   */
+  _computeBarDeletionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+    if (
+      !file ||
+      !stats ||
+      stats.maxDeleted === 0 ||
+      !file.lines_deleted ||
+      !this._showBarsForPath(file.__path)
+    ) {
+      return 0;
+    }
+    const width =
+      (stats.maxDeletionWidth * file.lines_deleted) / stats.maxDeleted;
+    return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
+  }
+
+  /**
+   * Get the x-offset of the deletion bar for a file.
+   */
+  _computeBarDeletionX(stats: SizeBarLayout) {
+    return stats.deletionOffset;
+  }
+
+  _computeShowSizeBars(userPrefs?: PreferencesInfo) {
+    return !!userPrefs?.size_bar_in_change_table;
+  }
+
+  _computeSizeBarsClass(showSizeBars?: boolean, path?: string) {
+    let hideClass = '';
+    if (!showSizeBars) {
+      hideClass = 'hide';
+    } else if (!this._showBarsForPath(path)) {
+      hideClass = 'invisible';
+    }
+    return `sizeBars desktop ${hideClass}`;
+  }
+
+  /**
+   * Shows registered dynamic columns iff the 'header', 'content' and
+   * 'summary' endpoints are registered the exact same number of times.
+   * Ideally, there should be a better way to enforce the expectation of the
+   * dependencies between dynamic endpoints.
+   */
+  _computeShowDynamicColumns(
+    headerEndpoints?: string,
+    contentEndpoints?: string,
+    summaryEndpoints?: string
+  ) {
+    return (
+      headerEndpoints &&
+      contentEndpoints &&
+      summaryEndpoints &&
+      headerEndpoints.length &&
+      headerEndpoints.length === contentEndpoints.length &&
+      headerEndpoints.length === summaryEndpoints.length
+    );
+  }
+
+  /**
+   * Shows registered dynamic prepended columns iff the 'header', 'content'
+   * endpoints are registered the exact same number of times.
+   */
+  _computeShowPrependedDynamicColumns(
+    headerEndpoints?: string,
+    contentEndpoints?: string
+  ) {
+    return (
+      headerEndpoints &&
+      contentEndpoints &&
+      headerEndpoints.length &&
+      headerEndpoints.length === contentEndpoints.length
+    );
+  }
+
+  /**
+   * Returns true if none of the inline diffs have been expanded.
+   */
+  _noDiffsExpanded() {
+    return this.filesExpanded === FilesExpandedState.NONE;
+  }
+
+  /**
+   * Method to call via binding when each file list row is rendered. This
+   * allows approximate detection of when the dom-repeat has completed
+   * rendering.
+   *
+   * @param index The index of the row being rendered.
+   */
+  _reportRenderedRow(index: number) {
+    if (index === this._shownFiles.length - 1) {
+      this.async(() => {
+        this.reporting.timeEndWithAverage(
+          RENDER_TIMING_LABEL,
+          RENDER_AVG_TIMING_LABEL,
+          this._reportinShownFilesIncrement
+        );
+      }, 1);
+    }
+    return '';
+  }
+
+  _reviewedTitle(reviewed?: boolean) {
+    if (reviewed) {
+      return 'Mark as not reviewed (shortcut: r)';
+    }
+
+    return 'Mark as reviewed (shortcut: r)';
+  }
+
+  _handleReloadingDiffPreference() {
+    this._getDiffPreferences().then(prefs => {
+      this.diffPrefs = prefs;
+    });
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeDisplayPath(path: string) {
+    return computeDisplayPath(path);
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeTruncatedPath(path: string) {
+    return computeTruncatedPath(path);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-file-list': GrFileList;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
index a577eb6..d93ce68 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
@@ -314,6 +314,7 @@
       --gr-comment-thread-display: none;
     }
   </style>
+  <h3 class="assistive-tech-only">File list</h3>
   <div
     id="container"
     on-click="_handleFileListClick"
@@ -375,7 +376,7 @@
       <div class="stickyArea">
         <div
           class$="file-row row [[_computePathClass(file.__path, _expandedFiles.*)]]"
-          data-file$="[[_computeFileRange(file)]]"
+          data-file$="[[_computePatchSetFile(file)]]"
           tabindex="-1"
           role="row"
         >
@@ -656,8 +657,9 @@
             display-line="[[_displayLine]]"
             hidden="[[!_isFileExpanded(file.__path, _expandedFiles.*)]]"
             change-num="[[changeNum]]"
+            change="[[change]]"
             patch-range="[[patchRange]]"
-            file="[[_computeFileRange(file)]]"
+            file="[[_computePatchSetFile(file)]]"
             path="[[file.__path]]"
             prefs="[[diffPrefs]]"
             project-name="[[change.project]]"
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
index 8aebf9b..c750bd2 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
@@ -16,6 +16,7 @@
  */
 
 import '../../../test/common-test-setup-karma.js';
+import {listenOnce} from '../../../test/test-utils.js';
 import '../../diff/gr-comment-api/gr-comment-api.js';
 import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
 import './gr-file-list.js';
@@ -114,7 +115,7 @@
       element.numFilesShown = 200;
       element.patchRange = {
         basePatchNum: 'PARENT',
-        patchNum: '2',
+        patchNum: 2,
       };
       saveStub = sinon.stub(element, '_saveReviewedState').callsFake(
           () => Promise.resolve());
@@ -312,7 +313,7 @@
 
       for (const bytes in table) {
         if (table.hasOwnProperty(bytes)) {
-          assert.equal(element._formatBytes(bytes), table[bytes]);
+          assert.equal(element._formatBytes(Number(bytes)), table[bytes]);
         }
       }
     });
@@ -354,36 +355,66 @@
     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'},
-          {patch_set: 2, message: 'hello', updated: '2017-02-10 16:40:49'},
+          {
+            patch_set: 1,
+            message: 'Done',
+            updated: '2017-02-08 16:40:49',
+            id: '1',
+          },
+          {
+            patch_set: 1,
+            message: 'oh hay',
+            updated: '2017-02-09 16:40:49',
+            id: '2',
+          },
+          {
+            patch_set: 2,
+            message: 'hello',
+            updated: '2017-02-10 16:40:49',
+            id: '3',
+          },
         ],
         'myfile.txt': [
-          {patch_set: 1, message: 'good news!', updated: '2017-02-08 16:40:49'},
-          {patch_set: 2, message: 'wat!?', updated: '2017-02-09 16:40:49'},
-          {patch_set: 2, message: 'hi', updated: '2017-02-10 16:40:49'},
+          {
+            patch_set: 1,
+            message: 'good news!',
+            updated: '2017-02-08 16:40:49',
+            id: '4',
+          },
+          {
+            patch_set: 2,
+            message: 'wat!?',
+            updated: '2017-02-09 16:40:49',
+            id: '5',
+          },
+          {
+            patch_set: 2,
+            message: 'hi',
+            updated: '2017-02-10 16:40:49',
+            id: '6',
+          },
         ],
         'unresolved.file': [
           {
             patch_set: 2,
             message: 'wat!?',
             updated: '2017-02-09 16:40:49',
-            id: '1',
+            id: '7',
             unresolved: true,
           },
           {
             patch_set: 2,
             message: 'hi',
             updated: '2017-02-10 16:40:49',
-            id: '2',
-            in_reply_to: '1',
+            id: '8',
+            in_reply_to: '7',
             unresolved: false,
           },
           {
             patch_set: 2,
             message: 'good news!',
             updated: '2017-02-08 16:40:49',
-            id: '3',
+            id: '9',
             unresolved: true,
           },
         ],
@@ -394,14 +425,14 @@
             patch_set: 1,
             message: 'hi',
             updated: '2017-02-15 16:40:49',
-            id: '5',
+            id: '10',
             unresolved: true,
           },
           {
             patch_set: 1,
             message: 'fyi',
             updated: '2017-02-15 16:40:49',
-            id: '6',
+            id: '11',
             unresolved: false,
           },
         ],
@@ -410,7 +441,7 @@
             patch_set: 1,
             message: 'hi',
             updated: '2017-02-11 16:40:49',
-            id: '4',
+            id: '12',
             unresolved: false,
           },
         ],
@@ -419,17 +450,17 @@
 
       const parentTo1 = {
         basePatchNum: 'PARENT',
-        patchNum: '1',
+        patchNum: 1,
       };
 
       const parentTo2 = {
         basePatchNum: 'PARENT',
-        patchNum: '2',
+        patchNum: 2,
       };
 
       const _1To2 = {
-        basePatchNum: '1',
-        patchNum: '2',
+        basePatchNum: 1,
+        patchNum: 2,
       };
 
       assert.equal(
@@ -569,10 +600,10 @@
               'file_added_in_rev2.txt', 'comment'), '');
       assert.equal(
           element._computeCommentsString(element.changeComments, parentTo2,
-              'unresolved.file', 'comment'), '3 comments (1 unresolved)');
+              'unresolved.file', 'comment'), '2 comments (1 unresolved)');
       assert.equal(
           element._computeCommentsString(element.changeComments, _1To2,
-              'unresolved.file', 'comment'), '3 comments (1 unresolved)');
+              'unresolved.file', 'comment'), '2 comments (1 unresolved)');
     });
 
     test('_reviewedTitle', () => {
@@ -593,7 +624,7 @@
         element.changeNum = '42';
         element.patchRange = {
           basePatchNum: 'PARENT',
-          patchNum: '2',
+          patchNum: 2,
         };
         element.change = {_number: 42};
         element.$.fileCursor.setCursorAtIndex(0);
@@ -613,7 +644,7 @@
       test('keyboard shortcuts', () => {
         flush();
 
-        const items = element.root.querySelectorAll('.file-row');
+        const items = [...element.root.querySelectorAll('.file-row')];
         element.$.fileCursor.stops = items;
         element.$.fileCursor.setCursorAtIndex(0);
         assert.equal(items.length, 3);
@@ -650,7 +681,7 @@
         MockInteractions.pressAndReleaseKeyOn(element, 79, null, 'o');
 
         assert(navStub.lastCall.calledWith(element.change,
-            'file_added_in_rev2.txt', '2'),
+            'file_added_in_rev2.txt', 2),
         'Should navigate to /c/42/2/file_added_in_rev2.txt');
 
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
@@ -669,7 +700,7 @@
         const paths = Object.keys(element._filesByPath);
         sinon.stub(element, '_expandedFilesChanged');
         flush();
-        const files = element.root.querySelectorAll('.file-row');
+        const files = [...element.root.querySelectorAll('.file-row')];
         element.$.fileCursor.stops = files;
         element.$.fileCursor.setCursorAtIndex(0);
         assert.equal(element.diffs.length, 0);
@@ -826,7 +857,7 @@
       element.changeNum = '42';
       element.patchRange = {
         basePatchNum: 'PARENT',
-        patchNum: '2',
+        patchNum: 2,
       };
       element.$.fileCursor.setCursorAtIndex(0);
       const reviewSpy = sinon.spy(element, '_reviewFile');
@@ -881,7 +912,7 @@
       element.changeNum = '42';
       element.patchRange = {
         basePatchNum: 'PARENT',
-        patchNum: '2',
+        patchNum: 2,
       };
 
       const clickSpy = sinon.spy(element, '_handleFileListClick');
@@ -916,7 +947,7 @@
       element.changeNum = '42';
       element.patchRange = {
         basePatchNum: 'PARENT',
-        patchNum: '2',
+        patchNum: 2,
       };
       element.editMode = true;
       flush();
@@ -937,7 +968,7 @@
       element.changeNum = '42';
       element.patchRange = {
         basePatchNum: 'PARENT',
-        patchNum: '2',
+        patchNum: 2,
       };
       element.$.fileCursor.setCursorAtIndex(0);
       sinon.stub(element, '_expandedFilesChanged');
@@ -964,7 +995,7 @@
       element.changeNum = '42';
       element.patchRange = {
         basePatchNum: 'PARENT',
-        patchNum: '2',
+        patchNum: 2,
       };
       sinon.spy(element, '_updateDiffPreferences');
       element.$.fileCursor.setCursorAtIndex(0);
@@ -998,7 +1029,7 @@
       element.changeNum = '42';
       element.patchRange = {
         basePatchNum: 'PARENT',
-        patchNum: '2',
+        patchNum: 2,
       };
       sinon.stub(element, '_expandedFilesChanged');
       flush();
@@ -1329,15 +1360,23 @@
 
   suite('size bars', () => {
     test('_computeSizeBarLayout', () => {
-      assert.isUndefined(element._computeSizeBarLayout(null));
-      assert.isUndefined(element._computeSizeBarLayout({}));
-      assert.deepEqual(element._computeSizeBarLayout({base: []}), {
+      const defaultSizeBarLayout = {
         maxInserted: 0,
         maxDeleted: 0,
         maxAdditionWidth: 0,
         maxDeletionWidth: 0,
         deletionOffset: 0,
-      });
+      };
+
+      assert.deepEqual(
+          element._computeSizeBarLayout(null),
+          defaultSizeBarLayout);
+      assert.deepEqual(
+          element._computeSizeBarLayout({}),
+          defaultSizeBarLayout);
+      assert.deepEqual(
+          element._computeSizeBarLayout({base: []}),
+          defaultSizeBarLayout);
 
       const files = [
         {__path: '/COMMIT_MSG', lines_inserted: 10000},
@@ -1459,6 +1498,7 @@
     const commitMsgComments = [
       {
         patch_set: 2,
+        path: '/p',
         id: 'ecf0b9fa_fe1a5f62',
         line: 20,
         updated: '2018-02-08 18:49:18.000000000',
@@ -1467,6 +1507,7 @@
       },
       {
         patch_set: 2,
+        path: '/p',
         id: '503008e2_0ab203ee',
         line: 10,
         updated: '2018-02-14 22:07:43.000000000',
@@ -1475,6 +1516,7 @@
       },
       {
         patch_set: 2,
+        path: '/p',
         id: 'cc788d2c_cb1d728c',
         line: 20,
         in_reply_to: 'ecf0b9fa_fe1a5f62',
@@ -1484,7 +1526,7 @@
       },
     ];
 
-    const setupDiff = function(diff) {
+    async function setupDiff(diff) {
       diff.comments = {
         left: diff.path === '/COMMIT_MSG' ? commitMsgComments : [],
         right: [],
@@ -1513,21 +1555,29 @@
         ignore_whitespace: 'IGNORE_NONE',
       };
       diff.diff = getMockDiffResponse();
-      diff.$.diff.flushDebouncer('renderDiffTable');
-    };
+      commentApiWrapper.loadComments().then(() => {
+        sinon.stub(element.changeComments, 'getCommentsBySideForPath')
+            .withArgs('/COMMIT_MSG', {
+              basePatchNum: 'PARENT',
+              patchNum: 2,
+            })
+            .returns(diff.comments);
+      });
+      await listenOnce(diff, 'render');
+    }
 
-    const renderAndGetNewDiffs = function(index) {
+    async function renderAndGetNewDiffs(index) {
       const diffs =
           element.root.querySelectorAll('gr-diff-host');
 
       for (let i = index; i < diffs.length; i++) {
-        setupDiff(diffs[i]);
+        await setupDiff(diffs[i]);
       }
 
       element._updateDiffCursor();
       element.$.diffCursor.handleDiffUpdate();
       return diffs;
-    };
+    }
 
     setup(done => {
       stub('gr-rest-api-interface', {
@@ -1585,16 +1635,16 @@
       element.changeNum = '42';
       element.patchRange = {
         basePatchNum: 'PARENT',
-        patchNum: '2',
+        patchNum: 2,
       };
       sinon.stub(window, 'fetch').callsFake(() => Promise.resolve());
       flush();
     });
 
-    test('cursor with individually opened files', () => {
+    test('cursor with individually opened files', async () => {
       MockInteractions.keyUpOn(element, 73, null, 'i');
       flush();
-      let diffs = renderAndGetNewDiffs(0);
+      let diffs = await renderAndGetNewDiffs(0);
       const diffStops = diffs[0].getCursorStops();
 
       // 1 diff should be rendered.
@@ -1621,7 +1671,7 @@
       MockInteractions.keyUpOn(element, 73, null, 'i');
       flush();
 
-      diffs = renderAndGetNewDiffs(1);
+      diffs = await renderAndGetNewDiffs(1);
       // Two diffs should be rendered.
       assert.equal(diffs.length, 2);
       const diffStopsFirst = diffs[0].getCursorStops();
@@ -1632,11 +1682,11 @@
       assert.isFalse(diffStopsSecond[10].classList.contains('target-row'));
     });
 
-    test('cursor with toggle all files', () => {
+    test('cursor with toggle all files', async () => {
       MockInteractions.keyUpOn(element, 73, 'shift', 'i');
       flush();
 
-      const diffs = renderAndGetNewDiffs(0);
+      const diffs = await renderAndGetNewDiffs(0);
       const diffStops = diffs[0].getCursorStops();
 
       // 1 diff should be rendered.
@@ -1813,10 +1863,10 @@
       assert.isTrue(editControls[0].classList.contains('invisible'));
     });
 
-    test('reloadCommentsForThreadWithRootId', () => {
+    test('reloadCommentsForThreadWithRootId', async () => {
       // Expand the commit message diff
       MockInteractions.keyUpOn(element, 73, 'shift', 'i');
-      const diffs = renderAndGetNewDiffs(0);
+      const diffs = await renderAndGetNewDiffs(0);
       flush();
 
       // Two comment threads should be generated by renderAndGetNewDiffs
@@ -1841,6 +1891,7 @@
       const commentStubRes1 = [
         {
           patch_set: 2,
+          path: '/p',
           id: '503008e2_0ab203ee',
           line: 20,
           updated: '2018-02-08 18:49:18.000000000',
@@ -1851,6 +1902,7 @@
       const commentStubRes2 = [
         {
           patch_set: 2,
+          path: '/p',
           id: 'ecf0b9fa_fe1a5f62',
           line: 20,
           updated: '2018-02-08 18:49:18.000000000',
@@ -1859,6 +1911,7 @@
         },
         {
           patch_set: 2,
+          path: '/p',
           id: '503008e2_0ab203ee',
           line: 10,
           in_reply_to: 'ecf0b9fa_fe1a5f62',
@@ -1868,6 +1921,7 @@
         },
         {
           patch_set: 2,
+          path: '/p',
           id: '503008e2_0ab203ef',
           line: 20,
           in_reply_to: '503008e2_0ab203ee',
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
index f5e3588..1957f5c 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
@@ -65,7 +65,7 @@
 
   loadData() {
     if (!this.changeNum) {
-      return;
+      return Promise.reject(new Error('missing required property changeNum'));
     }
     this._filterText = '';
     return this.$.restAPI.getChangeIncludedIn(this.changeNum).then(configs => {
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
index 8202328..60a6058 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
@@ -133,14 +133,12 @@
     ) {
       return [];
     }
-    const startPosition = this.labelValues[
-      parseInt(permittedLabels[label][0], 10)
-    ];
+    const startPosition = this.labelValues[Number(permittedLabels[label][0])];
     if (side === 'start') {
       return new Array(startPosition);
     }
     const endPosition = this.labelValues[
-      parseInt(permittedLabels[label][permittedLabels[label].length - 1], 10)
+      Number(permittedLabels[label][permittedLabels[label].length - 1])
     ];
     return new Array(Object.keys(this.labelValues).length - endPosition - 1);
   }
@@ -159,8 +157,7 @@
       // default_value is an int, convert it to string label, e.g. "+1".
       return permittedLabels[label.name].find(
         value =>
-          parseInt(value, 10) ===
-          (labels[label.name] as QuickLabelInfo).default_value
+          Number(value) === (labels[label.name] as QuickLabelInfo).default_value
       );
     }
     return;
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
index 245c65f..d528192 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
@@ -61,7 +61,7 @@
   @property({type: Object})
   _labelValues?: LabelValuesMap;
 
-  getLabelValues(): LabelNameToValuesMap {
+  getLabelValues(includeDefaults = true): LabelNameToValuesMap {
     const labels: LabelNameToValuesMap = {};
     if (this.shadowRoot === null || !this.change) {
       return labels;
@@ -85,7 +85,7 @@
 
       const selectedVal =
         typeof selectorEl.selectedValue === 'string'
-          ? parseInt(selectorEl.selectedValue, 10)
+          ? Number(selectorEl.selectedValue)
           : selectorEl.selectedValue;
 
       if (selectedVal === undefined) {
@@ -101,13 +101,17 @@
 
       let prevValNum: number | null | undefined;
       if (typeof prevVal === 'string') {
-        prevValNum = parseInt(prevVal, 10);
+        prevValNum = Number(prevVal);
       } else {
         prevValNum = prevVal;
       }
 
+      const defValNum = this._getDefaultValue(this.change.labels, label);
+
       if (selectedVal !== prevValNum) {
-        labels[label] = selectedVal;
+        if (includeDefaults || !!prevValNum || selectedVal !== defValNum) {
+          labels[label] = selectedVal;
+        }
       }
     }
     return labels;
@@ -119,13 +123,19 @@
     numberValue?: number
   ) {
     for (const k in (labels[labelName] as DetailedLabelInfo).values) {
-      if (parseInt(k, 10) === numberValue) {
+      if (Number(k) === numberValue) {
         return k;
       }
     }
     return numberValue;
   }
 
+  _getDefaultValue(labels?: LabelNameToInfoMap, labelName?: string) {
+    if (!labelName || !labels?.[labelName]) return undefined;
+    const labelInfo = labels[labelName] as DetailedLabelInfo;
+    return labelInfo.default_value;
+  }
+
   _getVoteForAccount(
     labels: LabelNameToInfoMap | undefined,
     labelName: string,
@@ -181,7 +191,7 @@
     const values: Set<number> = new Set();
     for (const label of labels) {
       for (const value of permittedLabels[label]) {
-        values.add(parseInt(value, 10));
+        values.add(Number(value));
       }
     }
 
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.ts
index 974e55d..7b1fb7f 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.ts
@@ -34,7 +34,7 @@
       display: table-row;
     }
     gr-label-score-row.no-access {
-      display: var(--label-no-access-display, table-row);
+      display: none;
     }
   </style>
   <div class="scoresTable">
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
index ffc17cd..ae639e1 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
@@ -99,6 +99,22 @@
     });
   });
 
+  test('getLabelValues includeDefaults', async () => {
+    element.change = {
+      _number: '123',
+      labels: {
+        'Code-Review': {
+          values: {'0': 'meh', '+1': 'good', '-1': 'bad'},
+          default_value: 0,
+        },
+      },
+    };
+    await flush();
+
+    assert.deepEqual(element.getLabelValues(true), {'Code-Review': 0});
+    assert.deepEqual(element.getLabelValues(false), {});
+  });
+
   test('_getVoteForAccount', () => {
     const labelName = 'Code-Review';
     assert.strictEqual(element._getVoteForAccount(
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index 87d443b..8b0cf4b 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -39,9 +39,10 @@
   ReviewInputTag,
   VotingRangeInfo,
   NumericChangeId,
+  ChangeMessageId,
 } from '../../../types/common';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {CommentThread} from '../../diff/gr-comment-api/gr-comment-api';
+import {CommentThread} from '../../../utils/comment-util';
 import {hasOwnProperty} from '../../../utils/common-util';
 
 const PATCH_SET_PREFIX_PATTERN = /^(?:Uploaded\s*)?(?:P|p)atch (?:S|s)et \d+:\s*(.*)/;
@@ -53,6 +54,10 @@
   }
 }
 
+export interface MessageAnchorTapDetail {
+  id: ChangeMessageId;
+}
+
 export interface GrMessage {
   $: {
     restAPI: RestApiService & Element;
@@ -438,11 +443,16 @@
 
   _handleAnchorClick(e: Event) {
     e.preventDefault();
+    // The element which triggers _handleAnchorClick is rendered only if
+    // message.id defined: the elemenet is wrapped in dom-if if="[[message.id]]"
+    const detail: MessageAnchorTapDetail = {
+      id: this.message!.id,
+    };
     this.dispatchEvent(
       new CustomEvent('message-anchor-tap', {
         bubbles: true,
         composed: true,
-        detail: {id: this.message?.id},
+        detail,
       })
     );
   }
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
deleted file mode 100644
index bed78df..0000000
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
+++ /dev/null
@@ -1,433 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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.
- */
-import '@polymer/paper-toggle-button/paper-toggle-button.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../gr-message/gr-message.js';
-import '../../../styles/shared-styles.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-messages-list_html.js';
-import {
-  KeyboardShortcutMixin,
-  Shortcut, ShortcutSection,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {parseDate} from '../../../utils/date-util.js';
-import {MessageTag} from '../../../constants/constants.js';
-import {appContext} from '../../../services/app-context.js';
-
-/**
- * The content of the enum is also used in the UI for the button text.
- *
- * @enum {string}
- */
-const ExpandAllState = {
-  EXPAND_ALL: 'Expand All',
-  COLLAPSE_ALL: 'Collapse All',
-};
-
-/**
- * Computes message author's comments for this change message. The backend
- * sets comment.change_message_id for matching, so this computation is fairly
- * straightforward.
- */
-function computeThreads(message, allMessages, changeComments) {
-  if ([message, allMessages, changeComments].includes(undefined)) {
-    return [];
-  }
-  if (message._index === undefined) {
-    return [];
-  }
-
-  return changeComments.getAllThreadsForChange().filter(
-      thread => thread.comments.map(comment => {
-        // collapse all by default
-        comment.collapsed = true;
-        return comment;
-      }).some(comment => {
-        const condition = comment.change_message_id === message.id;
-        // Since getAllThreadsForChange() always returns a new copy of
-        // all comments we can modify them here without worrying about
-        // polluting other threads.
-        comment.collapsed = !condition;
-        return condition;
-      })
-  );
-}
-
-/**
- * If messages have the same tag, then that influences grouping and whether
- * a message is initally hidden or not, see isImportant(). So we are applying
- * some "magic" rules here in order to hide exactly the right messages.
- *
- * 1. If a message does not have a tag, but is associated with robot comments,
- * then it gets a tag.
- *
- * 2. Use the same tag for some of Gerrit's standard events, if they should be
- * considered one group, e.g. normal and wip patchset uploads.
- *
- * 3. Everything beyond the ~ character is cut off from the tag. That gives
- * tools control over which messages will be hidden.
- */
-function computeTag(message) {
-  if (!message.tag) {
-    const threads = message.commentThreads || [];
-    const comments = threads.map(
-        t => t.comments.find(c => c.change_message_id === message.id));
-    const isRobot = comments.some(c => c && !!c.robot_id);
-    return isRobot ? 'autogenerated:has-robot-comments' : undefined;
-  }
-
-  if (message.tag === MessageTag.TAG_NEW_WIP_PATCHSET) {
-    return MessageTag.TAG_NEW_PATCHSET;
-  }
-  if (message.tag === MessageTag.TAG_UNSET_ASSIGNEE) {
-    return MessageTag.TAG_SET_ASSIGNEE;
-  }
-  if (message.tag === MessageTag.TAG_UNSET_PRIVATE) {
-    return MessageTag.TAG_SET_PRIVATE;
-  }
-  if (message.tag === MessageTag.TAG_SET_WIP) {
-    return MessageTag.TAG_SET_READY;
-  }
-
-  return message.tag.replace(/~.*/, '');
-}
-
-/**
- * Try to set a revision number that makes sense, if none is set. Just copy
- * over the revision number of the next older message. This is mostly relevant
- * for reviewer updates. Other messages should typically have the revision
- * number already set.
- */
-function computeRevision(message, allMessages) {
-  if (message._revision_number > 0) return message._revision_number;
-  let revision = 0;
-  for (const m of allMessages) {
-    if (m.date > message.date) break;
-    if (m._revision_number > revision) revision = m._revision_number;
-  }
-  return revision > 0 ? revision : undefined;
-}
-
-/**
- * Unimportant messages are initially hidden.
- *
- * Human messages are always important. They have an undefined tag.
- *
- * Autogenerated messages are unimportant, if there is a message with the same
- * tag and a higher revision number.
- */
-function computeIsImportant(message, allMessages) {
-  if (!message.tag) return true;
-
-  const hasSameTag = m => m.tag === message.tag;
-  const revNumber = message._revision_number || 0;
-  const hasHigherRevisionNumber = m => m._revision_number > revNumber;
-  return !allMessages.filter(hasSameTag).some(hasHigherRevisionNumber);
-}
-
-export const TEST_ONLY = {
-  computeThreads,
-  computeTag,
-  computeRevision,
-  computeIsImportant,
-};
-
-/**
- * @extends PolymerElement
- */
-class GrMessagesList extends KeyboardShortcutMixin(
-    GestureEventListeners(
-        LegacyElementMixin(
-            PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-messages-list'; }
-
-  static get properties() {
-    return {
-      /** @type {?} */
-      change: Object,
-      changeNum: Number,
-      /**
-       * These are just the change messages. They are combined with reviewer
-       * updates below. So _combinedMessages is the more important property.
-       */
-      messages: {
-        type: Array,
-        value() { return []; },
-      },
-      /**
-       * These are just the reviewer updates. They are combined with change
-       * messages above. So _combinedMessages is the more important property.
-       */
-      reviewerUpdates: {
-        type: Array,
-        value() { return []; },
-      },
-      changeComments: Object,
-      projectName: String,
-      showReplyButtons: {
-        type: Boolean,
-        value: false,
-      },
-      labels: Object,
-
-      /**
-       * Keeps track of the state of the "Expand All" toggle button. Note that
-       * you can individually expand/collapse some messages without affecting
-       * the toggle button's state.
-       *
-       * @type {ExpandAllState}
-       */
-      _expandAllState: {
-        type: String,
-        value: ExpandAllState.EXPAND_ALL,
-      },
-      _expandAllTitle: {
-        type: String,
-        computed: '_computeExpandAllTitle(_expandAllState)',
-      },
-
-      _showAllActivity: {
-        type: Boolean,
-        value: false,
-        observer: '_observeShowAllActivity',
-      },
-      /**
-       * The merged array of change messages and reviewer updates.
-       */
-      _combinedMessages: {
-        type: Array,
-        computed: '_computeCombinedMessages(messages, reviewerUpdates, '
-            + 'changeComments)',
-        observer: '_combinedMessagesChanged',
-      },
-
-      _labelExtremes: {
-        type: Object,
-        computed: '_computeLabelExtremes(labels.*)',
-      },
-    };
-  }
-
-  constructor() {
-    super();
-    this.reporting = appContext.reportingService;
-  }
-
-  scrollToMessage(messageID) {
-    const selector = `[data-message-id="${messageID}"]`;
-    const el = this.shadowRoot.querySelector(selector);
-
-    if (!el && this._showAllActivity) {
-      console.warn(`Failed to scroll to message: ${messageID}`);
-      return;
-    }
-    if (!el) {
-      this._showAllActivity = true;
-      setTimeout(() => this.scrollToMessage(messageID));
-      return;
-    }
-
-    el.set('message.expanded', true);
-    let top = el.offsetTop;
-    for (let offsetParent = el.offsetParent;
-      offsetParent;
-      offsetParent = offsetParent.offsetParent) {
-      top += offsetParent.offsetTop;
-    }
-    window.scrollTo(0, top);
-    this._highlightEl(el);
-  }
-
-  _observeShowAllActivity(showAllActivity) {
-    // We have to call render() such that the dom-repeat filter picks up the
-    // change.
-    this.$.messageRepeat.render();
-  }
-
-  /**
-   * Filter for the dom-repeat of combinedMessages.
-   */
-  _isMessageVisible(message) {
-    return this._showAllActivity || message.isImportant;
-  }
-
-  /**
-   * Merges change messages and reviewer updates into one array. Also processes
-   * all messages and updates, aligns or massages some of the properties.
-   */
-  _computeCombinedMessages(messages, reviewerUpdates, changeComments) {
-    const params = [messages, reviewerUpdates, changeComments];
-    if (params.some(o => o === undefined)) return [];
-
-    let mi = 0;
-    let ri = 0;
-    let combinedMessages = [];
-    let mDate;
-    let rDate;
-    for (let i = 0; i < messages.length; i++) {
-      messages[i]._index = i;
-    }
-
-    while (mi < messages.length || ri < reviewerUpdates.length) {
-      if (mi >= messages.length) {
-        combinedMessages = combinedMessages.concat(reviewerUpdates.slice(ri));
-        break;
-      }
-      if (ri >= reviewerUpdates.length) {
-        combinedMessages = combinedMessages.concat(messages.slice(mi));
-        break;
-      }
-      mDate = mDate || parseDate(messages[mi].date);
-      rDate = rDate || parseDate(reviewerUpdates[ri].date);
-      if (rDate < mDate) {
-        combinedMessages.push(reviewerUpdates[ri++]);
-        rDate = null;
-      } else {
-        combinedMessages.push(messages[mi++]);
-        mDate = null;
-      }
-    }
-    combinedMessages.forEach(m => {
-      if (m.expanded === undefined) {
-        m.expanded = false;
-      }
-      m.commentThreads = computeThreads(m, combinedMessages, changeComments);
-      m._revision_number = computeRevision(m, combinedMessages);
-      m.tag = computeTag(m);
-    });
-    // computeIsImportant() depends on tags and revision numbers already being
-    // updated for all messages, so we have to compute this in its own forEach
-    // loop.
-    combinedMessages.forEach(m => {
-      m.isImportant = computeIsImportant(m, combinedMessages);
-    });
-    return combinedMessages;
-  }
-
-  _updateExpandedStateOfAllMessages(exp) {
-    if (this._combinedMessages) {
-      for (let i = 0; i < this._combinedMessages.length; i++) {
-        this._combinedMessages[i].expanded = exp;
-        this.notifyPath(`_combinedMessages.${i}.expanded`);
-      }
-    }
-  }
-
-  _computeExpandAllTitle(_expandAllState) {
-    if (_expandAllState === ExpandAllState.COLLAPSE_ALL) {
-      return this.createTitle(
-          Shortcut.COLLAPSE_ALL_MESSAGES, ShortcutSection.ACTIONS);
-    }
-    if (_expandAllState === ExpandAllState.EXPAND_ALL) {
-      return this.createTitle(
-          Shortcut.EXPAND_ALL_MESSAGES, ShortcutSection.ACTIONS);
-    }
-    return '';
-  }
-
-  _highlightEl(el) {
-    const highlightedEls =
-        this.root.querySelectorAll('.highlighted');
-    for (const highlightedEl of highlightedEls) {
-      highlightedEl.classList.remove('highlighted');
-    }
-    function handleAnimationEnd() {
-      el.removeEventListener('animationend', handleAnimationEnd);
-      el.classList.remove('highlighted');
-    }
-    el.addEventListener('animationend', handleAnimationEnd);
-    el.classList.add('highlighted');
-  }
-
-  /**
-   * @param {boolean} expand
-   */
-  handleExpandCollapse(expand) {
-    this._expandAllState = expand ? ExpandAllState.COLLAPSE_ALL
-      : ExpandAllState.EXPAND_ALL;
-    this._updateExpandedStateOfAllMessages(expand);
-  }
-
-  _handleExpandCollapseTap(e) {
-    e.preventDefault();
-    this.handleExpandCollapse(
-        this._expandAllState === ExpandAllState.EXPAND_ALL);
-  }
-
-  _handleAnchorClick(e) {
-    this.scrollToMessage(e.detail.id);
-  }
-
-  _isVisibleShowAllActivityToggle(messages = []) {
-    return messages.some(m => !m.isImportant);
-  }
-
-  _computeHiddenEntriesCount(messages = []) {
-    return messages.filter(m => !m.isImportant).length;
-  }
-
-  /**
-   * This method is for reporting stats only.
-   */
-  _combinedMessagesChanged(combinedMessages) {
-    if (combinedMessages) {
-      if (combinedMessages.length === 0) return;
-      const tags = combinedMessages.map(
-          message => message.tag || message.type ||
-              (message.comments ? 'comments' : 'none'));
-      const tagsCounted = tags.reduce((acc, val) => {
-        acc[val] = (acc[val] || 0) + 1;
-        return acc;
-      }, {all: combinedMessages.length});
-      this.reporting.reportInteraction('messages-count', tagsCounted);
-    }
-  }
-
-  /**
-   * Compute a mapping from label name to objects representing the minimum and
-   * maximum possible values for that label.
-   */
-  _computeLabelExtremes(labelRecord) {
-    const extremes = {};
-    const labels = labelRecord.base;
-    if (!labels) { return extremes; }
-    for (const key of Object.keys(labels)) {
-      if (!labels[key] || !labels[key].values) { continue; }
-      const values = Object.keys(labels[key].values)
-          .map(v => parseInt(v, 10));
-      values.sort((a, b) => a - b);
-      if (!values.length) { continue; }
-      extremes[key] = {min: values[0], max: values[values.length - 1]};
-    }
-    return extremes;
-  }
-
-  /**
-   * Work around a issue on iOS when clicking turns into double tap
-   */
-  _onTapShowAllActivityToggle(e) {
-    e.preventDefault();
-  }
-}
-
-customElements.define(GrMessagesList.is,
-    GrMessagesList);
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
new file mode 100644
index 0000000..8557c10
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -0,0 +1,495 @@
+/**
+ * @license
+ * Copyright (C) 2020 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.
+ */
+import '@polymer/paper-toggle-button/paper-toggle-button';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icons/gr-icons';
+import '../gr-message/gr-message';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-messages-list_html';
+import {
+  KeyboardShortcutMixin,
+  Shortcut,
+  ShortcutSection,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {parseDate} from '../../../utils/date-util';
+import {MessageTag} from '../../../constants/constants';
+import {appContext} from '../../../services/app-context';
+import {customElement, property} from '@polymer/decorators';
+import {
+  ChangeId,
+  ChangeMessageId,
+  ChangeMessageInfo,
+  ChangeViewChangeInfo,
+  LabelNameToInfoMap,
+  NumericChangeId,
+  PatchSetNum,
+  RepoName,
+  ReviewerUpdateInfo,
+  VotingRangeInfo,
+} from '../../../types/common';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
+import {CommentThread, isRobot} from '../../../utils/comment-util';
+import {GrMessage, MessageAnchorTapDetail} from '../gr-message/gr-message';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {FormattedReviewerUpdateInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {DomRepeat} from '@polymer/polymer/lib/elements/dom-repeat';
+import {getVotingRange} from '../../../utils/label-util';
+
+/**
+ * The content of the enum is also used in the UI for the button text.
+ */
+enum ExpandAllState {
+  EXPAND_ALL = 'Expand All',
+  COLLAPSE_ALL = 'Collapse All',
+}
+
+interface TagsCountReportInfo {
+  [tag: string]: number;
+  all: number;
+}
+
+type CombinedMessage = Omit<
+  FormattedReviewerUpdateInfo | ChangeMessageInfo,
+  'tag'
+> & {
+  _revision_number?: PatchSetNum;
+  _index?: number;
+  expanded?: boolean;
+  isImportant?: boolean;
+  commentThreads?: CommentThread[];
+  tag?: string;
+};
+
+function isChangeMessageInfo(x: CombinedMessage): x is ChangeMessageInfo {
+  return (x as ChangeMessageInfo).id !== undefined;
+}
+
+function getMessageId(x: CombinedMessage): ChangeMessageId | undefined {
+  return isChangeMessageInfo(x) ? x.id : undefined;
+}
+
+/**
+ * Computes message author's comments for this change message. The backend
+ * sets comment.change_message_id for matching, so this computation is fairly
+ * straightforward.
+ */
+function computeThreads(
+  message: CombinedMessage,
+  changeComments: ChangeComments
+): CommentThread[] {
+  if (message._index === undefined) {
+    return [];
+  }
+  const messageId = getMessageId(message);
+  return changeComments.getAllThreadsForChange().filter(thread =>
+    thread.comments
+      .map(comment => {
+        // collapse all by default
+        comment.collapsed = true;
+        return comment;
+      })
+      .some(comment => {
+        const condition = comment.change_message_id === messageId;
+        // Since getAllThreadsForChange() always returns a new copy of
+        // all comments we can modify them here without worrying about
+        // polluting other threads.
+        comment.collapsed = !condition;
+        return condition;
+      })
+  );
+}
+
+/**
+ * If messages have the same tag, then that influences grouping and whether
+ * a message is initally hidden or not, see isImportant(). So we are applying
+ * some "magic" rules here in order to hide exactly the right messages.
+ *
+ * 1. If a message does not have a tag, but is associated with robot comments,
+ * then it gets a tag.
+ *
+ * 2. Use the same tag for some of Gerrit's standard events, if they should be
+ * considered one group, e.g. normal and wip patchset uploads.
+ *
+ * 3. Everything beyond the ~ character is cut off from the tag. That gives
+ * tools control over which messages will be hidden.
+ */
+function computeTag(message: CombinedMessage) {
+  if (!message.tag) {
+    const threads = message.commentThreads || [];
+    const messageId = getMessageId(message);
+    const comments = threads.map(t =>
+      t.comments.find(c => c.change_message_id === messageId)
+    );
+    const hasRobotComments = comments.some(isRobot);
+    return hasRobotComments ? 'autogenerated:has-robot-comments' : undefined;
+  }
+
+  if (message.tag === MessageTag.TAG_NEW_WIP_PATCHSET) {
+    return MessageTag.TAG_NEW_PATCHSET;
+  }
+  if (message.tag === MessageTag.TAG_UNSET_ASSIGNEE) {
+    return MessageTag.TAG_SET_ASSIGNEE;
+  }
+  if (message.tag === MessageTag.TAG_UNSET_PRIVATE) {
+    return MessageTag.TAG_SET_PRIVATE;
+  }
+  if (message.tag === MessageTag.TAG_SET_WIP) {
+    return MessageTag.TAG_SET_READY;
+  }
+
+  return message.tag.replace(/~.*/, '');
+}
+
+/**
+ * Try to set a revision number that makes sense, if none is set. Just copy
+ * over the revision number of the next older message. This is mostly relevant
+ * for reviewer updates. Other messages should typically have the revision
+ * number already set.
+ */
+function computeRevision(
+  message: CombinedMessage,
+  allMessages: CombinedMessage[]
+): PatchSetNum | undefined {
+  if (message._revision_number && message._revision_number > 0)
+    return message._revision_number;
+  let revision: PatchSetNum = 0 as PatchSetNum;
+  for (const m of allMessages) {
+    if (m.date > message.date) break;
+    if (m._revision_number && m._revision_number > revision)
+      revision = m._revision_number;
+  }
+  return revision > 0 ? revision : undefined;
+}
+
+/**
+ * Unimportant messages are initially hidden.
+ *
+ * Human messages are always important. They have an undefined tag.
+ *
+ * Autogenerated messages are unimportant, if there is a message with the same
+ * tag and a higher revision number.
+ */
+function computeIsImportant(
+  message: CombinedMessage,
+  allMessages: CombinedMessage[]
+) {
+  if (!message.tag) return true;
+
+  const hasSameTag = (m: CombinedMessage) => m.tag === message.tag;
+  const revNumber = message._revision_number || 0;
+  const hasHigherRevisionNumber = (m: CombinedMessage) =>
+    (m._revision_number || 0) > revNumber;
+  return !allMessages.filter(hasSameTag).some(hasHigherRevisionNumber);
+}
+
+export const TEST_ONLY = {
+  computeThreads,
+  computeTag,
+  computeRevision,
+  computeIsImportant,
+};
+
+export interface GrMessagesList {
+  $: {
+    messageRepeat: DomRepeat;
+  };
+}
+
+@customElement('gr-messages-list')
+export class GrMessagesList extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object})
+  change?: ChangeViewChangeInfo;
+
+  @property({type: String})
+  changeNum?: ChangeId | NumericChangeId;
+
+  @property({type: Array})
+  messages: ChangeMessageInfo[] = [];
+
+  @property({type: Array})
+  reviewerUpdates: ReviewerUpdateInfo[] = [];
+
+  @property({type: Object})
+  changeComments?: ChangeComments;
+
+  @property({type: String})
+  projectName?: RepoName;
+
+  @property({type: Boolean})
+  showReplyButtons = false;
+
+  @property({type: Object})
+  labels?: LabelNameToInfoMap;
+
+  @property({type: String})
+  _expandAllState = ExpandAllState.EXPAND_ALL;
+
+  @property({type: String, computed: '_computeExpandAllTitle(_expandAllState)'})
+  _expandAllTitle = '';
+
+  @property({type: Boolean, observer: '_observeShowAllActivity'})
+  _showAllActivity = false;
+
+  @property({
+    type: Array,
+    computed:
+      '_computeCombinedMessages(messages, reviewerUpdates, ' +
+      'changeComments)',
+    observer: '_combinedMessagesChanged',
+  })
+  _combinedMessages: CombinedMessage[] = [];
+
+  @property({type: Object, computed: '_computeLabelExtremes(labels.*)'})
+  _labelExtremes: {[lableName: string]: VotingRangeInfo} = {};
+
+  private readonly reporting = appContext.reportingService;
+
+  scrollToMessage(messageID: string) {
+    const selector = `[data-message-id="${messageID}"]`;
+    const el = this.shadowRoot!.querySelector(selector) as
+      | GrMessage
+      | undefined;
+
+    if (!el && this._showAllActivity) {
+      console.warn(`Failed to scroll to message: ${messageID}`);
+      return;
+    }
+    if (!el) {
+      this._showAllActivity = true;
+      setTimeout(() => this.scrollToMessage(messageID));
+      return;
+    }
+
+    el.set('message.expanded', true);
+    let top = el.offsetTop;
+    for (
+      let offsetParent = el.offsetParent as HTMLElement | null;
+      offsetParent;
+      offsetParent = offsetParent.offsetParent as HTMLElement | null
+    ) {
+      top += offsetParent.offsetTop;
+    }
+    window.scrollTo(0, top);
+    this._highlightEl(el);
+  }
+
+  _observeShowAllActivity() {
+    // We have to call render() such that the dom-repeat filter picks up the
+    // change.
+    this.$.messageRepeat.render();
+  }
+
+  /**
+   * Filter for the dom-repeat of combinedMessages.
+   */
+  _isMessageVisible(message: CombinedMessage) {
+    return this._showAllActivity || message.isImportant;
+  }
+
+  /**
+   * Merges change messages and reviewer updates into one array. Also processes
+   * all messages and updates, aligns or massages some of the properties.
+   */
+  _computeCombinedMessages(
+    messages?: ChangeMessageInfo[],
+    reviewerUpdates?: FormattedReviewerUpdateInfo[],
+    changeComments?: ChangeComments
+  ) {
+    if (
+      messages === undefined ||
+      reviewerUpdates === undefined ||
+      changeComments === undefined
+    )
+      return [];
+
+    let mi = 0;
+    let ri = 0;
+    let combinedMessages: CombinedMessage[] = [];
+    let mDate;
+    let rDate;
+    for (let i = 0; i < messages.length; i++) {
+      // TODO(TS): clone message instead and avoid API object mutation
+      (messages[i] as CombinedMessage)._index = i;
+    }
+
+    while (mi < messages.length || ri < reviewerUpdates.length) {
+      if (mi >= messages.length) {
+        combinedMessages = combinedMessages.concat(reviewerUpdates.slice(ri));
+        break;
+      }
+      if (ri >= reviewerUpdates.length) {
+        combinedMessages = combinedMessages.concat(messages.slice(mi));
+        break;
+      }
+      mDate = mDate || parseDate(messages[mi].date);
+      rDate = rDate || parseDate(reviewerUpdates[ri].date);
+      if (rDate < mDate) {
+        combinedMessages.push(reviewerUpdates[ri++]);
+        rDate = null;
+      } else {
+        combinedMessages.push(messages[mi++]);
+        mDate = null;
+      }
+    }
+    combinedMessages.forEach(m => {
+      if (m.expanded === undefined) {
+        m.expanded = false;
+      }
+      m.commentThreads = computeThreads(m, changeComments);
+      m._revision_number = computeRevision(m, combinedMessages);
+      m.tag = computeTag(m);
+    });
+    // computeIsImportant() depends on tags and revision numbers already being
+    // updated for all messages, so we have to compute this in its own forEach
+    // loop.
+    combinedMessages.forEach(m => {
+      m.isImportant = computeIsImportant(m, combinedMessages);
+    });
+    return combinedMessages;
+  }
+
+  _updateExpandedStateOfAllMessages(exp: boolean) {
+    if (this._combinedMessages) {
+      for (let i = 0; i < this._combinedMessages.length; i++) {
+        this._combinedMessages[i].expanded = exp;
+        this.notifyPath(`_combinedMessages.${i}.expanded`);
+      }
+    }
+  }
+
+  _computeExpandAllTitle(_expandAllState?: string) {
+    if (_expandAllState === ExpandAllState.COLLAPSE_ALL) {
+      return this.createTitle(
+        Shortcut.COLLAPSE_ALL_MESSAGES,
+        ShortcutSection.ACTIONS
+      );
+    }
+    if (_expandAllState === ExpandAllState.EXPAND_ALL) {
+      return this.createTitle(
+        Shortcut.EXPAND_ALL_MESSAGES,
+        ShortcutSection.ACTIONS
+      );
+    }
+    return '';
+  }
+
+  _highlightEl(el: HTMLElement) {
+    const highlightedEls = this.root!.querySelectorAll('.highlighted');
+    for (const highlightedEl of highlightedEls) {
+      highlightedEl.classList.remove('highlighted');
+    }
+    function handleAnimationEnd() {
+      el.removeEventListener('animationend', handleAnimationEnd);
+      el.classList.remove('highlighted');
+    }
+    el.addEventListener('animationend', handleAnimationEnd);
+    el.classList.add('highlighted');
+  }
+
+  handleExpandCollapse(expand: boolean) {
+    this._expandAllState = expand
+      ? ExpandAllState.COLLAPSE_ALL
+      : ExpandAllState.EXPAND_ALL;
+    this._updateExpandedStateOfAllMessages(expand);
+  }
+
+  _handleExpandCollapseTap(e: Event) {
+    e.preventDefault();
+    this.handleExpandCollapse(
+      this._expandAllState === ExpandAllState.EXPAND_ALL
+    );
+  }
+
+  _handleAnchorClick(e: CustomEvent<MessageAnchorTapDetail>) {
+    this.scrollToMessage(e.detail.id);
+  }
+
+  _isVisibleShowAllActivityToggle(messages: CombinedMessage[] = []) {
+    return messages.some(m => !m.isImportant);
+  }
+
+  _computeHiddenEntriesCount(messages: CombinedMessage[] = []) {
+    return messages.filter(m => !m.isImportant).length;
+  }
+
+  /**
+   * This method is for reporting stats only.
+   */
+  _combinedMessagesChanged(combinedMessages?: CombinedMessage[]) {
+    if (combinedMessages) {
+      if (combinedMessages.length === 0) return;
+      const tags = combinedMessages.map(
+        message =>
+          message.tag || (message as FormattedReviewerUpdateInfo).type || 'none'
+      );
+      const tagsCounted = tags.reduce(
+        (acc, val) => {
+          acc[val] = (acc[val] || 0) + 1;
+          return acc;
+        },
+        {all: combinedMessages.length} as TagsCountReportInfo
+      );
+      this.reporting.reportInteraction('messages-count', tagsCounted);
+    }
+  }
+
+  /**
+   * Compute a mapping from label name to objects representing the minimum and
+   * maximum possible values for that label.
+   */
+  _computeLabelExtremes(
+    labelRecord: PolymerDeepPropertyChange<
+      LabelNameToInfoMap,
+      LabelNameToInfoMap
+    >
+  ) {
+    const extremes: {[lableName: string]: VotingRangeInfo} = {};
+    const labels = labelRecord.base;
+    if (!labels) {
+      return extremes;
+    }
+    for (const key of Object.keys(labels)) {
+      const range = getVotingRange(labels[key]);
+      if (range) {
+        extremes[key] = range;
+      }
+    }
+    return extremes;
+  }
+
+  /**
+   * Work around a issue on iOS when clicking turns into double tap
+   */
+  _onTapShowAllActivityToggle(e: Event) {
+    e.preventDefault();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-messages-list': GrMessagesList;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index de56e9d..f42adc6 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -97,16 +97,24 @@
   PolymerSplice,
   PolymerSpliceChange,
 } from '@polymer/polymer/interfaces';
-import {assertNever} from '../../../utils/common-util';
-import {CommentThread, isDraft} from '../../diff/gr-comment-api/gr-comment-api';
+import {
+  areSetsEqual,
+  assertNever,
+  containsAll,
+} from '../../../utils/common-util';
+import {CommentThread} from '../../../utils/comment-util';
 import {GrTextarea} from '../../shared/gr-textarea/gr-textarea';
 import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrStorage, StorageLocation} from '../../shared/gr-storage/gr-storage';
+import {isAttentionSetEnabled} from '../../../utils/attention-set-util';
+import {CODE_REVIEW, getMaxAccounts} from '../../../utils/label-util';
+import {isUnresolved} from '../../../utils/comment-util';
+import {fireAlert} from '../../../utils/event-util';
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
-enum FocusTarget {
+export enum FocusTarget {
   ANY = 'any',
   BODY = 'body',
   CCS = 'cc',
@@ -233,6 +241,12 @@
   @property({type: Boolean, reflectToAttribute: true})
   disabled = false;
 
+  @property({
+    type: Boolean,
+    computed: '_computeHasDrafts(draft, draftCommentThreads.*)',
+  })
+  hasDrafts = false;
+
   @property({type: String, observer: '_draftChanged'})
   draft = '';
 
@@ -240,10 +254,10 @@
   quote = '';
 
   @property({type: Object})
-  filterReviewerSuggestion: () => (input: Suggestion) => boolean;
+  filterReviewerSuggestion: (input: Suggestion) => boolean;
 
   @property({type: Object})
-  filterCCSuggestion: () => (input: Suggestion) => boolean;
+  filterCCSuggestion: (input: Suggestion) => boolean;
 
   @property({type: Object})
   permittedLabels?: LabelNameToValueMap;
@@ -266,6 +280,9 @@
   @property({type: Array})
   _ccs: (AccountInfo | GroupInfo)[] = [];
 
+  @property({type: Number})
+  _attentionCcsCount = 0;
+
   @property({type: Object, observer: '_reviewerPendingConfirmationUpdated'})
   _ccPendingConfirmation: GroupObjectInput | null = null;
 
@@ -311,11 +328,16 @@
   @property({type: Boolean})
   _reviewersMutated = false;
 
+  /**
+   * Signifies that the user has changed their vote on a label or (if they have
+   * not yet voted on a label) if a selected vote is different from the default
+   * vote.
+   */
   @property({type: Boolean})
   _labelsChanged = false;
 
-  @property({type: String, readOnly: true})
-  _saveTooltip: string = ButtonTooltips.SAVE;
+  @property({type: String})
+  readonly _saveTooltip: string = ButtonTooltips.SAVE;
 
   @property({type: String})
   _pluginMessage = '';
@@ -324,7 +346,7 @@
   _commentEditing = false;
 
   @property({type: Boolean})
-  _attentionModified = false;
+  _attentionExpanded = false;
 
   @property({type: Object})
   _currentAttentionSet: Set<AccountId> = new Set();
@@ -337,13 +359,14 @@
     computed:
       '_computeSendButtonDisabled(canBeStarted, ' +
       'draftCommentThreads, draft, _reviewersMutated, _labelsChanged, ' +
-      '_includeComments, disabled, _commentEditing, _attentionModified)',
+      '_includeComments, disabled, _commentEditing, _attentionExpanded, ' +
+      '_currentAttentionSet, _newAttentionSet)',
     observer: '_sendDisabledChanged',
   })
   _sendDisabled?: boolean;
 
   @property({type: Array, observer: '_handleHeightChanged'})
-  draftCommentThreads?: CommentThread[];
+  draftCommentThreads: CommentThread[] | undefined;
 
   @property({type: Boolean})
   _isResolvedPatchsetLevelComment = true;
@@ -362,10 +385,10 @@
 
   constructor() {
     super();
-    this.filterReviewerSuggestion = () =>
-      this._filterReviewerSuggestionGenerator(false);
-    this.filterCCSuggestion = () =>
-      this._filterReviewerSuggestionGenerator(true);
+    this.filterReviewerSuggestion = this._filterReviewerSuggestionGenerator(
+      false
+    );
+    this.filterCCSuggestion = this._filterReviewerSuggestionGenerator(true);
   }
 
   /** @override */
@@ -437,6 +460,17 @@
     }
   }
 
+  _computeHasDrafts(
+    draft: string,
+    draftCommentThreads: PolymerDeepPropertyChange<
+      CommentThread[] | undefined,
+      CommentThread[] | undefined
+    >
+  ) {
+    if (draftCommentThreads.base === undefined) return false;
+    return draft.length > 0 || draftCommentThreads.base.length > 0;
+  }
+
   focus() {
     this._focusOn(FocusTarget.ANY);
   }
@@ -515,13 +549,7 @@
             const moveTo = isReviewer ? 'reviewer' : 'CC';
             const id = account.name || account.email || key;
             const message = `${id} moved from ${moveFrom} to ${moveTo}.`;
-            this.dispatchEvent(
-              new CustomEvent('show-alert', {
-                detail: {message},
-                composed: true,
-                bubbles: true,
-              })
-            );
+            fireAlert(this, message);
           }
         }
       }
@@ -628,7 +656,7 @@
       reviewInput.ready = true;
     }
 
-    if (this._isAttentionSetEnabled(this.serverConfig)) {
+    if (isAttentionSetEnabled(this.serverConfig)) {
       const selfName = getDisplayName(this.serverConfig, this._account);
       const reason = `${selfName} replied on the change`;
 
@@ -646,7 +674,7 @@
         }
       }
       this.reportAttentionSetChanges(
-        this._attentionModified,
+        this._attentionExpanded,
         reviewInput.add_to_attention_set,
         reviewInput.remove_from_attention_set
       );
@@ -872,7 +900,11 @@
   }
 
   _handleAttentionModify() {
-    this._attentionModified = true;
+    this._attentionExpanded = true;
+  }
+
+  @observe('_attentionExpanded')
+  _onAttentionExpandedChange() {
     // If the attention-detail section is expanded without dispatching this
     // event, then the dialog may expand beyond the screen's bottom border.
     this.dispatchEvent(
@@ -880,16 +912,19 @@
     );
   }
 
-  _showAttentionSummary(config?: ServerInfo, attentionModified?: boolean) {
-    return this._isAttentionSetEnabled(config) && !attentionModified;
+  _showAttentionSummary(config?: ServerInfo, attentionExpanded?: boolean) {
+    return isAttentionSetEnabled(config) && !attentionExpanded;
   }
 
-  _showAttentionDetails(config?: ServerInfo, attentionModified?: boolean) {
-    return this._isAttentionSetEnabled(config) && attentionModified;
+  _showAttentionDetails(config?: ServerInfo, attentionExpanded?: boolean) {
+    return isAttentionSetEnabled(config) && attentionExpanded;
   }
 
-  _isAttentionSetEnabled(config?: ServerInfo) {
-    return !!config && !!config.change && config.change.enable_attention_set;
+  _computeAttentionButtonTitle(sendDisabled?: boolean) {
+    return sendDisabled
+      ? 'Modify the attention set by adding a comment or use the account ' +
+          'hovercard in the change page.'
+      : 'Edit attention set changes';
   }
 
   _handleAttentionClick(e: Event) {
@@ -935,7 +970,10 @@
     '_reviewers.*',
     '_ccs.*',
     'change',
-    'draftCommentThreads'
+    'draftCommentThreads',
+    '_includeComments',
+    '_labelsChanged',
+    'hasDrafts'
   )
   _computeNewAttention(
     currentUser?: AccountInfo,
@@ -943,24 +981,33 @@
       AccountInfoInput[],
       AccountInfoInput[]
     >,
-    _?: PolymerDeepPropertyChange<AccountInfoInput[], AccountInfoInput[]>,
+    ccs?: PolymerDeepPropertyChange<AccountInfoInput[], AccountInfoInput[]>,
     change?: ChangeInfo,
-    draftCommentThreads?: CommentThread[]
+    draftCommentThreads?: CommentThread[],
+    includeComments?: boolean,
+    _labelsChanged?: boolean,
+    hasDrafts?: boolean
   ) {
     if (
       currentUser === undefined ||
       currentUser._account_id === undefined ||
       reviewers === undefined ||
+      ccs === undefined ||
       change === undefined ||
-      draftCommentThreads === undefined
+      draftCommentThreads === undefined ||
+      includeComments === undefined
     ) {
       return;
     }
-    this._attentionModified = false;
+    // The draft comments are only relevant for the attention set as long as the
+    // user actually plans to publish their drafts.
+    draftCommentThreads = includeComments ? draftCommentThreads : [];
+    const hasVote = !!_labelsChanged;
+    const isOwner = this._isOwner(currentUser, change);
+    const isUploader = this._uploader?._account_id === currentUser._account_id;
+    this._attentionCcsCount = removeServiceUsers(ccs.base).length;
     this._currentAttentionSet = new Set(
-      Object.keys(change.attention_set || {}).map(
-        id => parseInt(id) as AccountId
-      )
+      Object.keys(change.attention_set || {}).map(id => Number(id) as AccountId)
     );
     const newAttention = new Set(this._currentAttentionSet);
     if (change.status === ChangeStatus.NEW) {
@@ -970,30 +1017,28 @@
       );
       // Remove the current user.
       newAttention.delete(currentUser._account_id);
-      // Add all new reviewers.
+      // Add all new reviewers, but not the current reviewer, if they are also
+      // sending a draft or a label vote.
+      const notIsReviewerAndHasDraftOrLabel = (r: AccountInfo) =>
+        !(r._account_id === currentUser._account_id && (hasDrafts || hasVote));
       reviewers.base
         .filter(r => r._pendingAdd && r._account_id)
+        .filter(notIsReviewerAndHasDraftOrLabel)
         .forEach(r => newAttention.add(r._account_id!));
-      // Add the uploader, if someone else replies.
-      if (
-        this._uploader &&
-        this._uploader._account_id !== currentUser._account_id
-      ) {
-        // An uploader must have an _account_id.
-        newAttention.add(this._uploader._account_id!);
-      }
-      // Add the owner, if someone else replies. Also add the owner, if the
-      // attention set would otherwise be empty.
-      if (change.owner) {
-        if (!this._isOwner(currentUser, change) || newAttention.size === 0) {
-          // A change owner must have an _account_id.
-          newAttention.add(change.owner._account_id!);
+      // Add owner and uploader, if someone else replies.
+      if (hasDrafts || hasVote) {
+        if (this._uploader?._account_id && !isUploader) {
+          newAttention.add(this._uploader._account_id);
+        }
+        if (change.owner?._account_id && !isOwner) {
+          newAttention.add(change.owner._account_id);
         }
       }
     } else {
       // The only reason for adding someone to the attention set for merged or
-      // abandoned changes is that someone adds a new comment thread.
-      if (change.owner && this._containsNewCommentThread(draftCommentThreads)) {
+      // abandoned changes is that someone makes a comment thread unresolved.
+      const hasUnresolvedDraft = draftCommentThreads.some(isUnresolved);
+      if (change.owner && hasUnresolvedDraft) {
         // A change owner must have an _account_id.
         newAttention.add(change.owner._account_id!);
       }
@@ -1008,34 +1053,55 @@
     this._newAttentionSet = new Set(
       [...newAttention].filter(id => allAccountIds.includes(id))
     );
+    this._attentionExpanded = this._computeShowAttentionTip(
+      currentUser,
+      change.owner,
+      this._currentAttentionSet,
+      this._newAttentionSet
+    );
+  }
+
+  _computeShowAttentionTip(
+    currentUser?: AccountInfo,
+    owner?: AccountInfo,
+    currentAttentionSet?: Set<AccountId>,
+    newAttentionSet?: Set<AccountId>
+  ) {
+    if (!currentUser || !owner || !currentAttentionSet || !newAttentionSet)
+      return false;
+    const isOwner = currentUser._account_id === owner._account_id;
+    const addedIds = [...newAttentionSet].filter(
+      id => !currentAttentionSet.has(id)
+    );
+    return isOwner && addedIds.length > 2;
   }
 
   _computeCommentAccounts(threads: CommentThread[]) {
+    const crLabel = this.change?.labels?.[CODE_REVIEW];
+    const maxCrVoteAccountIds = getMaxAccounts(crLabel).map(a => a._account_id);
     const accountIds = new Set<AccountId>();
     threads.forEach(thread => {
+      const unresolved = isUnresolved(thread);
       thread.comments.forEach(comment => {
         if (comment.author) {
           // A comment author must have an _account_id.
-          accountIds.add(comment.author._account_id!);
+          const authorId = comment.author._account_id!;
+          const hasGivenMaxReviewVote = maxCrVoteAccountIds.includes(authorId);
+          if (unresolved || !hasGivenMaxReviewVote) accountIds.add(authorId);
         }
       });
     });
     return accountIds;
   }
 
-  _containsNewCommentThread(threads: CommentThread[]) {
-    return threads.some(
-      thread =>
-        !!thread.comments && !!thread.comments[0] && isDraft(thread.comments[0])
-    );
-  }
-
-  _isNewAttentionEmpty(
+  _computeShowNoAttentionUpdate(
     config?: ServerInfo,
     currentAttentionSet?: Set<AccountId>,
-    newAttentionSet?: Set<AccountId>
+    newAttentionSet?: Set<AccountId>,
+    sendDisabled?: boolean
   ) {
     return (
+      sendDisabled ||
       this._computeNewAttentionAccounts(
         config,
         currentAttentionSet,
@@ -1044,6 +1110,24 @@
     );
   }
 
+  _computeDoNotUpdateMessage(
+    currentAttentionSet?: Set<AccountId>,
+    newAttentionSet?: Set<AccountId>,
+    sendDisabled?: boolean
+  ) {
+    if (!currentAttentionSet || !newAttentionSet) return '';
+    if (sendDisabled || areSetsEqual(currentAttentionSet, newAttentionSet)) {
+      return 'No changes to the attention set.';
+    }
+    if (containsAll(currentAttentionSet, newAttentionSet)) {
+      return 'No additions to the attention set.';
+    }
+    console.error(
+      '_computeDoNotUpdateMessage() should not be called when users were added to the attention set.'
+    );
+    return '';
+  }
+
   _computeNewAttentionAccounts(
     _?: ServerInfo,
     currentAttentionSet?: Set<AccountId>,
@@ -1078,10 +1162,6 @@
     return removeServiceUsers(accounts);
   }
 
-  _computeShowAttentionCcs(ccs: AccountInfo[]) {
-    return removeServiceUsers(ccs).length > 0;
-  }
-
   _computeUploader(change: ChangeInfo) {
     if (
       !change ||
@@ -1190,13 +1270,7 @@
       return;
     }
     if (this._sendDisabled) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          bubbles: true,
-          composed: true,
-          detail: {message: EMPTY_REPLY_MESSAGE},
-        })
-      );
+      fireAlert(this, EMPTY_REPLY_MESSAGE);
       return;
     }
     return this.send(this._includeComments, this.canBeStarted)
@@ -1311,7 +1385,7 @@
 
   _handleLabelsChanged() {
     this._labelsChanged =
-      Object.keys(this.$.labelScores.getLabelValues()).length !== 0;
+      Object.keys(this.$.labelScores.getLabelValues(false)).length !== 0;
   }
 
   _isState(knownLatestState?: LatestPatchState, value?: LatestPatchState) {
@@ -1351,8 +1425,7 @@
     labelsChanged?: boolean,
     includeComments?: boolean,
     disabled?: boolean,
-    commentEditing?: boolean,
-    attentionModified?: boolean
+    commentEditing?: boolean
   ) {
     if (
       canBeStarted === undefined ||
@@ -1362,8 +1435,7 @@
       labelsChanged === undefined ||
       includeComments === undefined ||
       disabled === undefined ||
-      commentEditing === undefined ||
-      attentionModified === undefined
+      commentEditing === undefined
     ) {
       return undefined;
     }
@@ -1374,13 +1446,7 @@
       return false;
     }
     const hasDrafts = includeComments && draftCommentThreads.length;
-    return (
-      !hasDrafts &&
-      !text.length &&
-      !reviewersMutated &&
-      !labelsChanged &&
-      !attentionModified
-    );
+    return !hasDrafts && !text.length && !reviewersMutated && !labelsChanged;
   }
 
   _computePatchSetWarning(patchNum?: PatchSetNum, labelsChanged?: boolean) {
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
index 1be2f75..c56a5c9 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
@@ -47,6 +47,7 @@
     .stickyBottom {
       background-color: var(--dialog-background-color);
       box-shadow: 0px 0px 8px 0px rgba(60, 64, 67, 0.15);
+      margin-top: var(--spacing-s);
       bottom: 0;
       position: sticky;
       /* @see Issue 8602 */
@@ -167,33 +168,39 @@
       display: flex;
       justify-content: space-between;
     }
-    .attentionSummary gr-account-chip {
-      display: inline-block;
-      vertical-align: top;
-      /* The account chip is misbehaving currently: It consumes 22px height, so
-         it does not fit nicely into the 20px line-height of the standard
-         inline layout flow. :-( */
-      position: relative;
-      top: -1px;
-    }
-    .attentionSummary,
-    .attention-detail {
-      --account-max-length: 150px;
+    .attentionSummary {
+      /* The account label for selection is misbehaving currently: It consumes
+         26px height instead of 20px, which is the default line-height and thus
+         the max that can be nicely fit into an inline layout flow. We
+         acknowledge that using a fixed 26px value here is a hack and not a
+         great solution. */
+      line-height: 26px;
     }
     .attention-detail .peopleList .accountList {
       display: flex;
       flex-wrap: wrap;
     }
+    .attentionSummary gr-account-label,
     .attention-detail gr-account-label {
+      --account-max-length: 150px;
       display: inline-block;
       padding: var(--spacing-xs) var(--spacing-m);
-      vertical-align: baseline;
       user-select: none;
       --label-border-radius: 8px;
     }
+    .attentionSummary gr-account-label {
+      margin: 0 var(--spacing-xs);
+      line-height: var(--line-height-normal);
+      vertical-align: top;
+    }
+    .attention-detail gr-account-label {
+      vertical-align: baseline;
+    }
+    .attentionSummary gr-account-label:focus,
     .attention-detail gr-account-label:focus {
       outline: none;
     }
+    .attentionSummary gr-account-label:hover,
     .attention-detail gr-account-label:hover {
       box-shadow: var(--elevation-level-1);
       cursor: pointer;
@@ -206,6 +213,16 @@
       color: var(--deemphasized-text-color);
       margin-bottom: var(--spacing-m);
     }
+    .attentionTip {
+      padding: var(--spacing-m);
+      border: 1px solid var(--border-color);
+      border-radius: var(--border-radius);
+      margin-top: var(--spacing-m);
+      background-color: var(--assignee-highlight-color);
+    }
+    .attentionTip div iron-icon {
+      margin-right: var(--spacing-s);
+    }
   </style>
   <div class="container" tabindex="-1">
     <section class="peopleContainer">
@@ -352,20 +369,23 @@
     </section>
     <div class="stickyBottom">
       <section
-        hidden$="[[!_showAttentionSummary(serverConfig, _attentionModified)]]"
+        hidden$="[[!_showAttentionSummary(serverConfig, _attentionExpanded)]]"
         class="attention"
       >
         <div class="attentionSummary">
           <div>
             <template
               is="dom-if"
-              if="[[_isNewAttentionEmpty(serverConfig, _currentAttentionSet, _newAttentionSet)]]"
+              if="[[_computeShowNoAttentionUpdate(serverConfig, _currentAttentionSet, _newAttentionSet, _sendDisabled)]]"
             >
-              <span>Do not update the attention set.</span>
+              <span
+                >[[_computeDoNotUpdateMessage(_currentAttentionSet,
+                _newAttentionSet, _sendDisabled)]]</span
+              >
             </template>
             <template
               is="dom-if"
-              if="[[!_isNewAttentionEmpty(serverConfig, _currentAttentionSet, _newAttentionSet)]]"
+              if="[[!_computeShowNoAttentionUpdate(serverConfig, _currentAttentionSet, _newAttentionSet, _sendDisabled)]]"
             >
               <span>Bring to attention of</span>
               <template
@@ -373,19 +393,27 @@
                 items="[[_computeNewAttentionAccounts(serverConfig, _currentAttentionSet, _newAttentionSet)]]"
                 as="account"
               >
-                <gr-account-chip account="[[account]]" force-attention="">
-                </gr-account-chip>
+                <gr-account-label
+                  account="[[account]]"
+                  force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+                  selected$="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+                  deselected$="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
+                  hide-hovercard=""
+                  on-click="_handleAttentionClick"
+                ></gr-account-label>
               </template>
             </template>
             <gr-button
               class="edit-attention-button"
               on-click="_handleAttentionModify"
+              disabled="[[_sendDisabled]]"
               link=""
               position-below=""
               data-label="Edit"
               data-action-type="change"
               data-action-key="edit"
-              title="Edit attention set changes"
+              has-tooltip=""
+              title="[[_computeAttentionButtonTitle(_sendDisabled)]]"
               role="button"
               tabindex="0"
             >
@@ -416,7 +444,7 @@
         </div>
       </section>
       <section
-        hidden$="[[!_showAttentionDetails(serverConfig, _attentionModified)]]"
+        hidden$="[[!_showAttentionDetails(serverConfig, _attentionExpanded)]]"
         class="attention-detail"
       >
         <div class="attentionDetailsTitle">
@@ -501,7 +529,7 @@
             </template>
           </div>
         </div>
-        <template is="dom-if" if="[[_computeShowAttentionCcs(_ccs)]]">
+        <template is="dom-if" if="[[_attentionCcsCount]]">
           <div class="peopleList">
             <div class="peopleListLabel">CC</div>
             <div>
@@ -523,6 +551,18 @@
             </div>
           </div>
         </template>
+        <template
+          is="dom-if"
+          if="[[_computeShowAttentionTip(_account, _owner, _currentAttentionSet, _newAttentionSet)]]"
+        >
+          <div class="attentionTip">
+            <iron-icon
+              class="pointer"
+              icon="gr-icons:lightbulb-outline"
+            ></iron-icon>
+            Be mindful of requiring attention from too many users.
+          </div>
+        </template>
       </section>
       <section class="actions">
         <div class="left">
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
index abbb1d4..b612a57 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
@@ -203,7 +203,8 @@
   });
 
   function checkComputeAttention(status, userId, reviewerIds, ownerId,
-      attSetIds, replyToIds, expectedIds, uploaderId, hasDraft) {
+      attSetIds, replyToIds, expectedIds, uploaderId, hasDraft,
+      includeComments = true) {
     const user = {_account_id: userId};
     const reviewers = {base: reviewerIds.map(id => {
       return {_account_id: id};
@@ -212,7 +213,7 @@
       {comments: []},
     ];
     if (hasDraft) {
-      draftThreads[0].comments.push({__draft: true});
+      draftThreads[0].comments.push({__draft: true, unresolved: true});
     }
     replyToIds.forEach(id => draftThreads[0].comments.push({
       author: {_account_id: id},
@@ -229,8 +230,12 @@
     }
     element.change = change;
     element._reviewers = reviewers.base;
+
     flush();
-    element._computeNewAttention(user, reviewers, [], change, draftThreads);
+    const hasDrafts = draftThreads.length > 0;
+    element._computeNewAttention(
+        user, reviewers, [], change, draftThreads, includeComments, undefined,
+        hasDrafts);
     assert.sameMembers([...element._newAttentionSet], expectedIds);
   }
 
@@ -242,9 +247,11 @@
     checkComputeAttention('NEW', 1, [22], 999, [22], [], [22, 999]);
     checkComputeAttention('NEW', 1, [22], 999, [], [22], [22, 999]);
     checkComputeAttention('NEW', 1, [22, 33], 999, [33], [22], [22, 33, 999]);
-    checkComputeAttention('NEW', 1, [], 1, [], [], [1]);
-    checkComputeAttention('NEW', 1, [], 1, [1], [], [1]);
-    checkComputeAttention('NEW', 1, [22], 1, [], [], [1]);
+    // If the owner replies, then do not add them.
+    checkComputeAttention('NEW', 1, [], 1, [], [], []);
+    checkComputeAttention('NEW', 1, [], 1, [1], [], []);
+    checkComputeAttention('NEW', 1, [22], 1, [], [], []);
+
     checkComputeAttention('NEW', 1, [22], 1, [], [22], [22]);
     checkComputeAttention('NEW', 1, [22, 33], 1, [33], [22], [22, 33]);
     checkComputeAttention('NEW', 1, [22, 33], 1, [], [22], [22]);
@@ -260,6 +267,8 @@
     checkComputeAttention('MERGED', null, [], 999, [], [], []);
     checkComputeAttention('MERGED', 1, [], 999, [], [], []);
     checkComputeAttention('MERGED', 1, [], 999, [], [], [999], undefined, true);
+    checkComputeAttention(
+        'MERGED', 1, [], 999, [], [], [], undefined, true, false);
     checkComputeAttention('MERGED', 1, [], 999, [1], [], []);
     checkComputeAttention('MERGED', 1, [22], 999, [], [], []);
     checkComputeAttention('MERGED', 1, [22], 999, [22], [], [22]);
@@ -277,6 +286,33 @@
     checkComputeAttention('MERGED', 1, [22, 33], 1, [22, 33], [], [22, 33]);
   });
 
+  test('computeNewAttention when adding reviewers', () => {
+    const user = {_account_id: 1};
+    const reviewers = {base: [
+      {_account_id: 1, _pendingAdd: true},
+      {_account_id: 2, _pendingAdd: true},
+    ]};
+    const change = {
+      owner: {_account_id: 5},
+      status: 'NEW',
+      attention_set: {},
+    };
+    element.change = change;
+    element._reviewers = reviewers.base;
+    flush();
+
+    element._computeNewAttention(user, reviewers, [], change, [], true);
+    assert.sameMembers([...element._newAttentionSet], [1, 2]);
+
+    // If the user votes on the change, then they should not be added to the
+    // attention set, even if they have just added themselves as reviewer.
+    // But voting should also add the owner (5).
+    const labelsChanged = true;
+    element._computeNewAttention(
+        user, reviewers, [], change, [], true, labelsChanged);
+    assert.sameMembers([...element._newAttentionSet], [2, 5]);
+  });
+
   test('computeNewAttentionAccounts', () => {
     element._reviewers = [
       {_account_id: 123, display_name: 'Ernie'},
@@ -298,6 +334,45 @@
     assert.sameMembers(compute([999], [7, 123, 999]), [7, 123]);
   });
 
+  test('_computeCommentAccounts', () => {
+    element.change = {
+      labels: {
+        'Code-Review': {
+          all: [
+            {_account_id: 1, value: 0},
+            {_account_id: 2, value: 1},
+            {_account_id: 3, value: 2},
+          ],
+          values: {
+            '-2': 'Do not submit',
+            '-1': 'I would prefer that you didnt submit this',
+            ' 0': 'No score',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+        },
+      },
+    };
+    const threads = [
+      {
+        comments: [
+          {author: {_account_id: 1}, unresolved: false},
+          {author: {_account_id: 2}, unresolved: true},
+        ],
+      },
+      {
+        comments: [
+          {author: {_account_id: 3}, unresolved: false},
+          {author: {_account_id: 4}, unresolved: false},
+        ],
+      },
+    ];
+    const actualAccounts = [...element._computeCommentAccounts(threads)];
+    // Account 3 is not included, because the comment is resolved *and* they
+    // have given the highest possible vote on the Code-Review label.
+    assert.sameMembers(actualAccounts, [1, 2, 4]);
+  });
+
   test('toggle resolved checkbox', done => {
     // Async tick is needed because iron-selector content is distributed and
     // distributed content requires an observer to be set up.
@@ -954,11 +1029,18 @@
     element._reviewers = [makeAccount(), makeAccount()];
     element._ccs = [makeAccount(), makeAccount()];
     element.draftCommentThreads = [];
-    MockInteractions.tap(
-        element.shadowRoot.querySelector('.edit-attention-button'));
+    const modifyButton =
+        element.shadowRoot.querySelector('.edit-attention-button');
+    MockInteractions.tap(modifyButton);
     flush();
 
-    assert.isTrue(element._attentionModified);
+    // "Modify" button disabled, because "Send" button is disabled.
+    assert.isFalse(element._attentionExpanded);
+    element.draft = 'a test comment';
+    MockInteractions.tap(modifyButton);
+    flush();
+    assert.isTrue(element._attentionExpanded);
+
     let accountLabels = Array.from(element.shadowRoot.querySelectorAll(
         '.attention-detail gr-account-label'));
     assert.equal(accountLabels.length, 5);
@@ -969,13 +1051,13 @@
 
     // The 'attention modified' section collapses and resets when reviewers or
     // ccs change.
-    assert.isFalse(element._attentionModified);
+    assert.isFalse(element._attentionExpanded);
 
     MockInteractions.tap(
         element.shadowRoot.querySelector('.edit-attention-button'));
     flush();
 
-    assert.isTrue(element._attentionModified);
+    assert.isTrue(element._attentionExpanded);
     accountLabels = Array.from(element.shadowRoot.querySelectorAll(
         '.attention-detail gr-account-label'));
     assert.equal(accountLabels.length, 7);
@@ -1323,8 +1405,7 @@
         /* labelsChanged= */ false,
         /* includeComments= */ false,
         /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* attentionModified= */ false
+        /* commentEditing= */ false
     ));
   });
 
@@ -1339,24 +1420,7 @@
         /* labelsChanged= */ false,
         /* includeComments= */ false,
         /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* attentionModified= */ false
-    ));
-  });
-
-  test('_computeSendButtonDisabled_attentionModified true', () => {
-    const fn = element._computeSendButtonDisabled.bind(element);
-    // Mock everything false
-    assert.isFalse(fn(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* attentionModified= */ true
+        /* commentEditing= */ false
     ));
   });
 
@@ -1371,8 +1435,7 @@
         /* labelsChanged= */ false,
         /* includeComments= */ true,
         /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* attentionModified= */ false
+        /* commentEditing= */ false
     ));
   });
 
@@ -1387,8 +1450,7 @@
         /* labelsChanged= */ false,
         /* includeComments= */ false,
         /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* attentionModified= */ false
+        /* commentEditing= */ false
     ));
   });
 
@@ -1403,8 +1465,7 @@
         /* labelsChanged= */ false,
         /* includeComments= */ false,
         /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* attentionModified= */ false
+        /* commentEditing= */ false
     ));
   });
 
@@ -1419,8 +1480,7 @@
         /* labelsChanged= */ false,
         /* includeComments= */ false,
         /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* attentionModified= */ false
+        /* commentEditing= */ false
     ));
   });
 
@@ -1435,8 +1495,7 @@
         /* labelsChanged= */ true,
         /* includeComments= */ false,
         /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* attentionModified= */ false
+        /* commentEditing= */ false
     ));
   });
 
@@ -1451,8 +1510,7 @@
         /* labelsChanged= */ true,
         /* includeComments= */ false,
         /* disabled= */ true,
-        /* commentEditing= */ false,
-        /* attentionModified= */ false
+        /* commentEditing= */ false
     ));
     assert.isTrue(fn(
         /* buttonLabel= */ 'Send',
@@ -1462,8 +1520,7 @@
         /* labelsChanged= */ true,
         /* includeComments= */ false,
         /* disabled= */ false,
-        /* commentEditing= */ true,
-        /* attentionModified= */ false
+        /* commentEditing= */ true
     ));
   });
 
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
index 70e7ba7..9c3fa42 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -23,7 +23,8 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-reviewer-list_html';
-import {hasAttention, isServiceUser} from '../../../utils/account-util';
+import {isServiceUser} from '../../../utils/account-util';
+import {hasAttention} from '../../../utils/attention-set-util';
 import {customElement, property, computed, observe} from '@polymer/decorators';
 import {
   ChangeInfo,
@@ -34,11 +35,14 @@
   Reviewers,
   AccountId,
   DetailedLabelInfo,
+  EmailAddress,
 } from '../../../types/common';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {hasOwnProperty} from '../../../utils/common-util';
+import {isRemovableReviewer} from '../../../utils/change-util';
+import {ReviewerState} from '../../../constants/constants';
 
 export interface GrReviewerList {
   $: {
@@ -125,7 +129,7 @@
     return Object.keys(labels).map(label => {
       return {
         label,
-        scores: labels[label].map(v => parseInt(v, 10)),
+        scores: labels[label].map(v => Number(v)),
       };
     });
   }
@@ -251,25 +255,7 @@
   }
 
   _computeCanRemoveReviewer(reviewer: AccountInfo, mutable: boolean) {
-    if (
-      !mutable ||
-      this.change === undefined ||
-      this.change.removable_reviewers === undefined
-    ) {
-      return false;
-    }
-
-    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;
+    return mutable && isRemovableReviewer(this.change, reviewer);
   }
 
   _handleRemove(e: Event) {
@@ -278,7 +264,7 @@
     if (!target.account || !this.change) {
       return;
     }
-    const accountID = target.account._account_id;
+    const accountID = target.account._account_id || target.account.email;
     this.disabled = true;
     if (!accountID) return;
     this._xhrPromise = this._removeReviewer(accountID)
@@ -288,12 +274,15 @@
           return response;
         }
         if (!this.change || !this.change.reviewers) return;
-        const reviewers: {[type: string]: AccountInfo[] | undefined} = this
-          .change!.reviewers;
-        for (const type of ['REVIEWER', 'CC']) {
-          reviewers[type] = reviewers[type] || [];
-          for (let i = 0; i < reviewers[type]!.length; i++) {
-            if (reviewers[type]![i]._account_id === accountID) {
+        const reviewers = this.change.reviewers;
+        for (const type of [ReviewerState.REVIEWER, ReviewerState.CC]) {
+          const reviewerStateByType = reviewers[type] || [];
+          reviewers[type] = reviewerStateByType;
+          for (let i = 0; i < reviewerStateByType.length; i++) {
+            if (
+              reviewerStateByType[i]._account_id === accountID ||
+              reviewerStateByType[i].email === accountID
+            ) {
               this.splice('change.reviewers.' + type, i, 1);
               break;
             }
@@ -332,7 +321,7 @@
     this._displayedReviewers = this._reviewers;
   }
 
-  _removeReviewer(id: AccountId): Promise<Response | undefined> {
+  _removeReviewer(id: AccountId | EmailAddress): Promise<Response | undefined> {
     if (!this.change) return Promise.resolve(undefined);
     return this.$.restAPI.removeChangeReviewer(this.change._number, id);
   }
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
index ad9af30..d29abfc 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
@@ -116,6 +116,101 @@
     }
   });
 
+  suite('_handleRemove', () => {
+    let removeReviewerStub;
+    let reviewersChangedSpy;
+
+    const reviewerWithId = {
+      _account_id: 2,
+      name: 'Some name',
+    };
+
+    const reviewerWithIdAndEmail = {
+      _account_id: 4,
+      name: 'Some other name',
+      email: 'example@',
+    };
+
+    const reviewerWithEmailOnly = {
+      email: 'example2@example',
+    };
+
+    let chips;
+
+    setup(() => {
+      removeReviewerStub = sinon
+          .stub(element, '_removeReviewer')
+          .returns(Promise.resolve(new Response({status: 200})));
+      element.mutable = true;
+
+      const allReviewers = [
+        reviewerWithId,
+        reviewerWithIdAndEmail,
+        reviewerWithEmailOnly,
+      ];
+
+      element.change = {
+        owner: {
+          _account_id: 1,
+        },
+        reviewers: {
+          REVIEWER: allReviewers,
+        },
+        removable_reviewers: allReviewers,
+      };
+      flush();
+      chips = Array.from(element.root.querySelectorAll('gr-account-chip'));
+      assert.equal(chips.length, allReviewers.length);
+      reviewersChangedSpy = sinon.spy(element, '_reviewersChanged');
+    });
+
+    test('_handleRemove for account with accountId only', async () => {
+      const accountChip = chips.find(chip =>
+        chip.account._account_id === reviewerWithId._account_id
+      );
+      accountChip._handleRemoveTap(new MouseEvent('click'));
+      await flush();
+      assert.isTrue(removeReviewerStub.calledOnce);
+      assert.isTrue(removeReviewerStub.calledWith(reviewerWithId._account_id));
+      assert.isTrue(reviewersChangedSpy.called);
+      expect(element.change.reviewers.REVIEWER).to.have.deep.members([
+        reviewerWithIdAndEmail,
+        reviewerWithEmailOnly,
+      ]);
+    });
+
+    test('_handleRemove for account with accountId and email', async () => {
+      const accountChip = chips.find(chip =>
+        chip.account._account_id === reviewerWithIdAndEmail._account_id
+      );
+      accountChip._handleRemoveTap(new MouseEvent('click'));
+      await flush();
+      assert.isTrue(removeReviewerStub.calledOnce);
+      assert.isTrue(
+          removeReviewerStub.calledWith(reviewerWithIdAndEmail._account_id));
+      assert.isTrue(reviewersChangedSpy.called);
+      expect(element.change.reviewers.REVIEWER).to.have.deep.members([
+        reviewerWithId,
+        reviewerWithEmailOnly,
+      ]);
+    });
+
+    test('_handleRemove for account with email only', async () => {
+      const accountChip = chips.find(
+          chip => chip.account.email === reviewerWithEmailOnly.email
+      );
+      accountChip._handleRemoveTap(new MouseEvent('click'));
+      await flush();
+      assert.isTrue(removeReviewerStub.calledOnce);
+      assert.isTrue(removeReviewerStub.calledWith(reviewerWithEmailOnly.email));
+      assert.isTrue(reviewersChangedSpy.called);
+      expect(element.change.reviewers.REVIEWER).to.have.deep.members([
+        reviewerWithId,
+        reviewerWithIdAndEmail,
+      ]);
+    });
+  });
+
   test('tracking reviewers and ccs', () => {
     let counter = 0;
     function makeAccount() {
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 4b02075..6a32834 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -24,16 +24,14 @@
 import {htmlTemplate} from './gr-thread-list_html';
 import {parseDate} from '../../../utils/date-util';
 
-import {NO_THREADS_MSG} from '../../../constants/messages';
 import {CommentSide, SpecialFilePath} from '../../../constants/constants';
 import {customElement, observe, property} from '@polymer/decorators';
 import {
-  CommentThread,
-  isDraft,
-  UIRobot,
-} from '../../diff/gr-comment-api/gr-comment-api';
-import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
+  PolymerSpliceChange,
+  PolymerDeepPropertyChange,
+} from '@polymer/polymer/interfaces';
 import {ChangeInfo} from '../../../types/common';
+import {CommentThread, isDraft, UIRobot} from '../../../utils/comment-util';
 
 interface CommentThreadWithInfo {
   thread: CommentThread;
@@ -68,8 +66,19 @@
   @property({type: Array})
   _sortedThreads: CommentThread[] = [];
 
+  @property({
+    computed:
+      '_computeDisplayedThreads(_sortedThreads.*, unresolvedOnly, ' +
+      '_draftsOnly, onlyShowRobotCommentsWithHumanReply)',
+    type: Array,
+  })
+  _displayedThreads: CommentThread[] = [];
+
+  // thread-list is used in multiple places like the change log, hence
+  // keeping the default to be false. When used in comments tab, it's
+  // set as true.
   @property({type: Boolean})
-  _unresolvedOnly = false;
+  unresolvedOnly = false;
 
   @property({type: Boolean})
   _draftsOnly = false;
@@ -80,13 +89,53 @@
   @property({type: Boolean})
   hideToggleButtons = false;
 
-  @property({type: String})
-  emptyThreadMsg = NO_THREADS_MSG;
-
   _computeShowDraftToggle(loggedIn?: boolean) {
     return loggedIn ? 'show' : '';
   }
 
+  _showEmptyThreadsMessage(
+    threads: CommentThread[],
+    displayedThreads: CommentThread[],
+    unresolvedOnly: boolean
+  ) {
+    if (!threads || !displayedThreads) return false;
+    return !threads.length || (unresolvedOnly && !displayedThreads.length);
+  }
+
+  _computeEmptyThreadsMessage(threads: CommentThread[]) {
+    return !threads.length ? 'No comments.' : 'No unresolved comments';
+  }
+
+  _showPartyPopper(threads: CommentThread[]) {
+    return !!threads.length;
+  }
+
+  _computeResolvedCommentsMessage(
+    threads: CommentThread[],
+    displayedThreads: CommentThread[],
+    unresolvedOnly: boolean
+  ) {
+    if (unresolvedOnly && threads.length && !displayedThreads.length) {
+      return (
+        `Show ${threads.length} resolved comment` +
+        (threads.length > 1 ? 's' : '')
+      );
+    }
+    return '';
+  }
+
+  _showResolvedCommentsButton(
+    threads: CommentThread[],
+    displayedThreads: CommentThread[],
+    unresolvedOnly: boolean
+  ) {
+    return unresolvedOnly && threads.length && !displayedThreads.length;
+  }
+
+  _handleResolvedCommentsMessageClick() {
+    this.unresolvedOnly = !this.unresolvedOnly;
+  }
+
   _compareThreads(c1: CommentThreadWithInfo, c2: CommentThreadWithInfo) {
     if (c1.thread.path !== c2.thread.path) {
       // '/PATCHSET' will not come before '/COMMIT' when sorting
@@ -170,6 +219,7 @@
   ) {
     if (!threads || threads.length === 0) {
       this._sortedThreads = [];
+      this._displayedThreads = [];
       return;
     }
     // We only want to sort on thread additions / removals to avoid
@@ -200,14 +250,34 @@
       .map(threadInfo => threadInfo.thread);
   }
 
+  _computeDisplayedThreads(
+    sortedThreadsRecord?: PolymerDeepPropertyChange<
+      CommentThread[],
+      CommentThread[]
+    >,
+    unresolvedOnly?: boolean,
+    draftsOnly?: boolean,
+    onlyShowRobotCommentsWithHumanReply?: boolean
+  ) {
+    if (!sortedThreadsRecord || !sortedThreadsRecord.base) return [];
+    return sortedThreadsRecord.base.filter(t =>
+      this._shouldShowThread(
+        t,
+        unresolvedOnly,
+        draftsOnly,
+        onlyShowRobotCommentsWithHumanReply
+      )
+    );
+  }
+
   _isFirstThreadWithFileName(
-    sortedThreads: CommentThread[],
+    displayedThreads: CommentThread[],
     thread: CommentThread,
     unresolvedOnly?: boolean,
     draftsOnly?: boolean,
     onlyShowRobotCommentsWithHumanReply?: boolean
   ) {
-    const threads = sortedThreads.filter(t =>
+    const threads = displayedThreads.filter(t =>
       this._shouldShowThread(
         t,
         unresolvedOnly,
@@ -223,13 +293,13 @@
   }
 
   _shouldRenderSeparator(
-    sortedThreads: CommentThread[],
+    displayedThreads: CommentThread[],
     thread: CommentThread,
     unresolvedOnly?: boolean,
     draftsOnly?: boolean,
     onlyShowRobotCommentsWithHumanReply?: boolean
   ) {
-    const threads = sortedThreads.filter(t =>
+    const threads = displayedThreads.filter(t =>
       this._shouldShowThread(
         t,
         unresolvedOnly,
@@ -244,7 +314,7 @@
     return (
       index > 0 &&
       this._isFirstThreadWithFileName(
-        sortedThreads,
+        displayedThreads,
         thread,
         unresolvedOnly,
         draftsOnly,
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
index d74c985..e55f98a 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
@@ -57,15 +57,23 @@
       border-top: 1px solid var(--border-color);
       margin-top: var(--spacing-xl);
     }
+    .resolved-comments-message {
+      color: var(--link-color);
+      cursor: pointer;
+    }
+    .show-resolved-comments {
+      box-shadow: none;
+      padding-left: var(--spacing-m);
+    }
   </style>
   <template is="dom-if" if="[[!hideToggleButtons]]">
     <div class="header">
       <div class="toggleItem">
         <paper-toggle-button
           id="unresolvedToggle"
-          checked="{{_unresolvedOnly}}"
+          checked="{{!unresolvedOnly}}"
           on-tap="_onTapUnresolvedToggle"
-          >Only unresolved threads</paper-toggle-button
+          >All comments</paper-toggle-button
         >
       </div>
       <div
@@ -75,48 +83,63 @@
           id="draftToggle"
           checked="{{_draftsOnly}}"
           on-tap="_onTapUnresolvedToggle"
-          >Only threads with drafts</paper-toggle-button
+          >Comments with drafts</paper-toggle-button
         >
       </div>
     </div>
   </template>
   <div id="threads">
-    <template is="dom-if" if="[[!threads.length]]">
-      [[emptyThreadMsg]]
+    <template
+      is="dom-if"
+      if="[[_showEmptyThreadsMessage(threads, _displayedThreads, unresolvedOnly)]]"
+    >
+      <div>
+        <span>
+          <template is="dom-if" if="[[_showPartyPopper(threads)]]">
+            <span> \&#x1F389 </span>
+          </template>
+          [[_computeEmptyThreadsMessage(threads, _displayedThreads,
+          unresolvedOnly)]]
+          <template is="dom-if" if="[[_showResolvedCommentsButton(threads, _displayedThreads, unresolvedOnly)]]">
+            <gr-button
+              class="show-resolved-comments"
+              link
+              on-click="_handleResolvedCommentsMessageClick">
+                [[_computeResolvedCommentsMessage(threads, _displayedThreads,
+                unresolvedOnly)]]
+            </gr-button>
+          </template>
+        </span>
+      </div>
     </template>
     <template
       is="dom-repeat"
-      items="[[_sortedThreads]]"
+      items="[[_displayedThreads]]"
       as="thread"
       initial-count="10"
       target-framerate="60"
     >
       <template
         is="dom-if"
-        if="[[_shouldShowThread(thread, _unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
+        if="[[_shouldRenderSeparator(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
       >
-        <template
-          is="dom-if"
-          if="[[_shouldRenderSeparator(_sortedThreads, thread, _unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
-        >
-          <div class="thread-separator"></div>
-        </template>
-        <gr-comment-thread
-          show-file-path=""
-          change-num="[[changeNum]]"
-          comments="[[thread.comments]]"
-          comment-side="[[thread.commentSide]]"
-          show-file-name="[[_isFirstThreadWithFileName(_sortedThreads, thread, _unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
-          project-name="[[change.project]]"
-          is-on-parent="[[_isOnParent(thread.commentSide)]]"
-          line-num="[[thread.line]]"
-          patch-num="[[thread.patchNum]]"
-          path="[[thread.path]]"
-          root-id="{{thread.rootId}}"
-          on-thread-changed="_handleCommentsChanged"
-          on-thread-discard="_handleThreadDiscard"
-        ></gr-comment-thread>
+        <div class="thread-separator"></div>
       </template>
+      <gr-comment-thread
+        show-file-path=""
+        change-num="[[changeNum]]"
+        comments="[[thread.comments]]"
+        comment-side="[[thread.commentSide]]"
+        show-file-name="[[_isFirstThreadWithFileName(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
+        project-name="[[change.project]]"
+        is-on-parent="[[_isOnParent(thread.commentSide)]]"
+        line-num="[[thread.line]]"
+        patch-num="[[thread.patchNum]]"
+        path="[[thread.path]]"
+        root-id="{{thread.rootId}}"
+        on-thread-changed="_handleCommentsChanged"
+        on-thread-discard="_handleThreadDiscard"
+      ></gr-comment-thread>
     </template>
   </div>
 `;
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
index bad3a99..efc072f 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
@@ -18,7 +18,6 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-thread-list.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {NO_THREADS_MSG} from '../../../constants/messages.js';
 import {SpecialFilePath} from '../../../constants/constants.js';
 
 const basicFixture = fixtureFromElement('gr-thread-list');
@@ -287,13 +286,23 @@
     assert.equal(getVisibleThreads().length, element.threads.length);
   });
 
+  test('show unresolved threads if unresolvedOnly is set', done => {
+    element.unresolvedOnly = true;
+    flush();
+    const unresolvedThreads = element.threads.filter(t => t.comments.some(
+        c => c.unresolved
+    ));
+    assert.equal(getVisibleThreads().length, unresolvedThreads.length);
+    done();
+  });
+
   test('showing file name takes visible threads into account', () => {
     assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
-        element._sortedThreads[2], element._unresolvedOnly, element._draftsOnly,
+        element._sortedThreads[2], element.unresolvedOnly, element._draftsOnly,
         element.onlyShowRobotCommentsWithHumanReply), true);
-    element._unresolvedOnly = true;
+    element.unresolvedOnly = true;
     assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
-        element._sortedThreads[2], element._unresolvedOnly, element._draftsOnly,
+        element._sortedThreads[2], element.unresolvedOnly, element._draftsOnly,
         element.onlyShowRobotCommentsWithHumanReply), false);
   });
 
@@ -539,7 +548,7 @@
     });
   });
 
-  test('toggle unresolved only shows unresolved comments', () => {
+  test('toggle unresolved shows all comments', () => {
     MockInteractions.tap(element.shadowRoot.querySelector(
         '#unresolvedToggle'));
     flush();
@@ -617,18 +626,9 @@
     });
 
     test('default empty message should show', () => {
-      assert.equal(
-          element.shadowRoot.querySelector('#threads').textContent.trim(),
-          NO_THREADS_MSG
-      );
-    });
-
-    test('can override empty message', () => {
-      element.emptyThreadMsg = 'test';
-      assert.equal(
-          element.shadowRoot.querySelector('#threads').textContent.trim(),
-          'test'
-      );
+      assert.isTrue(
+          element.shadowRoot.querySelector('#threads').textContent.trim()
+              .includes('No comments.'));
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
new file mode 100644
index 0000000..ca73f4f
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -0,0 +1,36 @@
+/**
+ * @license
+ * Copyright (C) 2020 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.
+ */
+import {html} from 'lit-html';
+import {customElement} from 'lit-element';
+import {GrLitElement} from '../lit/gr-lit-element';
+
+/**
+ * The "Checks" tab on the Gerrit change page. Gets its data from plugins that
+ * have registered with the Checks Plugin API.
+ */
+@customElement('gr-checks-tab')
+export class GrChecksTab extends GrLitElement {
+  render() {
+    return html`<span>Hello Checks!</span>`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-checks-tab': GrChecksTab;
+  }
+}
diff --git a/polygerrit-ui/app/gr-diff/gr-diff-root.js b/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
similarity index 70%
copy from polygerrit-ui/app/gr-diff/gr-diff-root.js
copy to polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
index bb5d602..85183ed 100644
--- a/polygerrit-ui/app/gr-diff/gr-diff-root.js
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
@@ -15,5 +15,12 @@
  * limitations under the License.
  */
 
-window.Gerrit = window.Gerrit || {};
-import '../elements/diff/gr-diff/gr-diff.js';
+import '../../test/common-test-setup-karma';
+import {GrChecksTab} from './gr-checks-tab';
+
+suite('gr-checks-tab test', () => {
+  test('is defined', () => {
+    const el = document.createElement('gr-checks-tab');
+    assert.instanceOf(el, GrChecksTab);
+  });
+});
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
index bd3c052..4f3d7ce6 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
@@ -38,6 +38,8 @@
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {FetchRequest} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 import {ErrorType, FixIronA11yAnnouncer} from '../../../types/types';
+import {AccountId} from '../../../types/common';
+import {EventType} from '../../../utils/event-util';
 
 const HIDE_ALERT_TIMEOUT_MS = 5000;
 const CHECK_SIGN_IN_INTERVAL_MS = 60 * 1000;
@@ -86,7 +88,7 @@
    * not set, then there was no account at launch.
    */
   @property({type: Number})
-  knownAccountId?: number;
+  knownAccountId?: AccountId | null;
 
   @property({type: Object})
   _alertElement: GrAlert | null = null;
@@ -128,7 +130,7 @@
     super.attached();
     this.listen(document, 'server-error', '_handleServerError');
     this.listen(document, 'network-error', '_handleNetworkError');
-    this.listen(document, 'show-alert', '_handleShowAlert');
+    this.listen(document, EventType.SHOW_ALERT, '_handleShowAlert');
     this.listen(document, 'hide-alert', '_hideAlert');
     this.listen(document, 'show-error', '_handleShowErrorDialog');
     this.listen(document, 'visibilitychange', '_handleVisibilityChange');
@@ -150,7 +152,7 @@
     this._clearHideAlertHandle();
     this.unlisten(document, 'server-error', '_handleServerError');
     this.unlisten(document, 'network-error', '_handleNetworkError');
-    this.unlisten(document, 'show-alert', '_handleShowAlert');
+    this.unlisten(document, EventType.SHOW_ALERT, '_handleShowAlert');
     this.unlisten(document, 'hide-alert', '_hideAlert');
     this.unlisten(document, 'show-error', '_handleShowErrorDialog');
     this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.ts
index 0a75104..251e30f 100644
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.ts
@@ -20,6 +20,7 @@
   <style include="shared-styles">
     .key {
       background-color: var(--chip-background-color);
+      color: var(--primary-text-color);
       border: 1px solid var(--border-color);
       border-radius: var(--border-radius);
       display: inline-block;
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.ts b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.ts
index 1860f38..3576dfe 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.ts
@@ -30,21 +30,39 @@
       display: flex;
       padding: 0 var(--spacing-xxl) var(--spacing-xxl);
     }
+    .column {
+      flex: 50%;
+    }
     header {
       align-items: center;
       border-bottom: 1px solid var(--border-color);
       display: flex;
       justify-content: space-between;
     }
-    table:last-of-type {
-      margin-left: var(--spacing-xxl);
+    table caption {
+      font-weight: var(--font-weight-bold);
+      padding-top: var(--spacing-l);
+      text-align: left;
+    }
+    tr {
+      height: 32px;
     }
     td {
       padding: var(--spacing-xs) 0;
     }
-    td:first-child {
+    td:first-child,
+    th:first-child {
       padding-right: var(--spacing-m);
       text-align: right;
+      width: 160px;
+      color: var(--deemphasized-text-color);
+    }
+    td:second-child {
+      min-width: 200px;
+    }
+    th {
+      color: var(--deemphasized-text-color);
+      text-align: left;
     }
     .header {
       font-weight: var(--font-weight-bold);
@@ -59,33 +77,19 @@
     <gr-button link="" on-click="_handleCloseTap">Close</gr-button>
   </header>
   <main>
-    <table>
-      <tbody>
-        <template is="dom-repeat" items="[[_left]]">
-          <tr>
-            <td></td>
-            <td class="header">[[item.section]]</td>
-          </tr>
-          <template is="dom-repeat" items="[[item.shortcuts]]" as="shortcut">
+    <div class="column">
+      <template is="dom-repeat" items="[[_left]]">
+        <table>
+          <caption>
+            [[item.section]]
+          </caption>
+          <thead>
             <tr>
-              <td>
-                <gr-key-binding-display binding="[[shortcut.binding]]">
-                </gr-key-binding-display>
-              </td>
-              <td>[[shortcut.text]]</td>
+              <th>Key</th>
+              <th>Action</th>
             </tr>
-          </template>
-        </template>
-      </tbody>
-    </table>
-    <template is="dom-if" if="[[_right]]">
-      <table>
-        <tbody>
-          <template is="dom-repeat" items="[[_right]]">
-            <tr>
-              <td></td>
-              <td class="header">[[item.section]]</td>
-            </tr>
+          </thead>
+          <tbody>
             <template is="dom-repeat" items="[[item.shortcuts]]" as="shortcut">
               <tr>
                 <td>
@@ -95,10 +99,36 @@
                 <td>[[shortcut.text]]</td>
               </tr>
             </template>
-          </template>
-        </tbody>
-      </table>
-    </template>
+          </tbody>
+        </table>
+      </template>
+    </div>
+    <div class="column">
+      <template is="dom-repeat" items="[[_right]]">
+        <table>
+          <caption>
+            [[item.section]]
+          </caption>
+          <thead>
+            <tr>
+              <th>Key</th>
+              <th>Action</th>
+            </tr>
+          </thead>
+          <tbody>
+            <template is="dom-repeat" items="[[item.shortcuts]]" as="shortcut">
+              <tr>
+                <td>
+                  <gr-key-binding-display binding="[[shortcut.binding]]">
+                  </gr-key-binding-display>
+                </td>
+                <td>[[shortcut.text]]</td>
+              </tr>
+            </template>
+          </tbody>
+        </table>
+      </template>
+    </div>
   </main>
   <footer></footer>
 `;
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
deleted file mode 100644
index 88c4cef..0000000
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
+++ /dev/null
@@ -1,363 +0,0 @@
-/**
- * @license
- * 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.
- */
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../shared/gr-dropdown/gr-dropdown.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-account-dropdown/gr-account-dropdown.js';
-import '../gr-smart-search/gr-smart-search.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-main-header_html.js';
-import {getBaseUrl, getDocsBaseUrl} from '../../../utils/url-util.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {getAdminLinks} from '../../../utils/admin-nav-util.js';
-
-const DEFAULT_LINKS = [{
-  title: 'Changes',
-  links: [
-    {
-      url: '/q/status:open+-is:wip',
-      name: 'Open',
-    },
-    {
-      url: '/q/status:merged',
-      name: 'Merged',
-    },
-    {
-      url: '/q/status:abandoned',
-      name: 'Abandoned',
-    },
-  ],
-}];
-
-const DOCUMENTATION_LINKS = [
-  {
-    url: '/index.html',
-    name: 'Table of Contents',
-  },
-  {
-    url: '/user-search.html',
-    name: 'Searching',
-  },
-  {
-    url: '/user-upload.html',
-    name: 'Uploading',
-  },
-  {
-    url: '/access-control.html',
-    name: 'Access Control',
-  },
-  {
-    url: '/rest-api.html',
-    name: 'REST API',
-  },
-  {
-    url: '/intro-project-owner.html',
-    name: 'Project Owner Guide',
-  },
-];
-
-// Set of authentication methods that can provide custom registration page.
-const AUTH_TYPES_WITH_REGISTER_URL = new Set([
-  'LDAP',
-  'LDAP_BIND',
-  'CUSTOM_EXTENSION',
-]);
-
-/**
- * @extends PolymerElement
- */
-class GrMainHeader extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-main-header'; }
-
-  static get properties() {
-    return {
-      searchQuery: {
-        type: String,
-        notify: true,
-      },
-      loggedIn: {
-        type: Boolean,
-        reflectToAttribute: true,
-      },
-      loading: {
-        type: Boolean,
-        reflectToAttribute: true,
-      },
-
-      /** @type {?Object} */
-      _account: Object,
-      _adminLinks: {
-        type: Array,
-        value() { return []; },
-      },
-      _defaultLinks: {
-        type: Array,
-        value() {
-          return DEFAULT_LINKS;
-        },
-      },
-      _docBaseUrl: {
-        type: String,
-        value: null,
-      },
-      _links: {
-        type: Array,
-        computed: '_computeLinks(_defaultLinks, _userLinks, _adminLinks, ' +
-          '_topMenus, _docBaseUrl)',
-      },
-      loginUrl: {
-        type: String,
-        value: '/login',
-      },
-      _userLinks: {
-        type: Array,
-        value() { return []; },
-      },
-      _topMenus: {
-        type: Array,
-        value() { return []; },
-      },
-      _registerText: {
-        type: String,
-        value: 'Sign up',
-      },
-      _registerURL: {
-        type: String,
-        value: null,
-      },
-      mobileSearchHidden: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_accountLoaded(_account)',
-    ];
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this._ensureAttribute('role', 'banner');
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._loadAccount();
-    this._loadConfig();
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-  }
-
-  reload() {
-    this._loadAccount();
-  }
-
-  _computeRelativeURL(path) {
-    return '//' + window.location.host + getBaseUrl() + path;
-  }
-
-  _computeLinks(defaultLinks, userLinks, adminLinks, topMenus, docBaseUrl) {
-    // Polymer 2: check for undefined
-    if ([
-      defaultLinks,
-      userLinks,
-      adminLinks,
-      topMenus,
-      docBaseUrl,
-    ].includes(undefined)) {
-      return undefined;
-    }
-
-    const links = defaultLinks.map(menu => {
-      return {
-        title: menu.title,
-        links: menu.links.slice(),
-      };
-    });
-    if (userLinks && userLinks.length > 0) {
-      links.push({
-        title: 'Your',
-        links: userLinks.slice(),
-      });
-    }
-    const docLinks = this._getDocLinks(docBaseUrl, DOCUMENTATION_LINKS);
-    if (docLinks.length) {
-      links.push({
-        title: 'Documentation',
-        links: docLinks,
-        class: 'hideOnMobile',
-      });
-    }
-    links.push({
-      title: 'Browse',
-      links: adminLinks.slice(),
-    });
-    const topMenuLinks = [];
-    links.forEach(link => { topMenuLinks[link.title] = link.links; });
-    for (const m of topMenus) {
-      const items = m.items.map(this._fixCustomMenuItem).filter(link =>
-        // Ignore GWT project links
-        !link.url.includes('${projectName}')
-      );
-      if (m.name in topMenuLinks) {
-        items.forEach(link => { topMenuLinks[m.name].push(link); });
-      } else {
-        links.push({
-          title: m.name,
-          links: topMenuLinks[m.name] = items,
-        });
-      }
-    }
-    return links;
-  }
-
-  _getDocLinks(docBaseUrl, docLinks) {
-    if (!docBaseUrl || !docLinks) {
-      return [];
-    }
-    return docLinks.map(link => {
-      let url = docBaseUrl;
-      if (url && url[url.length - 1] === '/') {
-        url = url.substring(0, url.length - 1);
-      }
-      return {
-        url: url + link.url,
-        name: link.name,
-        target: '_blank',
-      };
-    });
-  }
-
-  _loadAccount() {
-    this.loading = true;
-    const promises = [
-      this.$.restAPI.getAccount(),
-      this.$.restAPI.getTopMenus(),
-      getPluginLoader().awaitPluginsLoaded(),
-    ];
-
-    return Promise.all(promises).then(result => {
-      const account = result[0];
-      this._account = account;
-      this.loggedIn = !!account;
-      this.loading = false;
-      this._topMenus = result[1];
-
-      return getAdminLinks(account,
-          params => this.$.restAPI.getAccountCapabilities(params),
-          () => this.$.jsAPI.getAdminMenuLinks())
-          .then(res => {
-            this._adminLinks = res.links;
-          });
-    });
-  }
-
-  _loadConfig() {
-    this.$.restAPI.getConfig()
-        .then(config => {
-          this._retrieveRegisterURL(config);
-          return getDocsBaseUrl(config, this.$.restAPI);
-        })
-        .then(docBaseUrl => { this._docBaseUrl = docBaseUrl; });
-  }
-
-  _accountLoaded(account) {
-    if (!account) { return; }
-
-    this.$.restAPI.getPreferences().then(prefs => {
-      this._userLinks = prefs && prefs.my ?
-        prefs.my.map(this._fixCustomMenuItem) : [];
-    });
-  }
-
-  _retrieveRegisterURL(config) {
-    if (AUTH_TYPES_WITH_REGISTER_URL.has(config.auth.auth_type)) {
-      this._registerURL = config.auth.register_url;
-      if (config.auth.register_text) {
-        this._registerText = config.auth.register_text;
-      }
-    }
-  }
-
-  _computeIsInvisible(registerURL) {
-    return registerURL ? '' : 'invisible';
-  }
-
-  _fixCustomMenuItem(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;
-  }
-
-  _generateSettingsLink() {
-    return getBaseUrl() + '/settings/';
-  }
-
-  _onMobileSearchTap(e) {
-    e.preventDefault();
-    e.stopPropagation();
-    this.dispatchEvent(new CustomEvent('mobile-search', {
-      composed: true, bubbles: false,
-    }));
-  }
-
-  _computeLinkGroupClass(linkGroup) {
-    if (linkGroup && linkGroup.class) {
-      return linkGroup.class;
-    }
-
-    return '';
-  }
-
-  _computeShowHideAriaLabel(mobileSearchHidden) {
-    if (mobileSearchHidden) {
-      return 'Show Searchbar';
-    } else {
-      return 'Hide Searchbar';
-    }
-  }
-}
-
-customElements.define(GrMainHeader.is, GrMainHeader);
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
new file mode 100644
index 0000000..caa0521
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
@@ -0,0 +1,393 @@
+/**
+ * @license
+ * 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.
+ */
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../shared/gr-dropdown/gr-dropdown';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-account-dropdown/gr-account-dropdown';
+import '../gr-smart-search/gr-smart-search';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-main-header_html';
+import {getBaseUrl, getDocsBaseUrl} from '../../../utils/url-util';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {getAdminLinks, NavLink} from '../../../utils/admin-nav-util';
+import {customElement, property, observe} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  AccountDetailInfo,
+  RequireProperties,
+  ServerInfo,
+  TopMenuEntryInfo,
+  TopMenuItemInfo,
+} from '../../../types/common';
+import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
+import {AuthType} from '../../../constants/constants';
+import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
+
+type MainHeaderLink = RequireProperties<DropdownLink, 'url' | 'name'>;
+
+interface MainHeaderLinkGroup {
+  title: string;
+  links: MainHeaderLink[];
+  class?: string;
+}
+
+const DEFAULT_LINKS: MainHeaderLinkGroup[] = [
+  {
+    title: 'Changes',
+    links: [
+      {
+        url: '/q/status:open+-is:wip',
+        name: 'Open',
+      },
+      {
+        url: '/q/status:merged',
+        name: 'Merged',
+      },
+      {
+        url: '/q/status:abandoned',
+        name: 'Abandoned',
+      },
+    ],
+  },
+];
+
+const DOCUMENTATION_LINKS: MainHeaderLink[] = [
+  {
+    url: '/index.html',
+    name: 'Table of Contents',
+  },
+  {
+    url: '/user-search.html',
+    name: 'Searching',
+  },
+  {
+    url: '/user-upload.html',
+    name: 'Uploading',
+  },
+  {
+    url: '/access-control.html',
+    name: 'Access Control',
+  },
+  {
+    url: '/rest-api.html',
+    name: 'REST API',
+  },
+  {
+    url: '/intro-project-owner.html',
+    name: 'Project Owner Guide',
+  },
+];
+
+// Set of authentication methods that can provide custom registration page.
+const AUTH_TYPES_WITH_REGISTER_URL: Set<AuthType> = new Set([
+  AuthType.LDAP,
+  AuthType.LDAP_BIND,
+  AuthType.CUSTOM_EXTENSION,
+]);
+
+export interface GrMainHeader {
+  $: {
+    restAPI: RestApiService & Element;
+    jsAPI: JsApiService & Element;
+  };
+}
+
+@customElement('gr-main-header')
+export class GrMainHeader extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String, notify: true})
+  searchQuery?: string;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  loggedIn?: boolean;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  loading?: boolean;
+
+  @property({type: Object})
+  _account?: AccountDetailInfo;
+
+  @property({type: Array})
+  _adminLinks: NavLink[] = [];
+
+  @property({type: String})
+  _docBaseUrl: string | null = null;
+
+  @property({
+    type: Array,
+    computed: '_computeLinks(_userLinks, _adminLinks, _topMenus, _docBaseUrl)',
+  })
+  _links?: MainHeaderLinkGroup[];
+
+  @property({type: String})
+  loginUrl = '/login';
+
+  @property({type: Array})
+  _userLinks: MainHeaderLink[] = [];
+
+  @property({type: Array})
+  _topMenus?: TopMenuEntryInfo[] = [];
+
+  @property({type: String})
+  _registerText = 'Sign up';
+
+  // Empty string means that the register <div> will be hidden.
+  @property({type: String})
+  _registerURL = '';
+
+  @property({type: Boolean})
+  mobileSearchHidden = false;
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._ensureAttribute('role', 'banner');
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadAccount();
+    this._loadConfig();
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+  }
+
+  reload() {
+    this._loadAccount();
+  }
+
+  _computeRelativeURL(path: string) {
+    return '//' + window.location.host + getBaseUrl() + path;
+  }
+
+  _computeLinks(
+    userLinks?: TopMenuItemInfo[],
+    adminLinks?: NavLink[],
+    topMenus?: TopMenuEntryInfo[],
+    docBaseUrl?: string | null,
+    // defaultLinks parameter is used in tests only
+    defaultLinks = DEFAULT_LINKS
+  ) {
+    // Polymer 2: check for undefined
+    if (
+      userLinks === undefined ||
+      adminLinks === undefined ||
+      topMenus === undefined ||
+      docBaseUrl === undefined
+    ) {
+      return undefined;
+    }
+
+    const links: MainHeaderLinkGroup[] = defaultLinks.map(menu => {
+      return {
+        title: menu.title,
+        links: menu.links.slice(),
+      };
+    });
+    if (userLinks && userLinks.length > 0) {
+      links.push({
+        title: 'Your',
+        links: userLinks.slice(),
+      });
+    }
+    const docLinks = this._getDocLinks(docBaseUrl, DOCUMENTATION_LINKS);
+    if (docLinks.length) {
+      links.push({
+        title: 'Documentation',
+        links: docLinks,
+        class: 'hideOnMobile',
+      });
+    }
+    links.push({
+      title: 'Browse',
+      links: adminLinks.slice(),
+    });
+    const topMenuLinks: {[name: string]: MainHeaderLink[]} = {};
+    links.forEach(link => {
+      topMenuLinks[link.title] = link.links;
+    });
+    for (const m of topMenus) {
+      const items = m.items.map(this._createHeaderLink).filter(
+        link =>
+          // Ignore GWT project links
+          !link.url.includes('${projectName}')
+      );
+      if (m.name in topMenuLinks) {
+        items.forEach(link => {
+          topMenuLinks[m.name].push(link);
+        });
+      } else {
+        links.push({
+          title: m.name,
+          links: topMenuLinks[m.name] = items,
+        });
+      }
+    }
+    return links;
+  }
+
+  _getDocLinks(docBaseUrl: string | null, docLinks: MainHeaderLink[]) {
+    if (!docBaseUrl) {
+      return [];
+    }
+    return docLinks.map(link => {
+      let url = docBaseUrl;
+      if (url && url[url.length - 1] === '/') {
+        url = url.substring(0, url.length - 1);
+      }
+      return {
+        url: url + link.url,
+        name: link.name,
+        target: '_blank',
+      };
+    });
+  }
+
+  _loadAccount() {
+    this.loading = true;
+
+    return Promise.all([
+      this.$.restAPI.getAccount(),
+      this.$.restAPI.getTopMenus(),
+      getPluginLoader().awaitPluginsLoaded(),
+    ]).then(result => {
+      const account = result[0];
+      this._account = account;
+      this.loggedIn = !!account;
+      this.loading = false;
+      this._topMenus = result[1];
+
+      return getAdminLinks(
+        account,
+        () =>
+          this.$.restAPI.getAccountCapabilities().then(capabilities => {
+            if (!capabilities) {
+              throw new Error('getAccountCapabilities returns undefined');
+            }
+            return capabilities;
+          }),
+        () => this.$.jsAPI.getAdminMenuLinks()
+      ).then(res => {
+        this._adminLinks = res.links;
+      });
+    });
+  }
+
+  _loadConfig() {
+    this.$.restAPI
+      .getConfig()
+      .then(config => {
+        if (!config) {
+          throw new Error('getConfig returned undefined');
+        }
+        this._retrieveRegisterURL(config);
+        return getDocsBaseUrl(config, this.$.restAPI);
+      })
+      .then(docBaseUrl => {
+        this._docBaseUrl = docBaseUrl;
+      });
+  }
+
+  @observe('_account')
+  _accountLoaded(account?: AccountDetailInfo) {
+    if (!account) {
+      return;
+    }
+
+    this.$.restAPI.getPreferences().then(prefs => {
+      this._userLinks =
+        prefs && prefs.my ? prefs.my.map(this._createHeaderLink) : [];
+    });
+  }
+
+  _retrieveRegisterURL(config: ServerInfo) {
+    if (AUTH_TYPES_WITH_REGISTER_URL.has(config.auth.auth_type)) {
+      this._registerURL = config.auth.register_url ?? '';
+      if (config.auth.register_text) {
+        this._registerText = config.auth.register_text;
+      }
+    }
+  }
+
+  _computeRegisterHidden(registerURL: string) {
+    return !registerURL;
+  }
+
+  _createHeaderLink(linkObj: TopMenuItemInfo): MainHeaderLink {
+    // 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.
+    const {target, ...headerLink} = {...linkObj};
+
+    // Normalize all urls to PolyGerrit style.
+    if (headerLink.url.startsWith('#')) {
+      headerLink.url = linkObj.url.slice(1);
+    }
+
+    return headerLink;
+  }
+
+  _generateSettingsLink() {
+    return getBaseUrl() + '/settings/';
+  }
+
+  _onMobileSearchTap(e: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('mobile-search', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+
+  _computeLinkGroupClass(linkGroup: MainHeaderLinkGroup) {
+    return linkGroup.class ?? '';
+  }
+
+  _computeShowHideAriaLabel(mobileSearchHidden: boolean) {
+    if (mobileSearchHidden) {
+      return 'Show Searchbar';
+    } else {
+      return 'Hide Searchbar';
+    }
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-main-header': GrMainHeader;
+  }
+}
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts
index a4554d3..5778fb8 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts
@@ -214,7 +214,10 @@
           role="button"
           aria-label="[[_computeShowHideAriaLabel(mobileSearchHidden)]]"
         ></iron-icon>
-        <div class$="[[_computeIsInvisible(_registerURL)]]">
+        <div
+          class="registerDiv"
+          hidden="[[_computeRegisterHidden(_registerURL)]]"
+        >
           <a class="registerButton" href$="[[_registerURL]]">
             [[_registerText]]
           </a>
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.js
deleted file mode 100644
index 48194a6..0000000
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.js
+++ /dev/null
@@ -1,392 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-main-header.js';
-
-const basicFixture = fixtureFromElement('gr-main-header');
-
-suite('gr-main-header tests', () => {
-  let element;
-
-  setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      probePath(path) { return Promise.resolve(false); },
-    });
-    stub('gr-main-header', {
-      _loadAccount() {},
-    });
-    element = basicFixture.instantiate();
-  });
-
-  test('link visibility', () => {
-    element.loading = true;
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('.accountContainer')).display,
-    'none');
-    element.loading = false;
-    element.loggedIn = false;
-    assert.notEqual(getComputedStyle(element.shadowRoot
-        .querySelector('.accountContainer')).display,
-    'none');
-    assert.notEqual(getComputedStyle(element.shadowRoot
-        .querySelector('.loginButton')).display,
-    'none');
-    assert.notEqual(getComputedStyle(element.shadowRoot
-        .querySelector('.registerButton')).display,
-    'none');
-    element._account = {};
-    flush();
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('gr-account-dropdown')).display,
-    'none');
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('.settingsButton')).display,
-    'none');
-    element.loggedIn = true;
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('.loginButton')).display,
-    'none');
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('.registerButton')).display,
-    'none');
-    assert.notEqual(getComputedStyle(element.shadowRoot
-        .querySelector('gr-account-dropdown'))
-        .display,
-    'none');
-    assert.notEqual(getComputedStyle(element.shadowRoot
-        .querySelector('.settingsButton')).display,
-    'none');
-  });
-
-  test('fix my menu item', () => {
-    assert.deepEqual([
-      {url: 'https://awesometown.com/#hashyhash'},
-      {url: 'url', target: '_blank'},
-    ].map(element._fixCustomMenuItem), [
-      {url: 'https://awesometown.com/#hashyhash'},
-      {url: 'url'},
-    ]);
-  });
-
-  test('user links', () => {
-    const defaultLinks = [{
-      title: 'Faves',
-      links: [{
-        name: 'Pinterest',
-        url: 'https://pinterest.com',
-      }],
-    }];
-    const userLinks = [{
-      name: 'Facebook',
-      url: 'https://facebook.com',
-    }];
-    const adminLinks = [{
-      name: 'Repos',
-      url: '/repos',
-    }];
-
-    // When no admin links are passed, it should use the default.
-    assert.deepEqual(element._computeLinks(
-        defaultLinks,
-        /* userLinks= */[],
-        adminLinks,
-        /* topMenus= */[],
-        /* docBaseUrl= */ ''
-    ),
-    defaultLinks.concat({
-      title: 'Browse',
-      links: adminLinks,
-    }));
-    assert.deepEqual(element._computeLinks(
-        defaultLinks,
-        userLinks,
-        adminLinks,
-        /* topMenus= */[],
-        /* docBaseUrl= */ ''
-    ),
-    defaultLinks.concat([
-      {
-        title: 'Your',
-        links: userLinks,
-      },
-      {
-        title: 'Browse',
-        links: adminLinks,
-      }])
-    );
-  });
-
-  test('documentation links', () => {
-    const docLinks = [
-      {
-        name: 'Table of Contents',
-        url: '/index.html',
-      },
-    ];
-
-    assert.deepEqual(element._getDocLinks(null, docLinks), []);
-    assert.deepEqual(element._getDocLinks('', docLinks), []);
-    assert.deepEqual(element._getDocLinks('base', null), []);
-    assert.deepEqual(element._getDocLinks('base', []), []);
-
-    assert.deepEqual(element._getDocLinks('base', docLinks), [{
-      name: 'Table of Contents',
-      target: '_blank',
-      url: 'base/index.html',
-    }]);
-
-    assert.deepEqual(element._getDocLinks('base/', docLinks), [{
-      name: 'Table of Contents',
-      target: '_blank',
-      url: 'base/index.html',
-    }]);
-  });
-
-  test('top menus', () => {
-    const adminLinks = [{
-      name: 'Repos',
-      url: '/repos',
-    }];
-    const topMenus = [{
-      name: 'Plugins',
-      items: [{
-        name: 'Manage',
-        target: '_blank',
-        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-      }],
-    }];
-    assert.deepEqual(element._computeLinks(
-        /* defaultLinks= */ [],
-        /* userLinks= */ [],
-        adminLinks,
-        topMenus,
-        /* baseDocUrl= */ ''
-    ), [{
-      title: 'Browse',
-      links: adminLinks,
-    },
-    {
-      title: 'Plugins',
-      links: [{
-        name: 'Manage',
-        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-      }],
-    }]);
-  });
-
-  test('ignore top project menus', () => {
-    const adminLinks = [{
-      name: 'Repos',
-      url: '/repos',
-    }];
-    const topMenus = [{
-      name: 'Projects',
-      items: [{
-        name: 'Project Settings',
-        target: '_blank',
-        url: '/plugins/myplugin/${projectName}',
-      }, {
-        name: 'Project List',
-        target: '_blank',
-        url: '/plugins/myplugin/index.html',
-      }],
-    }];
-    assert.deepEqual(element._computeLinks(
-        /* defaultLinks= */ [],
-        /* userLinks= */ [],
-        adminLinks,
-        topMenus,
-        /* baseDocUrl= */ ''
-    ), [{
-      title: 'Browse',
-      links: adminLinks,
-    },
-    {
-      title: 'Projects',
-      links: [{
-        name: 'Project List',
-        url: '/plugins/myplugin/index.html',
-      }],
-    }]);
-  });
-
-  test('merge top menus', () => {
-    const adminLinks = [{
-      name: 'Repos',
-      url: '/repos',
-    }];
-    const topMenus = [{
-      name: 'Plugins',
-      items: [{
-        name: 'Manage',
-        target: '_blank',
-        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-      }],
-    }, {
-      name: 'Plugins',
-      items: [{
-        name: 'Create',
-        target: '_blank',
-        url: 'https://gerrit/plugins/plugin-manager/static/create.html',
-      }],
-    }];
-    assert.deepEqual(element._computeLinks(
-        /* defaultLinks= */ [],
-        /* userLinks= */ [],
-        adminLinks,
-        topMenus,
-        /* baseDocUrl= */ ''
-    ), [{
-      title: 'Browse',
-      links: adminLinks,
-    }, {
-      title: 'Plugins',
-      links: [{
-        name: 'Manage',
-        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-      }, {
-        name: 'Create',
-        url: 'https://gerrit/plugins/plugin-manager/static/create.html',
-      }],
-    }]);
-  });
-
-  test('merge top menus in default links', () => {
-    const defaultLinks = [{
-      title: 'Faves',
-      links: [{
-        name: 'Pinterest',
-        url: 'https://pinterest.com',
-      }],
-    }];
-    const topMenus = [{
-      name: 'Faves',
-      items: [{
-        name: 'Manage',
-        target: '_blank',
-        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-      }],
-    }];
-    assert.deepEqual(element._computeLinks(
-        defaultLinks,
-        /* userLinks= */ [],
-        /* adminLinks= */ [],
-        topMenus,
-        /* baseDocUrl= */ ''
-    ), [{
-      title: 'Faves',
-      links: defaultLinks[0].links.concat([{
-        name: 'Manage',
-        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-      }]),
-    }, {
-      title: 'Browse',
-      links: [],
-    }]);
-  });
-
-  test('merge top menus in user links', () => {
-    const userLinks = [{
-      name: 'Facebook',
-      url: 'https://facebook.com',
-    }];
-    const topMenus = [{
-      name: 'Your',
-      items: [{
-        name: 'Manage',
-        target: '_blank',
-        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-      }],
-    }];
-    assert.deepEqual(element._computeLinks(
-        /* defaultLinks= */ [],
-        userLinks,
-        /* adminLinks= */ [],
-        topMenus,
-        /* baseDocUrl= */ ''
-    ), [{
-      title: 'Your',
-      links: userLinks.concat([{
-        name: 'Manage',
-        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-      }]),
-    }, {
-      title: 'Browse',
-      links: [],
-    }]);
-  });
-
-  test('merge top menus in admin links', () => {
-    const adminLinks = [{
-      name: 'Repos',
-      url: '/repos',
-    }];
-    const topMenus = [{
-      name: 'Browse',
-      items: [{
-        name: 'Manage',
-        target: '_blank',
-        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-      }],
-    }];
-    assert.deepEqual(element._computeLinks(
-        /* defaultLinks= */ [],
-        /* userLinks= */ [],
-        adminLinks,
-        topMenus,
-        /* baseDocUrl= */ ''
-    ), [{
-      title: 'Browse',
-      links: adminLinks.concat([{
-        name: 'Manage',
-        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-      }]),
-    }]);
-  });
-
-  test('register URL', () => {
-    const config = {
-      auth: {
-        auth_type: 'LDAP',
-        register_url: 'https//gerrit.example.com/register',
-      },
-    };
-    element._retrieveRegisterURL(config);
-    assert.equal(element._registerURL, config.auth.register_url);
-    assert.equal(element._registerText, 'Sign up');
-
-    config.auth.register_text = 'Create account';
-    element._retrieveRegisterURL(config);
-    assert.equal(element._registerURL, config.auth.register_url);
-    assert.equal(element._registerText, config.auth.register_text);
-  });
-
-  test('register URL ignored for wrong auth type', () => {
-    const config = {
-      auth: {
-        auth_type: 'OPENID',
-        register_url: 'https//gerrit.example.com/register',
-      },
-    };
-    element._retrieveRegisterURL(config);
-    assert.equal(element._registerURL, null);
-    assert.equal(element._registerText, 'Sign up');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
new file mode 100644
index 0000000..3ab40e9
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
@@ -0,0 +1,521 @@
+/**
+ * @license
+ * 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.
+ */
+
+import '../../../test/common-test-setup-karma';
+import {isHidden, query} from '../../../test/test-utils';
+import './gr-main-header';
+import {GrMainHeader} from './gr-main-header';
+import {
+  createAccountDetailWithId,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import {NavLink} from '../../../utils/admin-nav-util';
+import {ServerInfo, TopMenuItemInfo} from '../../../types/common';
+import {AuthType} from '../../../constants/constants';
+
+const basicFixture = fixtureFromElement('gr-main-header');
+
+suite('gr-main-header tests', () => {
+  let element: GrMainHeader;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() {
+        return Promise.resolve(createServerInfo());
+      },
+      probePath(_) {
+        return Promise.resolve(false);
+      },
+    });
+    stub('gr-main-header', {
+      _loadAccount() {
+        return Promise.resolve();
+      },
+    });
+    element = basicFixture.instantiate();
+  });
+
+  test('link visibility', () => {
+    element.loading = true;
+    assert.isTrue(isHidden(query(element, '.accountContainer')));
+
+    element.loading = false;
+    element.loggedIn = false;
+    assert.isFalse(isHidden(query(element, '.accountContainer')));
+    assert.isFalse(isHidden(query(element, '.loginButton')));
+    assert.isFalse(isHidden(query(element, '.registerButton')));
+    assert.isTrue(isHidden(query(element, '.registerDiv')));
+
+    element._account = createAccountDetailWithId(1);
+    flush();
+    assert.isTrue(isHidden(query(element, 'gr-account-dropdown')));
+    assert.isTrue(isHidden(query(element, '.settingsButton')));
+
+    element.loggedIn = true;
+    assert.isTrue(isHidden(query(element, '.loginButton')));
+    assert.isTrue(isHidden(query(element, '.registerButton')));
+    assert.isFalse(isHidden(query(element, 'gr-account-dropdown')));
+    assert.isFalse(isHidden(query(element, '.settingsButton')));
+  });
+
+  test('fix my menu item', () => {
+    assert.deepEqual(
+      [
+        {url: 'https://awesometown.com/#hashyhash', name: '', target: ''},
+        {url: 'url', name: '', target: '_blank'},
+      ].map(element._createHeaderLink),
+      [
+        {url: 'https://awesometown.com/#hashyhash', name: ''},
+        {url: 'url', name: ''},
+      ]
+    );
+  });
+
+  test('user links', () => {
+    const defaultLinks = [
+      {
+        title: 'Faves',
+        links: [
+          {
+            name: 'Pinterest',
+            url: 'https://pinterest.com',
+          },
+        ],
+      },
+    ];
+    const userLinks: TopMenuItemInfo[] = [
+      {
+        name: 'Facebook',
+        url: 'https://facebook.com',
+        target: '',
+      },
+    ];
+    const adminLinks: NavLink[] = [
+      {
+        name: 'Repos',
+        url: '/repos',
+        noBaseUrl: true,
+        view: null,
+      },
+    ];
+
+    // When no admin links are passed, it should use the default.
+    assert.deepEqual(
+      element._computeLinks(
+        /* userLinks= */ [],
+        adminLinks,
+        /* topMenus= */ [],
+        /* docBaseUrl= */ '',
+        defaultLinks
+      ),
+      defaultLinks.concat({
+        title: 'Browse',
+        links: adminLinks,
+      })
+    );
+    assert.deepEqual(
+      element._computeLinks(
+        userLinks,
+        adminLinks,
+        /* topMenus= */ [],
+        /* docBaseUrl= */ '',
+        defaultLinks
+      ),
+      defaultLinks.concat([
+        {
+          title: 'Your',
+          links: userLinks,
+        },
+        {
+          title: 'Browse',
+          links: adminLinks,
+        },
+      ])
+    );
+  });
+
+  test('documentation links', () => {
+    const docLinks = [
+      {
+        name: 'Table of Contents',
+        url: '/index.html',
+      },
+    ];
+
+    assert.deepEqual(element._getDocLinks(null, docLinks), []);
+    assert.deepEqual(element._getDocLinks('', docLinks), []);
+    assert.deepEqual(element._getDocLinks('base', []), []);
+
+    assert.deepEqual(element._getDocLinks('base', docLinks), [
+      {
+        name: 'Table of Contents',
+        target: '_blank',
+        url: 'base/index.html',
+      },
+    ]);
+
+    assert.deepEqual(element._getDocLinks('base/', docLinks), [
+      {
+        name: 'Table of Contents',
+        target: '_blank',
+        url: 'base/index.html',
+      },
+    ]);
+  });
+
+  test('top menus', () => {
+    const adminLinks: NavLink[] = [
+      {
+        name: 'Repos',
+        url: '/repos',
+        noBaseUrl: true,
+        view: null,
+      },
+    ];
+    const topMenus = [
+      {
+        name: 'Plugins',
+        items: [
+          {
+            name: 'Manage',
+            target: '_blank',
+            url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+          },
+        ],
+      },
+    ];
+    assert.deepEqual(
+      element._computeLinks(
+        /* userLinks= */ [],
+        adminLinks,
+        topMenus,
+        /* baseDocUrl= */ '',
+        /* defaultLinks= */ []
+      ),
+      [
+        {
+          title: 'Browse',
+          links: adminLinks,
+        },
+        {
+          title: 'Plugins',
+          links: [
+            {
+              name: 'Manage',
+              url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+            },
+          ],
+        },
+      ]
+    );
+  });
+
+  test('ignore top project menus', () => {
+    const adminLinks: NavLink[] = [
+      {
+        name: 'Repos',
+        url: '/repos',
+        noBaseUrl: true,
+        view: null,
+      },
+    ];
+    const topMenus = [
+      {
+        name: 'Projects',
+        items: [
+          {
+            name: 'Project Settings',
+            target: '_blank',
+            url: '/plugins/myplugin/${projectName}',
+          },
+          {
+            name: 'Project List',
+            target: '_blank',
+            url: '/plugins/myplugin/index.html',
+          },
+        ],
+      },
+    ];
+    assert.deepEqual(
+      element._computeLinks(
+        /* userLinks= */ [],
+        adminLinks,
+        topMenus,
+        /* baseDocUrl= */ '',
+        /* defaultLinks= */ []
+      ),
+      [
+        {
+          title: 'Browse',
+          links: adminLinks,
+        },
+        {
+          title: 'Projects',
+          links: [
+            {
+              name: 'Project List',
+              url: '/plugins/myplugin/index.html',
+            },
+          ],
+        },
+      ]
+    );
+  });
+
+  test('merge top menus', () => {
+    const adminLinks: NavLink[] = [
+      {
+        name: 'Repos',
+        url: '/repos',
+        noBaseUrl: true,
+        view: null,
+      },
+    ];
+    const topMenus = [
+      {
+        name: 'Plugins',
+        items: [
+          {
+            name: 'Manage',
+            target: '_blank',
+            url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+          },
+        ],
+      },
+      {
+        name: 'Plugins',
+        items: [
+          {
+            name: 'Create',
+            target: '_blank',
+            url: 'https://gerrit/plugins/plugin-manager/static/create.html',
+          },
+        ],
+      },
+    ];
+    assert.deepEqual(
+      element._computeLinks(
+        /* userLinks= */ [],
+        adminLinks,
+        topMenus,
+        /* baseDocUrl= */ '',
+        /* defaultLinks= */ []
+      ),
+      [
+        {
+          title: 'Browse',
+          links: adminLinks,
+        },
+        {
+          title: 'Plugins',
+          links: [
+            {
+              name: 'Manage',
+              url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+            },
+            {
+              name: 'Create',
+              url: 'https://gerrit/plugins/plugin-manager/static/create.html',
+            },
+          ],
+        },
+      ]
+    );
+  });
+
+  test('merge top menus in default links', () => {
+    const defaultLinks = [
+      {
+        title: 'Faves',
+        links: [
+          {
+            name: 'Pinterest',
+            url: 'https://pinterest.com',
+          },
+        ],
+      },
+    ];
+    const topMenus = [
+      {
+        name: 'Faves',
+        items: [
+          {
+            name: 'Manage',
+            target: '_blank',
+            url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+          },
+        ],
+      },
+    ];
+    assert.deepEqual(
+      element._computeLinks(
+        /* userLinks= */ [],
+        /* adminLinks= */ [],
+        topMenus,
+        /* baseDocUrl= */ '',
+        defaultLinks
+      ),
+      [
+        {
+          title: 'Faves',
+          links: defaultLinks[0].links.concat([
+            {
+              name: 'Manage',
+              url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+            },
+          ]),
+        },
+        {
+          title: 'Browse',
+          links: [],
+        },
+      ]
+    );
+  });
+
+  test('merge top menus in user links', () => {
+    const userLinks = [
+      {
+        name: 'Facebook',
+        url: 'https://facebook.com',
+        target: '',
+      },
+    ];
+    const topMenus = [
+      {
+        name: 'Your',
+        items: [
+          {
+            name: 'Manage',
+            target: '_blank',
+            url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+          },
+        ],
+      },
+    ];
+    assert.deepEqual(
+      element._computeLinks(
+        userLinks,
+        /* adminLinks= */ [],
+        topMenus,
+        /* baseDocUrl= */ '',
+        /* defaultLinks= */ []
+      ),
+      [
+        {
+          title: 'Your',
+          links: [
+            {
+              name: 'Facebook',
+              url: 'https://facebook.com',
+              target: '',
+            },
+            {
+              name: 'Manage',
+              url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+            },
+          ],
+        },
+        {
+          title: 'Browse',
+          links: [],
+        },
+      ]
+    );
+  });
+
+  test('merge top menus in admin links', () => {
+    const adminLinks: NavLink[] = [
+      {
+        name: 'Repos',
+        url: '/repos',
+        noBaseUrl: true,
+        view: null,
+      },
+    ];
+    const topMenus = [
+      {
+        name: 'Browse',
+        items: [
+          {
+            name: 'Manage',
+            target: '_blank',
+            url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+          },
+        ],
+      },
+    ];
+    assert.deepEqual(
+      element._computeLinks(
+        /* userLinks= */ [],
+        adminLinks,
+        topMenus,
+        /* baseDocUrl= */ '',
+        /* defaultLinks= */ []
+      ),
+      [
+        {
+          title: 'Browse',
+          links: [
+            adminLinks[0],
+            {
+              name: 'Manage',
+              url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+            },
+          ],
+        },
+      ]
+    );
+  });
+
+  test('register URL', () => {
+    assert.isTrue(isHidden(query(element, '.registerDiv')));
+    const config: ServerInfo = {
+      ...createServerInfo(),
+      auth: {
+        auth_type: AuthType.LDAP,
+        register_url: 'https//gerrit.example.com/register',
+        editable_account_fields: [],
+      },
+    };
+    element._retrieveRegisterURL(config);
+    assert.equal(element._registerURL, config.auth.register_url);
+    assert.equal(element._registerText, 'Sign up');
+    assert.isFalse(isHidden(query(element, '.registerDiv')));
+
+    config.auth.register_text = 'Create account';
+    element._retrieveRegisterURL(config);
+    assert.equal(element._registerURL, config.auth.register_url);
+    assert.equal(element._registerText, config.auth.register_text);
+    assert.isFalse(isHidden(query(element, '.registerDiv')));
+  });
+
+  test('register URL ignored for wrong auth type', () => {
+    const config: ServerInfo = {
+      ...createServerInfo(),
+      auth: {
+        auth_type: AuthType.OPENID,
+        register_url: 'https//gerrit.example.com/register',
+        editable_account_fields: [],
+      },
+    };
+    element._retrieveRegisterURL(config);
+    assert.equal(element._registerURL, '');
+    assert.equal(element._registerText, 'Sign up');
+    assert.isTrue(isHidden(query(element, '.registerDiv')));
+  });
+});
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index db0bd64..8470611 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -32,6 +32,7 @@
   ParentPatchSetNum,
   ServerInfo,
 } from '../../../types/common';
+import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 
 // Navigation parameters object format:
 //
@@ -126,7 +127,7 @@
 export interface DashboardSection {
   name: string;
   query: string;
-  suffixForDashboard: string;
+  suffixForDashboard?: string;
   attentionSetOnly?: boolean;
   selfOnly?: boolean;
   hideIfEmpty?: boolean;
@@ -140,87 +141,95 @@
 }
 
 export interface UserDashboard {
-  title: string;
+  title?: string;
   sections: DashboardSection[];
 }
 
 // NOTE: These queries are tested in Java. Any changes made to definitions
 // here require corresponding changes to:
 // java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+const HAS_DRAFTS: DashboardSection = {
+  // Changes with unpublished draft comments. This section is omitted when
+  // viewing other users, so we don't need to filter anything out.
+  name: 'Has draft comments',
+  query: 'has:draft',
+  selfOnly: true,
+  hideIfEmpty: true,
+  suffixForDashboard: 'limit:10',
+};
+export const YOUR_TURN: DashboardSection = {
+  // Changes where the user is in the attention set.
+  name: 'Your Turn',
+  query: 'attention:${user}',
+  hideIfEmpty: false,
+  suffixForDashboard: 'limit:25',
+  attentionSetOnly: true,
+};
+const ASSIGNED: DashboardSection = {
+  // Changes that are assigned to the viewed user.
+  name: 'Assigned reviews',
+  query:
+    'assignee:${user} (-is:wip OR owner:self OR assignee:self) ' +
+    'is:open -is:ignored',
+  hideIfEmpty: true,
+  suffixForDashboard: 'limit:25',
+  assigneeOnly: true,
+};
+const WIP: DashboardSection = {
+  // WIP open changes owned by viewing user. This section is omitted when
+  // viewing other users, so we don't need to filter anything out.
+  name: 'Work in progress',
+  query: 'is:open owner:${user} is:wip',
+  selfOnly: true,
+  hideIfEmpty: true,
+  suffixForDashboard: 'limit:25',
+};
+const OUTGOING: DashboardSection = {
+  // Non-WIP open changes owned by viewed user. Filter out changes ignored
+  // by the viewing user.
+  name: 'Outgoing reviews',
+  query: 'is:open owner:${user} -is:wip -is:ignored',
+  isOutgoing: true,
+  suffixForDashboard: 'limit:25',
+};
+const INCOMING: DashboardSection = {
+  // Non-WIP open changes not owned by the viewed user, that the viewed user
+  // is associated with (as either a reviewer or the assignee). Changes
+  // ignored by the viewing user are filtered out.
+  name: 'Incoming reviews',
+  query:
+    'is:open -owner:${user} -is:wip -is:ignored ' +
+    '(reviewer:${user} OR assignee:${user})',
+  suffixForDashboard: 'limit:25',
+};
+const CCED: DashboardSection = {
+  // Open changes the viewed user is CCed on. Changes ignored by the viewing
+  // user are filtered out.
+  name: 'CCed on',
+  query: 'is:open -is:ignored cc:${user}',
+  suffixForDashboard: 'limit:10',
+};
+export const CLOSED: DashboardSection = {
+  name: 'Recently closed',
+  // Closed changes where viewed user is owner, reviewer, or assignee.
+  // Changes ignored by the viewing user are filtered out, and so are WIP
+  // changes not owned by the viewing user (the one instance of
+  // 'owner:self' is intentional and implements this logic).
+  query:
+    'is:closed -is:ignored (-is:wip OR owner:self) ' +
+    '(owner:${user} OR reviewer:${user} OR assignee:${user} ' +
+    'OR cc:${user})',
+  suffixForDashboard: '-age:4w limit:10',
+};
 const DEFAULT_SECTIONS: DashboardSection[] = [
-  {
-    // Changes with unpublished draft comments. This section is omitted when
-    // viewing other users, so we don't need to filter anything out.
-    name: 'Has draft comments',
-    query: 'has:draft',
-    selfOnly: true,
-    hideIfEmpty: true,
-    suffixForDashboard: 'limit:10',
-  },
-  {
-    // Changes where the user is in the attention set.
-    name: 'Your Turn',
-    query: 'attention:${user}',
-    hideIfEmpty: false,
-    suffixForDashboard: 'limit:25',
-    attentionSetOnly: true,
-  },
-  {
-    // Changes that are assigned to the viewed user.
-    name: 'Assigned reviews',
-    query:
-      'assignee:${user} (-is:wip OR owner:self OR assignee:self) ' +
-      'is:open -is:ignored',
-    hideIfEmpty: true,
-    suffixForDashboard: 'limit:25',
-    assigneeOnly: true,
-  },
-  {
-    // WIP open changes owned by viewing user. This section is omitted when
-    // viewing other users, so we don't need to filter anything out.
-    name: 'Work in progress',
-    query: 'is:open owner:${user} is:wip',
-    selfOnly: true,
-    hideIfEmpty: true,
-    suffixForDashboard: 'limit:25',
-  },
-  {
-    // Non-WIP open changes owned by viewed user. Filter out changes ignored
-    // by the viewing user.
-    name: 'Outgoing reviews',
-    query: 'is:open owner:${user} -is:wip -is:ignored',
-    isOutgoing: true,
-    suffixForDashboard: 'limit:25',
-  },
-  {
-    // Non-WIP open changes not owned by the viewed user, that the viewed user
-    // is associated with (as either a reviewer or the assignee). Changes
-    // ignored by the viewing user are filtered out.
-    name: 'Incoming reviews',
-    query:
-      'is:open -owner:${user} -is:wip -is:ignored ' +
-      '(reviewer:${user} OR assignee:${user})',
-    suffixForDashboard: 'limit:25',
-  },
-  {
-    // Open changes the viewed user is CCed on. Changes ignored by the viewing
-    // user are filtered out.
-    name: 'CCed on',
-    query: 'is:open -is:ignored cc:${user}',
-    suffixForDashboard: 'limit:10',
-  },
-  {
-    name: 'Recently closed',
-    // Closed changes where viewed user is owner, reviewer, or assignee.
-    // Changes ignored by the viewing user are filtered out, and so are WIP
-    // changes not owned by the viewing user (the one instance of
-    // 'owner:self' is intentional and implements this logic).
-    query:
-      'is:closed -is:ignored (-is:wip OR owner:self) ' +
-      '(owner:${user} OR reviewer:${user} OR assignee:${user} ' +
-      'OR cc:${user})',
-    suffixForDashboard: '-age:4w limit:10',
-  },
+  HAS_DRAFTS,
+  YOUR_TURN,
+  ASSIGNED,
+  WIP,
+  OUTGOING,
+  INCOMING,
+  CCED,
+  CLOSED,
 ];
 
 export interface GenerateUrlSearchViewParameters {
@@ -656,7 +665,7 @@
    * @param basePatchNum The string 'PARENT' can be used for none.
    */
   getUrlForDiff(
-    change: ChangeInfo,
+    change: ChangeInfo | ParsedChangeInfo,
     filePath: string,
     patchNum?: PatchSetNum,
     basePatchNum?: PatchSetNum,
@@ -715,7 +724,7 @@
   },
 
   getEditUrlForDiff(
-    change: ChangeInfo,
+    change: ChangeInfo | ParsedChangeInfo,
     filePath: string,
     patchNum?: PatchSetNum,
     lineNum?: number
@@ -755,7 +764,7 @@
    * @param basePatchNum The string 'PARENT' can be used for none.
    */
   navigateToDiff(
-    change: ChangeInfo,
+    change: ChangeInfo | ParsedChangeInfo,
     filePath: string,
     patchNum?: PatchSetNum,
     basePatchNum?: PatchSetNum,
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index aa2e4ce..21bf900 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -48,10 +48,14 @@
   GeneratedWebLink,
 } from '../gr-navigation/gr-navigation';
 import {appContext} from '../../../services/app-context';
-import {patchNumEquals} from '../../../utils/patch-set-util';
+import {
+  patchNumEquals,
+  convertToPatchSetNum,
+} from '../../../utils/patch-set-util';
 import {customElement, property} from '@polymer/decorators';
 import {assertNever} from '../../../utils/common-util';
 import {
+  DashboardId,
   GroupId,
   NumericChangeId,
   PatchSetNum,
@@ -65,6 +69,7 @@
   AppElementParams,
   AppElementAgreementParam,
 } from '../../gr-app-types';
+import {LocationChangeEventDetail} from '../../../types/events';
 
 const RoutePattern = {
   ROOT: '/',
@@ -727,7 +732,7 @@
     }
     return {
       leftSide: !!match[1],
-      lineNum: parseInt(match[2], 10),
+      lineNum: Number(match[2]),
     };
   }
 
@@ -853,12 +858,13 @@
       // Fire asynchronously so that the URL is changed by the time the event
       // is processed.
       this.async(() => {
+        const detail: LocationChangeEventDetail = {
+          hash: window.location.hash,
+          pathname: window.location.pathname,
+        };
         this.dispatchEvent(
           new CustomEvent('location-change', {
-            detail: {
-              hash: window.location.hash,
-              pathname: window.location.pathname,
-            },
+            detail,
             composed: true,
             bubbles: true,
           })
@@ -1256,7 +1262,7 @@
     this._setParams({
       view: GerritView.DASHBOARD,
       project,
-      dashboard: decodeURIComponent(data.params[1]),
+      dashboard: decodeURIComponent(data.params[1]) as DashboardId,
     });
     this.reporting.setRepoName(project);
   }
@@ -1536,8 +1542,8 @@
       project: ctx.params[0] as RepoName,
       // TODO(TS): remove as unknown
       changeNum: (ctx.params[1] as unknown) as NumericChangeId,
-      basePatchNum: ctx.params[4] as PatchSetNum,
-      patchNum: ctx.params[6] as PatchSetNum,
+      basePatchNum: convertToPatchSetNum(ctx.params[4]),
+      patchNum: convertToPatchSetNum(ctx.params[6]),
       view: GerritView.CHANGE,
       queryMap: ctx.queryMap,
     };
@@ -1563,8 +1569,8 @@
     const params: GenerateUrlDiffViewParameters = {
       project: ctx.params[0] as RepoName,
       changeNum: (ctx.params[1] as unknown) as NumericChangeId,
-      basePatchNum: ctx.params[4] as PatchSetNum,
-      patchNum: ctx.params[6] as PatchSetNum,
+      basePatchNum: convertToPatchSetNum(ctx.params[4]),
+      patchNum: convertToPatchSetNum(ctx.params[6]),
       path: ctx.params[8],
       view: GerritView.DIFF,
     };
@@ -1581,8 +1587,8 @@
     // Parameter order is based on the regex group number matched.
     const params: GenerateUrlLegacyChangeViewParameters = {
       changeNum: (ctx.params[0] as unknown) as NumericChangeId,
-      basePatchNum: ctx.params[3] as PatchSetNum,
-      patchNum: ctx.params[5] as PatchSetNum,
+      basePatchNum: convertToPatchSetNum(ctx.params[3]),
+      patchNum: convertToPatchSetNum(ctx.params[5]),
       view: GerritView.CHANGE,
       querystring: ctx.querystring,
     };
@@ -1599,8 +1605,8 @@
     const params: GenerateUrlLegacyDiffViewParameters = {
       // TODO(TS): remove "as unknown"
       changeNum: (ctx.params[0] as unknown) as NumericChangeId,
-      basePatchNum: ctx.params[2] as PatchSetNum,
-      patchNum: ctx.params[4] as PatchSetNum,
+      basePatchNum: convertToPatchSetNum(ctx.params[2]),
+      patchNum: convertToPatchSetNum(ctx.params[4]),
       path: ctx.params[5],
       view: GerritView.DIFF,
     };
@@ -1620,7 +1626,8 @@
     this._redirectOrNavigate({
       project,
       changeNum: (ctx.params[1] as unknown) as NumericChangeId,
-      patchNum: ctx.params[2] as PatchSetNum,
+      // for edit view params, patchNum cannot be undefined
+      patchNum: convertToPatchSetNum(ctx.params[2])!,
       path: ctx.params[3],
       lineNum: ctx.hash,
       view: GerritView.EDIT,
@@ -1635,7 +1642,7 @@
       project,
       // TODO(TS): remove "as unknown"
       changeNum: (ctx.params[1] as unknown) as NumericChangeId,
-      patchNum: ctx.params[3] as PatchSetNum,
+      patchNum: convertToPatchSetNum(ctx.params[3]),
       view: GerritView.CHANGE,
       edit: true,
     });
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
index acc2cbd..d785a2f 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
@@ -23,7 +23,6 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-search-bar_html';
 import {
-  CustomKeyboardEvent,
   KeyboardShortcutMixin,
   Shortcut,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
@@ -36,9 +35,11 @@
   GrAutocomplete,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {getDocsBaseUrl} from '../../../utils/url-util';
+import {CustomKeyboardEvent} from '../../../types/events';
+import {MergeabilityComputationBehavior} from '../../../constants/constants';
 
 // Possible static search options for auto complete, without negations.
-const SEARCH_OPERATORS = [
+const SEARCH_OPERATORS: ReadonlyArray<string> = [
   'added:',
   'age:',
   'age:1week', // Give an example age
@@ -116,7 +117,7 @@
 ];
 
 // All of the ops, with corresponding negations.
-const SEARCH_OPERATORS_WITH_NEGATIONS_SET = new Set(
+const SEARCH_OPERATORS_WITH_NEGATIONS_SET: ReadonlySet<string> = new Set(
   SEARCH_OPERATORS.concat(SEARCH_OPERATORS.map(op => `-${op}`))
 );
 
@@ -124,11 +125,15 @@
 
 const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+\s*/g;
 
-type SuggestionProvider = (
+export type SuggestionProvider = (
   predicate: string,
   expression: string
 ) => Promise<AutocompleteSuggestion[]>;
 
+export interface SearchBarHandleSearchDetail {
+  inputVal: string;
+}
+
 export interface GrSearchBar {
   $: {
     restAPI: RestApiService & Element;
@@ -144,6 +149,8 @@
     return htmlTemplate;
   }
 
+  private searchOperators = new Set(SEARCH_OPERATORS_WITH_NEGATIONS_SET);
+
   /**
    * Fired when a search is committed
    *
@@ -193,10 +200,12 @@
         serverConfig.change &&
         serverConfig.change.mergeability_computation_behavior;
       if (
-        mergeability === 'API_REF_UPDATED_AND_CHANGE_REINDEX' ||
-        mergeability === 'REF_UPDATED_AND_CHANGE_REINDEX'
+        mergeability ===
+          MergeabilityComputationBehavior.API_REF_UPDATED_AND_CHANGE_REINDEX ||
+        mergeability ===
+          MergeabilityComputationBehavior.REF_UPDATED_AND_CHANGE_REINDEX
       ) {
-        // add 'is:mergeable' to SEARCH_OPERATORS_WITH_NEGATIONS_SET
+        // add 'is:mergeable' to searchOperators
         this._addOperator('is:mergeable');
       }
       if (serverConfig) {
@@ -218,9 +227,9 @@
   }
 
   _addOperator(name: string, include_neg = true) {
-    SEARCH_OPERATORS_WITH_NEGATIONS_SET.add(name);
+    this.searchOperators.add(name);
     if (include_neg) {
-      SEARCH_OPERATORS_WITH_NEGATIONS_SET.add(`-${name}`);
+      this.searchOperators.add(`-${name}`);
     }
   }
 
@@ -254,17 +263,21 @@
     } else {
       target.blur();
     }
-    const trimmedInput = this._inputVal && this._inputVal.trim();
+    if (!this._inputVal) return;
+    const trimmedInput = this._inputVal.trim();
     if (trimmedInput) {
-      const predefinedOpOnlyQuery = [
-        ...SEARCH_OPERATORS_WITH_NEGATIONS_SET,
-      ].some(op => op.endsWith(':') && op === trimmedInput);
+      const predefinedOpOnlyQuery = [...this.searchOperators].some(
+        op => op.endsWith(':') && op === trimmedInput
+      );
       if (predefinedOpOnlyQuery) {
         return;
       }
+      const detail: SearchBarHandleSearchDetail = {
+        inputVal: this._inputVal,
+      };
       this.dispatchEvent(
         new CustomEvent('handle-search', {
-          detail: {inputVal: this._inputVal},
+          detail,
         })
       );
     }
@@ -308,7 +321,7 @@
 
       default:
         return Promise.resolve(
-          [...SEARCH_OPERATORS_WITH_NEGATIONS_SET]
+          [...this.searchOperators]
             .filter(operator => operator.includes(input))
             .map(operator => {
               return {text: operator};
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts
index 2fbdc7e..2ee0058 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts
@@ -47,6 +47,7 @@
         href$="[[_computeHelpDocLink(docBaseUrl)]]"
         target="_blank"
         class="help"
+        tabindex="-1"
       >
         <iron-icon
           icon="gr-icons:help-outline"
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.js
index f69398b..e470618 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.js
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.js
@@ -176,11 +176,9 @@
       });
     });
 
-    test('Autocompltes without is:mergable when disabled', done => {
-      element._getSearchSuggestions('is:mergeab').then(s => {
-        assert.equal(s.length, 0);
-        done();
-      });
+    test('Autocompletes without is:mergable when disabled', async () => {
+      const s = await element._getSearchSuggestions('is:mergeab');
+      assert.isEmpty(s);
     });
   });
 
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
deleted file mode 100644
index 813298c..0000000
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
+++ /dev/null
@@ -1,180 +0,0 @@
-/**
- * @license
- * 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.
- */
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-search-bar/gr-search-bar.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-smart-search_html.js';
-import {GerritNav} from '../gr-navigation/gr-navigation.js';
-import {getUserName} from '../../../utils/display-name-util.js';
-
-const MAX_AUTOCOMPLETE_RESULTS = 10;
-const SELF_EXPRESSION = 'self';
-const ME_EXPRESSION = 'me';
-
-/**
- * @extends PolymerElement
- */
-class GrSmartSearch extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-smart-search'; }
-
-  static get properties() {
-    return {
-      searchQuery: String,
-      _config: Object,
-      _projectSuggestions: {
-        type: Function,
-        value() {
-          return (predicate, expression) =>
-            this._fetchProjects(predicate, expression);
-        },
-      },
-      _groupSuggestions: {
-        type: Function,
-        value() {
-          return (predicate, expression) =>
-            this._fetchGroups(predicate, expression);
-        },
-      },
-      _accountSuggestions: {
-        type: Function,
-        value() {
-          return (predicate, expression) =>
-            this._fetchAccounts(predicate, expression);
-        },
-      },
-      /**
-       * Invisible label for input element. This label is exposed to
-       * screen readers by nested element
-       */
-      label: {
-        type: String,
-        value: '',
-      },
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this.$.restAPI.getConfig().then(cfg => {
-      this._config = cfg;
-    });
-  }
-
-  _handleSearch(e) {
-    const input = e.detail.inputVal;
-    if (input) {
-      GerritNav.navigateToSearchQuery(input);
-    }
-  }
-
-  /**
-   * Fetch from the API the predicted projects.
-   *
-   * @param {string} predicate - The first part of the search term, e.g.
-   *     'project'
-   * @param {string} expression - The second part of the search term, e.g.
-   *     'gerr'
-   * @return {!Promise} This returns a promise that resolves to an array of
-   *     strings.
-   */
-  _fetchProjects(predicate, expression) {
-    return this.$.restAPI.getSuggestedProjects(
-        expression,
-        MAX_AUTOCOMPLETE_RESULTS)
-        .then(projects => {
-          if (!projects) { return []; }
-          const keys = Object.keys(projects);
-          return keys.map(key => { return {text: predicate + ':' + key}; });
-        });
-  }
-
-  /**
-   * Fetch from the API the predicted groups.
-   *
-   * @param {string} predicate - The first part of the search term, e.g.
-   *     'ownerin'
-   * @param {string} expression - The second part of the search term, e.g.
-   *     'polyger'
-   * @return {!Promise} This returns a promise that resolves to an array of
-   *     strings.
-   */
-  _fetchGroups(predicate, expression) {
-    if (expression.length === 0) { return Promise.resolve([]); }
-    return this.$.restAPI.getSuggestedGroups(
-        expression,
-        MAX_AUTOCOMPLETE_RESULTS)
-        .then(groups => {
-          if (!groups) { return []; }
-          const keys = Object.keys(groups);
-          return keys.map(key => { return {text: predicate + ':' + key}; });
-        });
-  }
-
-  /**
-   * Fetch from the API the predicted accounts.
-   *
-   * @param {string} predicate - The first part of the search term, e.g.
-   *     'owner'
-   * @param {string} expression - The second part of the search term, e.g.
-   *     'kasp'
-   * @return {!Promise} This returns a promise that resolves to an array of
-   *     strings.
-   */
-  _fetchAccounts(predicate, expression) {
-    if (expression.length === 0) { return Promise.resolve([]); }
-    return this.$.restAPI.getSuggestedAccounts(
-        expression,
-        MAX_AUTOCOMPLETE_RESULTS)
-        .then(accounts => {
-          if (!accounts) { return []; }
-          return this._mapAccountsHelper(accounts, predicate);
-        })
-        .then(accounts => {
-          // When the expression supplied is a beginning substring of 'self',
-          // add it as an autocomplete option.
-          if (SELF_EXPRESSION.startsWith(expression)) {
-            return accounts.concat(
-                [{text: predicate + ':' + SELF_EXPRESSION}]);
-          } else if (ME_EXPRESSION.startsWith(expression)) {
-            return accounts.concat([{text: predicate + ':' + ME_EXPRESSION}]);
-          } else {
-            return accounts;
-          }
-        });
-  }
-
-  _mapAccountsHelper(accounts, predicate) {
-    return accounts.map(account => {
-      const userName = getUserName(this._serverConfig, account);
-      return {
-        label: account.name || '',
-        text: account.email ?
-          `${predicate}:${account.email}` :
-          `${predicate}:"${userName}"`,
-      };
-    });
-  }
-}
-
-customElements.define(GrSmartSearch.is, GrSmartSearch);
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
new file mode 100644
index 0000000..a818c59
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
@@ -0,0 +1,197 @@
+/**
+ * @license
+ * 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.
+ */
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-search-bar/gr-search-bar';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-smart-search_html';
+import {GerritNav} from '../gr-navigation/gr-navigation';
+import {getUserName} from '../../../utils/display-name-util';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {AccountInfo, ServerInfo} from '../../../types/common';
+import {
+  SearchBarHandleSearchDetail,
+  SuggestionProvider,
+} from '../gr-search-bar/gr-search-bar';
+import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
+
+const MAX_AUTOCOMPLETE_RESULTS = 10;
+const SELF_EXPRESSION = 'self';
+const ME_EXPRESSION = 'me';
+
+export interface GrSmartSearch {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+@customElement('gr-smart-search')
+export class GrSmartSearch extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String})
+  searchQuery?: string;
+
+  @property({type: Object})
+  _config?: ServerInfo;
+
+  @property({type: Object})
+  _projectSuggestions: SuggestionProvider = (predicate, expression) =>
+    this._fetchProjects(predicate, expression);
+
+  @property({type: Object})
+  _groupSuggestions: SuggestionProvider = (predicate, expression) =>
+    this._fetchGroups(predicate, expression);
+
+  @property({type: Object})
+  _accountSuggestions: SuggestionProvider = (predicate, expression) =>
+    this._fetchAccounts(predicate, expression);
+
+  @property({type: String})
+  label = '';
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.$.restAPI.getConfig().then(cfg => {
+      this._config = cfg;
+    });
+  }
+
+  _handleSearch(e: CustomEvent<SearchBarHandleSearchDetail>) {
+    const input = e.detail.inputVal;
+    if (input) {
+      GerritNav.navigateToSearchQuery(input);
+    }
+  }
+
+  /**
+   * Fetch from the API the predicted projects.
+   *
+   * @param predicate - The first part of the search term, e.g.
+   * 'project'
+   * @param expression - The second part of the search term, e.g.
+   * 'gerr'
+   */
+  _fetchProjects(
+    predicate: string,
+    expression: string
+  ): Promise<AutocompleteSuggestion[]> {
+    return this.$.restAPI
+      .getSuggestedProjects(expression, MAX_AUTOCOMPLETE_RESULTS)
+      .then(projects => {
+        if (!projects) {
+          return [];
+        }
+        const keys = Object.keys(projects);
+        return keys.map(key => {
+          return {text: predicate + ':' + key};
+        });
+      });
+  }
+
+  /**
+   * Fetch from the API the predicted groups.
+   *
+   * @param predicate - The first part of the search term, e.g.
+   * 'ownerin'
+   * @param expression - The second part of the search term, e.g.
+   * 'polyger'
+   */
+  _fetchGroups(
+    predicate: string,
+    expression: string
+  ): Promise<AutocompleteSuggestion[]> {
+    if (expression.length === 0) {
+      return Promise.resolve([]);
+    }
+    return this.$.restAPI
+      .getSuggestedGroups(expression, MAX_AUTOCOMPLETE_RESULTS)
+      .then(groups => {
+        if (!groups) {
+          return [];
+        }
+        const keys = Object.keys(groups);
+        return keys.map(key => {
+          return {text: predicate + ':' + key};
+        });
+      });
+  }
+
+  /**
+   * Fetch from the API the predicted accounts.
+   *
+   * @param predicate - The first part of the search term, e.g.
+   * 'owner'
+   * @param expression - The second part of the search term, e.g.
+   * 'kasp'
+   */
+  _fetchAccounts(
+    predicate: string,
+    expression: string
+  ): Promise<AutocompleteSuggestion[]> {
+    if (expression.length === 0) {
+      return Promise.resolve([]);
+    }
+    return this.$.restAPI
+      .getSuggestedAccounts(expression, MAX_AUTOCOMPLETE_RESULTS)
+      .then(accounts => {
+        if (!accounts) {
+          return [];
+        }
+        return this._mapAccountsHelper(accounts, predicate);
+      })
+      .then(accounts => {
+        // When the expression supplied is a beginning substring of 'self',
+        // add it as an autocomplete option.
+        if (SELF_EXPRESSION.startsWith(expression)) {
+          return accounts.concat([{text: predicate + ':' + SELF_EXPRESSION}]);
+        } else if (ME_EXPRESSION.startsWith(expression)) {
+          return accounts.concat([{text: predicate + ':' + ME_EXPRESSION}]);
+        } else {
+          return accounts;
+        }
+      });
+  }
+
+  _mapAccountsHelper(
+    accounts: AccountInfo[],
+    predicate: string
+  ): AutocompleteSuggestion[] {
+    return accounts.map(account => {
+      const userName = getUserName(this._config, account);
+      return {
+        label: account.name || '',
+        text: account.email
+          ? `${predicate}:${account.email}`
+          : `${predicate}:"${userName}"`,
+      };
+    });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-smart-search': GrSmartSearch;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index 675a048..66f3b81 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -29,18 +29,17 @@
 import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 import {
   NumericChangeId,
-  DiffInfo,
-  DiffPreferencesInfo,
   EditPatchSetNum,
   FixId,
   FixSuggestionInfo,
   PatchSetNum,
   RobotId,
 } from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {CommentEventDetail} from '../../shared/gr-comment/gr-comment';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {isRobot} from '../gr-comment-api/gr-comment-api';
+import {isRobot} from '../../../utils/comment-util';
+import {OpenFixPreviewEvent} from '../../../types/events';
 
 export interface GrApplyFixDialog {
   $: {
@@ -110,7 +109,7 @@
    * @return Promise that resolves either when all
    * preview diffs are fetched or no fix suggestions in custom event detail.
    */
-  open(e: CustomEvent<CommentEventDetail>) {
+  open(e: OpenFixPreviewEvent) {
     const detail = e.detail;
     const comment = detail.comment;
     if (!detail.patchNum || !comment || !isRobot(comment)) {
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
index deecdbf..683d887 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -19,7 +19,6 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-comment-api_html';
-import {parseDate} from '../../../utils/date-util';
 import {
   getParentIndex,
   isMergeParent,
@@ -28,121 +27,32 @@
 import {customElement, property} from '@polymer/decorators';
 import {
   CommentBasics,
-  CommentInfo,
   ConfigInfo,
   ParentPatchSetNum,
   PatchRange,
   PatchSetNum,
   PathToRobotCommentsInfoMap,
   RobotCommentInfo,
-  Timestamp,
   UrlEncodedCommentId,
   NumericChangeId,
+  RevisionId,
 } from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {CommentSide, Side} from '../../../constants/constants';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-
-export interface DraftCommentProps {
-  __draft?: boolean;
-  __draftID?: string;
-  __date?: Date;
-}
-
-export type DraftInfo = CommentBasics & DraftCommentProps;
-
-/**
- * Each of the type implements or extends CommentBasics.
- */
-export type Comment = DraftInfo | CommentInfo | RobotCommentInfo;
-
-export interface UIStateCommentProps {
-  // The `side` of the comment is PARENT or REVISION, but this is LEFT or RIGHT.
-  // TODO(TS): Remove the naming confusion of commentSide being of type of Side,
-  // but side being of type CommentSide. :-)
-  __commentSide?: Side;
-  // TODO(TS): Remove this. Seems to be exactly the same as `path`??
-  __path?: string;
-  collapsed?: boolean;
-  // TODO(TS): Consider allowing this only for drafts.
-  __editing?: boolean;
-  __otherEditing?: boolean;
-}
-
-export type UIDraft = DraftInfo & UIStateCommentProps;
-
-export type UIHuman = CommentInfo & UIStateCommentProps;
-
-export type UIRobot = RobotCommentInfo & UIStateCommentProps;
-
-export type UIComment = UIHuman | UIRobot | UIDraft;
-
-export type CommentMap = {[path: string]: boolean};
-
-export function isRobot<T extends CommentInfo>(
-  x: T | DraftInfo | RobotCommentInfo | undefined
-): x is RobotCommentInfo {
-  return !!x && !!(x as RobotCommentInfo).robot_id;
-}
-
-export function isDraft<T extends CommentInfo>(
-  x: T | UIDraft | undefined
-): x is UIDraft {
-  return !!x && !!(x as UIDraft).__draft;
-}
-
-export interface PatchSetFile {
-  path: string;
-  basePath?: string;
-  patchNum?: PatchSetNum;
-}
-
-export interface PatchNumOnly {
-  patchNum: PatchSetNum;
-}
-
-export function isPatchSetFile(
-  x: PatchSetFile | PatchNumOnly
-): x is PatchSetFile {
-  return !!(x as PatchSetFile).path;
-}
-
-interface SortableComment {
-  __draft?: boolean;
-  __date?: Date;
-  updated?: Timestamp;
-  id?: UrlEncodedCommentId;
-}
-
-export function sortComments<T extends SortableComment>(comments: T[]): T[] {
-  return comments.slice(0).sort((c1, c2) => {
-    const d1 = !!c1.__draft;
-    const d2 = !!c2.__draft;
-    if (d1 !== d2) return d1 ? 1 : -1;
-
-    const date1 = (c1.updated && parseDate(c1.updated)) || c1.__date;
-    const date2 = (c2.updated && parseDate(c2.updated)) || c2.__date;
-    const dateDiff = date1!.valueOf() - date2!.valueOf();
-    if (dateDiff !== 0) return dateDiff;
-
-    const id1 = c1.id ?? '';
-    const id2 = c2.id ?? '';
-    return id1.localeCompare(id2);
-  });
-}
-
-export interface CommentThread {
-  comments: UIComment[];
-  patchNum?: PatchSetNum;
-  path: string;
-  // TODO(TS): It would be nice to use LineNumber here, but the comment thread
-  // element actually relies on line to be undefined for file comments. Be
-  // aware of element attribute getters and setters, if you try to refactor
-  // this. :-) Still worthwhile to do ...
-  line?: number;
-  rootId: UrlEncodedCommentId;
-  commentSide?: CommentSide;
-}
+import {
+  Comment,
+  CommentMap,
+  CommentThread,
+  DraftInfo,
+  isUnresolved,
+  UIComment,
+  UIDraft,
+  UIHuman,
+  UIRobot,
+  createCommentThreads,
+} from '../../../utils/comment-util';
+import {PatchSetFile, PatchNumOnly, isPatchSetFile} from '../../../types/types';
 
 export type CommentIdToCommentThreadMap = {
   [urlEncodedCommentId: string]: CommentThread;
@@ -220,15 +130,20 @@
     return this._robotComments;
   }
 
-  findCommentById(commentId: UrlEncodedCommentId): Comment | undefined {
-    const findComment = (comments: {[path: string]: CommentBasics[]}) => {
+  findCommentById(commentId?: UrlEncodedCommentId): UIComment | undefined {
+    if (!commentId) return undefined;
+    const findComment = (comments: {[path: string]: UIComment[]}) => {
       let comment;
       for (const path of Object.keys(comments)) {
         comment = comment || comments[path].find(c => c.id === commentId);
       }
       return comment;
     };
-    return findComment(this._comments) || findComment(this._robotComments);
+    return (
+      findComment(this._comments) ||
+      findComment(this._robotComments) ||
+      findComment(this._drafts)
+    );
   }
 
   /**
@@ -398,6 +313,30 @@
     return allDrafts;
   }
 
+  _addCommentSide(comments: TwoSidesComments) {
+    const allComments = [];
+    for (const side of [Side.LEFT, Side.RIGHT]) {
+      // This is needed by the threading.
+      for (const comment of comments[side]) {
+        comment.__commentSide = side;
+      }
+      allComments.push(...comments[side]);
+    }
+    return allComments;
+  }
+
+  getThreadsBySideForPath(
+    path: string,
+    patchRange: PatchRange,
+    projectConfig?: ConfigInfo
+  ): CommentThread[] {
+    return createCommentThreads(
+      this._addCommentSide(
+        this.getCommentsBySideForPath(path, patchRange, projectConfig)
+      )
+    );
+  }
+
   /**
    * Get the comments (with drafts and robot comments) for a path and
    * patch-range. Returns an object with left and right properties mapping to
@@ -456,6 +395,18 @@
     };
   }
 
+  getThreadsBySideForFile(
+    file: PatchSetFile,
+    patchRange: PatchRange,
+    projectConfig?: ConfigInfo
+  ): CommentThread[] {
+    return createCommentThreads(
+      this._addCommentSide(
+        this.getCommentsBySideForFile(file, patchRange, projectConfig)
+      )
+    );
+  }
+
   /**
    * Get the comments (with drafts and robot comments) for a file and
    * patch-range. Returns an object with left and right properties mapping to
@@ -521,14 +472,19 @@
   }
 
   /**
-   * Computes a string counting the number of commens in a given file.
+   * Computes the number of comment threads in a given file or patch.
    */
-  computeCommentCount(file: PatchSetFile | PatchNumOnly) {
+  computeCommentThreadCount(file: PatchSetFile | PatchNumOnly) {
+    let comments: Comment[] = [];
     if (isPatchSetFile(file)) {
-      return this.getAllCommentsForFile(file).length;
+      comments = this.getAllCommentsForFile(file);
+    } else {
+      comments = this._commentObjToArray(
+        this.getAllPublishedComments(file.patchNum)
+      );
     }
-    const allComments = this.getAllPublishedComments(file.patchNum);
-    return this._commentObjToArray(allComments).length;
+
+    return createCommentThreads(comments).length;
   }
 
   /**
@@ -560,63 +516,14 @@
     }
 
     comments = comments.concat(drafts);
-
-    const threads = this.getCommentThreads(sortComments(comments));
-
-    const unresolvedThreads = threads.filter(
-      thread =>
-        thread.comments.length &&
-        thread.comments[thread.comments.length - 1].unresolved
-    );
-
+    const threads = createCommentThreads(comments);
+    const unresolvedThreads = threads.filter(isUnresolved);
     return unresolvedThreads.length;
   }
 
   getAllThreadsForChange() {
     const comments = this._commentObjToArrayWithFile(this.getAllComments(true));
-    const sortedComments = sortComments(comments);
-    return this.getCommentThreads(sortedComments);
-  }
-
-  /**
-   * Computes all of the comments in thread format.
-   *
-   * @param comments sorted by updated timestamp.
-   */
-  getCommentThreads(comments: UIComment[]) {
-    const threads: CommentThread[] = [];
-    const idThreadMap: CommentIdToCommentThreadMap = {};
-    for (const comment of comments) {
-      if (!comment.id) continue;
-      // If the comment is in reply to another comment, find that comment's
-      // thread and append to it.
-      if (comment.in_reply_to) {
-        const thread = idThreadMap[comment.in_reply_to];
-        if (thread) {
-          thread.comments.push(comment);
-          idThreadMap[comment.id] = thread;
-          continue;
-        }
-      }
-
-      // Otherwise, this comment starts its own thread.
-      if (!comment.__path && !comment.path) {
-        throw new Error('Comment missing required "path".');
-      }
-      const newThread: CommentThread = {
-        comments: [comment],
-        patchNum: comment.patch_set,
-        path: comment.__path || comment.path!,
-        line: comment.line,
-        rootId: comment.id,
-      };
-      if (comment.side) {
-        newThread.commentSide = comment.side;
-      }
-      threads.push(newThread);
-      idThreadMap[comment.id] = newThread;
-    }
-    return threads;
+    return createCommentThreads(comments);
   }
 
   /**
@@ -676,14 +583,14 @@
 export const _testOnly_findCommentById =
   ChangeComments.prototype.findCommentById;
 
-interface GrCommentApi {
+export interface GrCommentApi {
   $: {
     restAPI: RestApiService & Element;
   };
 }
 
 @customElement('gr-comment-api')
-class GrCommentApi extends GestureEventListeners(
+export class GrCommentApi extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
@@ -704,6 +611,19 @@
     );
   }
 
+  getPortedComments(changeNum: NumericChangeId, revision?: RevisionId) {
+    if (!revision) revision = 'current';
+    return Promise.all([
+      this.$.restAPI.getPortedComments(changeNum, revision),
+      this.$.restAPI.getPortedDrafts(changeNum, revision),
+    ]).then(result => {
+      return {
+        portedComments: result[0],
+        portedDrafts: result[1],
+      };
+    });
+  }
+
   /**
    * Load all comments (with drafts and robot comments) for the given change
    * number. The returned promise resolves when the comments have loaded, but
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
index 2cf9bc1..d21af73 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-comment-api.js';
 import {ChangeComments} from './gr-comment-api.js';
+import {CommentSide} from '../../../constants/constants.js';
 
 const basicFixture = fixtureFromElement('gr-comment-api');
 
@@ -220,14 +221,14 @@
         const drafts = {
           'file/one': [
             {
-              id: '11',
+              id: '12',
               patch_set: 2,
               side: PARENT,
               line: 1,
               updated: makeTime(3),
             },
             {
-              id: '12',
+              id: '13',
               in_reply_to: '04',
               patch_set: 2,
               line: 1,
@@ -289,21 +290,30 @@
               id: '07',
               patch_set: 2,
               side: PARENT,
-              unresolved: true,
+              unresolved: false,
               line: 1,
               updated: makeTime(1),
             },
-            {id: '08', patch_set: 3, line: 1, updated: makeTime(1)},
+            {
+              id: '08',
+              patch_set: 2,
+              side: PARENT,
+              unresolved: true,
+              in_reply_to: '07',
+              line: 1,
+              updated: makeTime(1),
+            },
+            {id: '09', patch_set: 3, line: 1, updated: makeTime(1)},
           ],
           'file/four': [
             {
-              id: '09',
+              id: '10',
               patch_set: 5,
               side: PARENT,
               line: 1,
               updated: makeTime(1),
             },
-            {id: '10', patch_set: 5, line: 1, updated: makeTime(1)},
+            {id: '11', patch_set: 5, line: 1, updated: makeTime(1)},
           ],
         };
         element._changeComments =
@@ -439,19 +449,19 @@
             element._changeComments.computeUnresolvedNum(1, 'path'), 0);
       });
 
-      test('computeCommentCount', () => {
+      test('computeCommentThreadCount', () => {
         assert.equal(element._changeComments
-            .computeCommentCount({
+            .computeCommentThreadCount({
               patchNum: 2,
               path: 'file/one',
-            }), 4);
+            }), 3);
         assert.equal(element._changeComments
-            .computeCommentCount({
+            .computeCommentThreadCount({
               patchNum: 1,
               path: 'file/one',
             }), 0);
         assert.equal(element._changeComments
-            .computeCommentCount({
+            .computeCommentThreadCount({
               patchNum: 2,
               path: 'file/three',
             }), 1);
@@ -529,11 +539,18 @@
                 __path: 'file/one',
               },
             ],
+            diffSide: undefined,
             commentSide: 'PARENT',
             patchNum: 2,
             path: 'file/one',
             line: 1,
             rootId: '01',
+            range: {
+              start_line: 1,
+              start_character: 2,
+              end_line: 2,
+              end_character: 2,
+            },
           }, {
             comments: [
               {
@@ -546,10 +563,12 @@
                 updated: '2013-02-26 15:01:43.986000000',
               },
             ],
-            commentSide: 'PARENT',
             patchNum: 2,
             path: 'file/one',
             line: 2,
+            range: undefined,
+            diffSide: undefined,
+            commentSide: CommentSide.PARENT,
             rootId: '03',
           }, {
             comments: [
@@ -572,7 +591,7 @@
                 updated: '2013-02-26 15:03:43.986000000',
               },
               {
-                id: '12',
+                id: '13',
                 in_reply_to: '04',
                 patch_set: 2,
                 line: 1,
@@ -586,6 +605,9 @@
             path: 'file/one',
             line: 1,
             rootId: '04',
+            range: undefined,
+            diffSide: undefined,
+            commentSide: CommentSide.REVISION,
           }, {
             comments: [
               {
@@ -601,6 +623,9 @@
             path: 'file/two',
             line: 2,
             rootId: '05',
+            range: undefined,
+            diffSide: undefined,
+            commentSide: CommentSide.REVISION,
           }, {
             comments: [
               {
@@ -616,12 +641,26 @@
             path: 'file/two',
             line: 2,
             rootId: '06',
+            range: undefined,
+            diffSide: undefined,
+            commentSide: CommentSide.REVISION,
           }, {
             comments: [
               {
                 id: '07',
                 patch_set: 2,
                 side: 'PARENT',
+                unresolved: false,
+                line: 1,
+                path: 'file/three',
+                __path: 'file/three',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+              {
+                id: '08',
+                in_reply_to: '07',
+                patch_set: 2,
+                side: 'PARENT',
                 unresolved: true,
                 line: 1,
                 path: 'file/three',
@@ -634,10 +673,12 @@
             path: 'file/three',
             line: 1,
             rootId: '07',
+            range: undefined,
+            diffSide: undefined,
           }, {
             comments: [
               {
-                id: '08',
+                id: '09',
                 patch_set: 3,
                 line: 1,
                 path: 'file/three',
@@ -648,11 +689,14 @@
             patchNum: 3,
             path: 'file/three',
             line: 1,
-            rootId: '08',
+            rootId: '09',
+            range: undefined,
+            diffSide: undefined,
+            commentSide: CommentSide.REVISION,
           }, {
             comments: [
               {
-                id: '09',
+                id: '10',
                 patch_set: 5,
                 side: 'PARENT',
                 line: 1,
@@ -665,11 +709,13 @@
             patchNum: 5,
             path: 'file/four',
             line: 1,
-            rootId: '09',
+            rootId: '10',
+            range: undefined,
+            diffSide: undefined,
           }, {
             comments: [
               {
-                id: '10',
+                id: '11',
                 patch_set: 5,
                 line: 1,
                 path: 'file/four',
@@ -677,10 +723,13 @@
                 updated: '2013-02-26 15:01:43.986000000',
               },
             ],
-            rootId: '10',
+            rootId: '11',
             patchNum: 5,
             path: 'file/four',
             line: 1,
+            range: undefined,
+            diffSide: undefined,
+            commentSide: CommentSide.REVISION,
           }, {
             comments: [
               {
@@ -697,10 +746,13 @@
             patchNum: 3,
             path: 'file/two',
             line: 1,
+            range: undefined,
+            diffSide: undefined,
+            commentSide: CommentSide.REVISION,
           }, {
             comments: [
               {
-                id: '11',
+                id: '12',
                 patch_set: 2,
                 side: 'PARENT',
                 line: 1,
@@ -710,11 +762,13 @@
                 updated: '2013-02-26 15:03:43.986000000',
               },
             ],
-            rootId: '11',
+            rootId: '12',
             commentSide: 'PARENT',
             patchNum: 2,
             path: 'file/one',
             line: 1,
+            range: undefined,
+            diffSide: undefined,
           },
         ];
         const threads = element._changeComments.getAllThreadsForChange();
@@ -745,7 +799,7 @@
             __path: 'file/one',
             path: 'file/one',
             __draft: true,
-            id: '12',
+            id: '13',
             in_reply_to: '04',
             patch_set: 2,
             line: 1,
@@ -756,7 +810,7 @@
             expectedComments);
 
         expectedComments = [{
-          id: '11',
+          id: '12',
           patch_set: 2,
           side: 'PARENT',
           line: 1,
@@ -766,7 +820,7 @@
           updated: '2013-02-26 15:03:43.986000000',
         }];
 
-        assert.deepEqual(element._changeComments.getCommentsForThread('11'),
+        assert.deepEqual(element._changeComments.getCommentsForThread('12'),
             expectedComments);
 
         assert.deepEqual(element._changeComments.getCommentsForThread('1000'),
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.ts b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.ts
index 2167641..6f9705f 100644
--- a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.ts
+++ b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.ts
@@ -84,7 +84,7 @@
     let elementLineNumber;
     const dataValue = lineNumberEl.getAttribute('data-value');
     if (dataValue) {
-      elementLineNumber = parseInt(dataValue, 10);
+      elementLineNumber = Number(dataValue);
     }
     if (!elementLineNumber || elementLineNumber < 1) return;
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.ts
index 7a26e77..763a524 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.ts
@@ -16,7 +16,7 @@
  */
 
 import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
 
 export class GrDiffBuilderBinary extends GrDiffBuilderUnified {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
index 5256564..2f85a0b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -31,12 +31,8 @@
 import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
 import {CancelablePromise, util} from '../../../scripts/util';
 import {customElement, property, observe} from '@polymer/decorators';
-import {
-  BlameInfo,
-  DiffInfo,
-  DiffPreferencesInfo,
-  ImageInfo,
-} from '../../../types/common';
+import {BlameInfo, ImageInfo} from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {CoverageRange, DiffLayer} from '../../../types/types';
 import {
   GrDiffProcessor,
@@ -52,6 +48,7 @@
 import {GrDiffGroup} from '../gr-diff/gr-diff-group';
 import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
 import {getLineNumber} from '../gr-diff/gr-diff-utils';
+import {fireAlert} from '../../../utils/event-util';
 
 const DiffViewMode = {
   SIDE_BY_SIDE: 'SIDE_BY_SIDE',
@@ -120,9 +117,6 @@
   @property({type: String})
   path?: string;
 
-  @property({type: String})
-  projectName?: string;
-
   @property({type: Object})
   _builder?: GrDiffBuilder;
 
@@ -153,6 +147,9 @@
   @property({type: Array})
   coverageRanges: CoverageRange[] = [];
 
+  @property({type: Boolean})
+  useNewContextControls = false;
+
   @property({
     type: Array,
     computed: '_computeLeftCoverageRanges(coverageRanges)',
@@ -363,15 +360,7 @@
     const message =
       `The value of the '${pref}' user preference is ` +
       'invalid. Fix in diff preferences';
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {
-          message,
-        },
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fireAlert(this, message);
     throw Error(`Invalid preference value: ${pref}`);
   }
 
@@ -408,14 +397,16 @@
         diff,
         localPrefs,
         this.diffElement,
-        this._layers
+        this._layers,
+        this.useNewContextControls
       );
     } else if (this.viewMode === DiffViewMode.UNIFIED) {
       builder = new GrDiffBuilderUnified(
         diff,
         localPrefs,
         this.diffElement,
-        this._layers
+        this._layers,
+        this.useNewContextControls
       );
     }
     if (!builder) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
index 7cbbdb9..b10b251 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
@@ -96,47 +96,113 @@
       return [new GrDiffGroup(GrDiffGroupType.BOTH, lines)];
     }
 
-    test('no +10 buttons for 10 or less lines', () => {
-      const contextGroups = createContextGroups({count: 10});
-      const td = builder._createContextControl({}, contextGroups);
-      const buttons = td.querySelectorAll('gr-button.showContext');
+    function createContextSectionForGroups(options) {
+      const section = document.createElement('div');
+      builder._createContextControls(
+          section, createContextGroups(options), DiffViewMode.UNIFIED);
+      return section;
+    }
 
-      assert.equal(buttons.length, 1);
-      assert.equal(buttons[0].textContent, 'Show 10 common lines');
+    suite('old style', () => {
+      setup(() => {
+        builder = new GrDiffBuilder(
+            {content: []}, prefs, null, [], false /* useNewContextControls */);
+      });
+
+      test('no +10 buttons for 10 or less lines', () => {
+        const section = createContextSectionForGroups({count: 10});
+        const buttons = section.querySelectorAll('gr-button.showContext');
+
+        assert.equal(buttons.length, 1);
+        assert.equal(buttons[0].textContent, 'Show 10 common lines');
+      });
+
+      test('context control at the top', () => {
+        builder._numLinesLeft = 50;
+        const section = createContextSectionForGroups({offset: 0, count: 20});
+        const buttons = section.querySelectorAll('gr-button.showContext');
+
+        assert.equal(buttons.length, 2);
+        assert.equal(buttons[0].textContent, 'Show 20 common lines');
+        assert.equal(buttons[1].textContent, '+10 below');
+      });
+
+      test('context control in the middle', () => {
+        builder._numLinesLeft = 50;
+        const section = createContextSectionForGroups({offset: 10, count: 20});
+        const buttons = section.querySelectorAll('gr-button.showContext');
+
+        assert.equal(buttons.length, 3);
+        assert.equal(buttons[0].textContent, '+10 above');
+        assert.equal(buttons[1].textContent, 'Show 20 common lines');
+        assert.equal(buttons[2].textContent, '+10 below');
+      });
+
+      test('context control at the bottom', () => {
+        builder._numLinesLeft = 50;
+        const section = createContextSectionForGroups({offset: 30, count: 20});
+        const buttons = section.querySelectorAll('gr-button.showContext');
+
+        assert.equal(buttons.length, 2);
+        assert.equal(buttons[0].textContent, '+10 above');
+        assert.equal(buttons[1].textContent, 'Show 20 common lines');
+      });
     });
 
-    test('context control at the top', () => {
-      const contextGroups = createContextGroups({offset: 0, count: 20});
-      builder._numLinesLeft = 50;
-      const td = builder._createContextControl({}, contextGroups);
-      const buttons = td.querySelectorAll('gr-button.showContext');
+    suite('new style', () => {
+      setup(() => {
+        builder = new GrDiffBuilder(
+            {content: []}, prefs, null, [], true /* useNewContextControls */);
+      });
 
-      assert.equal(buttons.length, 2);
-      assert.equal(buttons[0].textContent, 'Show 20 common lines');
-      assert.equal(buttons[1].textContent, '+10 below');
-    });
+      test('no +10 buttons for 10 or less lines', () => {
+        const section = createContextSectionForGroups({count: 10});
+        const buttons = section.querySelectorAll('gr-button.showContext');
 
-    test('context control in the middle', () => {
-      const contextGroups = createContextGroups({offset: 10, count: 20});
-      builder._numLinesLeft = 50;
-      const td = builder._createContextControl({}, contextGroups);
-      const buttons = td.querySelectorAll('gr-button.showContext');
+        assert.equal(buttons.length, 1);
+        assert.equal(buttons[0].textContent, '+10 common lines');
+      });
 
-      assert.equal(buttons.length, 3);
-      assert.equal(buttons[0].textContent, '+10 above');
-      assert.equal(buttons[1].textContent, 'Show 20 common lines');
-      assert.equal(buttons[2].textContent, '+10 below');
-    });
+      test('context control at the top', () => {
+        builder._numLinesLeft = 50;
+        const section = createContextSectionForGroups({offset: 0, count: 20});
+        const buttons = section.querySelectorAll('gr-button.showContext');
 
-    test('context control at the top', () => {
-      const contextGroups = createContextGroups({offset: 30, count: 20});
-      builder._numLinesLeft = 50;
-      const td = builder._createContextControl({}, contextGroups);
-      const buttons = td.querySelectorAll('gr-button.showContext');
+        assert.equal(buttons.length, 2);
+        assert.equal(buttons[0].textContent, '+20 common lines');
+        assert.equal(buttons[1].textContent, '+10');
 
-      assert.equal(buttons.length, 2);
-      assert.equal(buttons[0].textContent, '+10 above');
-      assert.equal(buttons[1].textContent, 'Show 20 common lines');
+        assert.include([...buttons[0].classList.values()], 'belowButton');
+        assert.include([...buttons[1].classList.values()], 'belowButton');
+      });
+
+      test('context control in the middle', () => {
+        builder._numLinesLeft = 50;
+        const section = createContextSectionForGroups({offset: 10, count: 20});
+        const buttons = section.querySelectorAll('gr-button.showContext');
+
+        assert.equal(buttons.length, 3);
+        assert.equal(buttons[0].textContent, '+20 common lines');
+        assert.equal(buttons[1].textContent, '+10');
+        assert.equal(buttons[2].textContent, '+10');
+
+        assert.include([...buttons[0].classList.values()], 'centeredButton');
+        assert.include([...buttons[1].classList.values()], 'aboveButton');
+        assert.include([...buttons[2].classList.values()], 'belowButton');
+      });
+
+      test('context control at the bottom', () => {
+        builder._numLinesLeft = 50;
+        const section = createContextSectionForGroups({offset: 30, count: 20});
+        const buttons = section.querySelectorAll('gr-button.showContext');
+
+        assert.equal(buttons.length, 2);
+        assert.equal(buttons[0].textContent, '+20 common lines');
+        assert.equal(buttons[1].textContent, '+10');
+
+        assert.include([...buttons[0].classList.values()], 'aboveButton');
+        assert.include([...buttons[1].classList.values()], 'aboveButton');
+      });
     });
   });
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts
index 15264ea..5b3f225 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts
@@ -16,7 +16,8 @@
  */
 
 import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
-import {DiffInfo, DiffPreferencesInfo, ImageInfo} from '../../../types/common';
+import {ImageInfo} from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {GrEndpointParam} from '../../plugins/gr-endpoint-param/gr-endpoint-param';
 
 // MIME types for images we allow showing. Do not include SVG, it can contain
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
index 0f7eb43..1ae302a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
@@ -17,9 +17,9 @@
 
 import {GrDiffBuilder} from './gr-diff-builder';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffLine, LineNumber} from '../gr-diff/gr-diff-line';
-import {Side} from '../../../constants/constants';
+import {DiffViewMode, Side} from '../../../constants/constants';
 
 export class GrDiffBuilderSideBySide extends GrDiffBuilder {
   constructor(
@@ -27,9 +27,10 @@
     prefs: DiffPreferencesInfo,
     outputEl: HTMLElement,
     // TODO(TS): Replace any by a layer interface.
-    readonly layers: any[] = []
+    readonly layers: any[] = [],
+    useNewContextControls = false
   ) {
-    super(diff, prefs, outputEl, layers);
+    super(diff, prefs, outputEl, layers, useNewContextControls);
   }
 
   _getMoveControlsConfig() {
@@ -49,7 +50,7 @@
     if (group.dueToRebase) {
       sectionEl.classList.add('dueToRebase');
     }
-    if (group.dueToMove) {
+    if (group.moveDetails) {
       sectionEl.classList.add('dueToMove');
       sectionEl.appendChild(this._buildMoveControls(group));
     }
@@ -57,8 +58,10 @@
       sectionEl.classList.add('ignoredWhitespaceOnly');
     }
     if (group.type === GrDiffGroupType.CONTEXT_CONTROL) {
-      sectionEl.appendChild(
-        this._createContextRow(sectionEl, group.contextGroups)
+      this._createContextControls(
+        sectionEl,
+        group.contextGroups,
+        DiffViewMode.SIDE_BY_SIDE
       );
       return sectionEl;
     }
@@ -102,7 +105,6 @@
     row.classList.add('diff-row', 'side-by-side');
     row.setAttribute('left-type', leftLine.type);
     row.setAttribute('right-type', rightLine.type);
-    row.tabIndex = -1;
 
     row.appendChild(this._createBlameCell(leftLine.beforeNumber));
 
@@ -122,21 +124,6 @@
     row.appendChild(this._createTextEl(lineNumberEl, line, side));
   }
 
-  _createContextRow(section: HTMLElement, contextGroups: GrDiffGroup[]) {
-    const row = this._createElement('tr');
-    row.classList.add('diff-row', 'side-by-side');
-    row.setAttribute('left-type', GrDiffGroupType.CONTEXT_CONTROL);
-    row.setAttribute('right-type', GrDiffGroupType.CONTEXT_CONTROL);
-    row.tabIndex = -1;
-
-    row.appendChild(this._createBlameCell(0));
-    row.appendChild(this._createElement('td', 'contextLineNum'));
-    row.appendChild(this._createContextControl(section, contextGroups));
-    row.appendChild(this._createElement('td', 'contextLineNum'));
-    row.appendChild(this._createContextControl(section, contextGroups));
-    return row;
-  }
-
   _getNextContentOnSide(content: HTMLElement, side: Side): HTMLElement | null {
     let tr: HTMLElement = content.parentElement!.parentElement!;
     while ((tr = tr.nextSibling as HTMLElement)) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
index 7c070e5..04ac472 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
@@ -17,8 +17,8 @@
 import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
 import {GrDiffBuilder} from './gr-diff-builder';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/common';
-import {Side} from '../../../constants/constants';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {DiffViewMode, Side} from '../../../constants/constants';
 
 export class GrDiffBuilderUnified extends GrDiffBuilder {
   constructor(
@@ -26,9 +26,10 @@
     prefs: DiffPreferencesInfo,
     outputEl: HTMLElement,
     // TODO(TS): Replace any by a layer interface.
-    readonly layers: any[] = []
+    readonly layers: any[] = [],
+    useNewContextControls = false
   ) {
-    super(diff, prefs, outputEl, layers);
+    super(diff, prefs, outputEl, layers, useNewContextControls);
   }
 
   _getMoveControlsConfig() {
@@ -48,7 +49,7 @@
     if (group.dueToRebase) {
       sectionEl.classList.add('dueToRebase');
     }
-    if (group.dueToMove) {
+    if (group.moveDetails) {
       sectionEl.classList.add('dueToMove');
       sectionEl.appendChild(this._buildMoveControls(group));
     }
@@ -56,8 +57,10 @@
       sectionEl.classList.add('ignoredWhitespaceOnly');
     }
     if (group.type === GrDiffGroupType.CONTEXT_CONTROL) {
-      sectionEl.appendChild(
-        this._createContextRow(sectionEl, group.contextGroups)
+      this._createContextControls(
+        sectionEl,
+        group.contextGroups,
+        DiffViewMode.UNIFIED
       );
       return sectionEl;
     }
@@ -101,7 +104,6 @@
   _createRow(line: GrDiffLine) {
     const row = this._createElement('tr', line.type);
     row.classList.add('diff-row', 'unified');
-    row.tabIndex = -1;
     row.appendChild(this._createBlameCell(line.beforeNumber));
     let lineNumberEl = this._createLineEl(
       line,
@@ -121,17 +123,6 @@
     return row;
   }
 
-  _createContextRow(section: HTMLElement, contextGroups: GrDiffGroup[]) {
-    const row = this._createElement('tr', GrDiffGroupType.CONTEXT_CONTROL);
-    row.classList.add('diff-row', 'unified');
-    row.tabIndex = -1;
-    row.appendChild(this._createBlameCell(0));
-    row.appendChild(this._createElement('td', 'contextLineNum'));
-    row.appendChild(this._createElement('td', 'contextLineNum'));
-    row.appendChild(this._createContextControl(section, contextGroups));
-    return row;
-  }
-
   _getNextContentOnSide(content: HTMLElement, side: Side): HTMLElement | null {
     let tr: HTMLElement = content.parentElement!.parentElement!;
     while ((tr = tr.nextSibling as HTMLElement)) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js
index 07c6410..24d3635 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js
@@ -105,7 +105,7 @@
       lines[0].text = 'def hello_world():';
       lines[1].text = '  print "Hello World"';
       const group = new GrDiffGroup(GrDiffGroupType.DELTA, lines);
-      group.dueToMove = true;
+      group.moveDetails = {changed: false};
 
       const sectionEl = diffBuilder.buildSectionElement(group);
 
@@ -128,7 +128,7 @@
       lines[0].text = 'def hello_world():';
       lines[1].text = '  print "Hello World"';
       const group = new GrDiffGroup(GrDiffGroupType.DELTA, lines);
-      group.dueToMove = true;
+      group.moveDetails = {changed: false};
 
       const sectionEl = diffBuilder.buildSectionElement(group);
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
index d1f52ae..75f95f3 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
@@ -18,12 +18,14 @@
 import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
 import {
   GrDiffGroup,
+  GrDiffGroupRange,
   GrDiffGroupType,
   hideInContextControl,
   rangeBySide,
 } from '../gr-diff/gr-diff-group';
-import {BlameInfo, DiffInfo, DiffPreferencesInfo} from '../../../types/common';
-import {Side} from '../../../constants/constants';
+import {BlameInfo} from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {DiffViewMode, Side} from '../../../constants/constants';
 import {DiffLayer} from '../../../types/types';
 
 /**
@@ -64,6 +66,10 @@
   };
 }
 
+export interface ContentLoadNeededEventDetail {
+  lineRange: GrDiffGroupRange;
+}
+
 export abstract class GrDiffBuilder {
   private readonly _diff: DiffInfo;
 
@@ -87,13 +93,14 @@
     diff: DiffInfo,
     prefs: DiffPreferencesInfo,
     outputEl: HTMLElement,
-    readonly layers: DiffLayer[] = []
+    readonly layers: DiffLayer[] = [],
+    protected readonly useNewContextControls: boolean = false
   ) {
     this._diff = diff;
     this._numLinesLeft = this._diff.content
       ? this._diff.content.reduce((sum, chunk) => {
           const left = chunk.a || chunk.ab;
-          return sum + (left ? left.length : 0);
+          return sum + (left?.length || chunk.skip || 0);
         }, 0)
       : 0;
     this._prefs = prefs;
@@ -299,20 +306,116 @@
     );
   }
 
-  _createContextControl(
+  _createContextControls(
     section: HTMLElement,
-    contextGroups: GrDiffGroup[]
-  ): HTMLElement {
+    contextGroups: GrDiffGroup[],
+    viewMode: DiffViewMode
+  ) {
     const leftStart = contextGroups[0].lineRange.left.start!;
     const leftEnd = contextGroups[contextGroups.length - 1].lineRange.left.end!;
     const numLines = leftEnd - leftStart + 1;
 
     if (numLines === 0) console.error('context group without lines');
 
-    const td = this._createElement('td');
-    const showPartialLinks = numLines > PARTIAL_CONTEXT_AMOUNT;
+    const firstGroupIsSkipped = !!contextGroups[0].skip;
+    const lastGroupIsSkipped = !!contextGroups[contextGroups.length - 1].skip;
 
-    if (showPartialLinks && leftStart > 1) {
+    const showPartialLinks = numLines > PARTIAL_CONTEXT_AMOUNT;
+    const showAbove = leftStart > 1 && !firstGroupIsSkipped;
+    const showBelow = leftEnd < this._numLinesLeft && !lastGroupIsSkipped;
+
+    if (this.useNewContextControls) {
+      section.classList.add('newStyle');
+      if (showAbove) {
+        const paddingRow = this._createContextControlPaddingRow(viewMode);
+        paddingRow.classList.add('above');
+        section.appendChild(paddingRow);
+      }
+      section.appendChild(
+        this._createNewContextControlRow(
+          section,
+          contextGroups,
+          showAbove,
+          showBelow,
+          numLines
+        )
+      );
+      if (showBelow) {
+        const paddingRow = this._createContextControlPaddingRow(viewMode);
+        paddingRow.classList.add('below');
+        section.appendChild(paddingRow);
+      }
+    } else {
+      section.appendChild(
+        this._createOldContextControlRow(
+          section,
+          contextGroups,
+          viewMode,
+          showAbove && showPartialLinks,
+          showBelow && showPartialLinks,
+          numLines
+        )
+      );
+    }
+  }
+
+  /**
+   * Creates old-style context controls: a single row of "+X above" and
+   * "+X below" buttons.
+   */
+  _createOldContextControlRow(
+    section: HTMLElement,
+    contextGroups: GrDiffGroup[],
+    viewMode: DiffViewMode,
+    showAbove: boolean,
+    showBelow: boolean,
+    numLines: number
+  ) {
+    const row = this._createElement('tr', GrDiffGroupType.CONTEXT_CONTROL);
+
+    row.classList.add('diff-row');
+    row.classList.add(
+      viewMode === DiffViewMode.SIDE_BY_SIDE ? 'side-by-side' : 'unified'
+    );
+
+    row.tabIndex = -1;
+    row.appendChild(this._createBlameCell(0));
+    row.appendChild(this._createElement('td', 'contextLineNum'));
+    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
+      row.appendChild(
+        this._createOldContextControlButtons(
+          section,
+          contextGroups,
+          showAbove,
+          showBelow,
+          numLines
+        )
+      );
+    }
+    row.appendChild(this._createElement('td', 'contextLineNum'));
+    row.appendChild(
+      this._createOldContextControlButtons(
+        section,
+        contextGroups,
+        showAbove,
+        showBelow,
+        numLines
+      )
+    );
+
+    return row;
+  }
+
+  _createOldContextControlButtons(
+    section: HTMLElement,
+    contextGroups: GrDiffGroup[],
+    showAbove: boolean,
+    showBelow: boolean,
+    numLines: number
+  ): HTMLElement {
+    const td = this._createElement('td');
+
+    if (showAbove) {
       td.appendChild(
         this._createContextButton(
           ContextButtonType.ABOVE,
@@ -332,7 +435,7 @@
       )
     );
 
-    if (showPartialLinks && leftEnd < this._numLinesLeft) {
+    if (showBelow) {
       td.appendChild(
         this._createContextButton(
           ContextButtonType.BELOW,
@@ -346,6 +449,100 @@
     return td;
   }
 
+  /**
+   * Creates new-style context controls: buttons extend from the gap created by
+   * this method up or down into the area of code that they affect.
+   */
+  _createNewContextControlRow(
+    section: HTMLElement,
+    contextGroups: GrDiffGroup[],
+    showAbove: boolean,
+    showBelow: boolean,
+    numLines: number
+  ): HTMLElement {
+    const row = this._createElement('tr', 'contextDivider');
+    if (!(showAbove && showBelow)) {
+      row.classList.add('collapsed');
+    }
+
+    const element = this._createElement('td', 'dividerCell');
+    row.appendChild(element);
+
+    const showAllContainer = this._createElement('div', 'aboveBelowButtons');
+    element.appendChild(showAllContainer);
+
+    const showAllButton = this._createContextButton(
+      ContextButtonType.ALL,
+      section,
+      contextGroups,
+      numLines
+    );
+    showAllButton.classList.add(
+      showAbove && showBelow
+        ? 'centeredButton'
+        : showAbove
+        ? 'aboveButton'
+        : 'belowButton'
+    );
+    showAllContainer.appendChild(showAllButton);
+
+    const showPartialLinks = numLines > PARTIAL_CONTEXT_AMOUNT;
+    if (showPartialLinks) {
+      const container = this._createElement('div', 'aboveBelowButtons');
+      if (showAbove) {
+        container.appendChild(
+          this._createContextButton(
+            ContextButtonType.ABOVE,
+            section,
+            contextGroups,
+            numLines
+          )
+        );
+      }
+      if (showBelow) {
+        container.appendChild(
+          this._createContextButton(
+            ContextButtonType.BELOW,
+            section,
+            contextGroups,
+            numLines
+          )
+        );
+      }
+      element.appendChild(container);
+    }
+
+    return row;
+  }
+
+  /**
+   * Creates a table row to serve as padding between code and context controls.
+   * Blame column, line gutters, and content area will continue visually, but
+   * context controls can render over this background to map more clearly to
+   * the area of code they expand.
+   */
+  _createContextControlPaddingRow(viewMode: DiffViewMode) {
+    const row = this._createElement('tr', 'contextBackground');
+
+    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
+      row.classList.add('side-by-side');
+      row.setAttribute('left-type', GrDiffGroupType.CONTEXT_CONTROL);
+      row.setAttribute('right-type', GrDiffGroupType.CONTEXT_CONTROL);
+    } else {
+      row.classList.add('unified');
+    }
+
+    row.appendChild(this._createBlameCell(0));
+    row.appendChild(this._createElement('td', 'contextLineNum'));
+    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
+      row.appendChild(this._createElement('td'));
+    }
+    row.appendChild(this._createElement('td', 'contextLineNum'));
+    row.appendChild(this._createElement('td'));
+
+    return row;
+  }
+
   _createContextButton(
     type: ContextButtonType,
     section: HTMLElement,
@@ -354,41 +551,87 @@
   ) {
     const context = PARTIAL_CONTEXT_AMOUNT;
     const button = this._createElement('gr-button', 'showContext');
+    if (this.useNewContextControls) {
+      button.classList.add('contextControlButton');
+    }
     button.setAttribute('link', 'true');
     button.setAttribute('no-uppercase', 'true');
 
     let text = '';
     let groups: GrDiffGroup[] = []; // The groups that replace this one if tapped.
+    let requiresLoad = false;
     if (type === GrDiffBuilder.ContextButtonType.ALL) {
-      const icon = this._createElement('iron-icon', 'showContext');
-      icon.setAttribute('icon', 'gr-icons:unfold-more');
-      button.appendChild(icon);
-
-      text = `Show ${numLines} common line`;
+      if (this.useNewContextControls) {
+        text = `+${numLines} common line`;
+        button.setAttribute('aria-label', `Show ${numLines} common lines`);
+      } else {
+        text = `Show ${numLines} common line`;
+        const icon = this._createElement('iron-icon', 'showContext');
+        icon.setAttribute('icon', 'gr-icons:unfold-more');
+        button.appendChild(icon);
+      }
       if (numLines > 1) {
         text += 's';
       }
+      requiresLoad = contextGroups.find(c => !!c.skip) !== undefined;
+      if (requiresLoad) {
+        // Expanding content would require load of more data
+        text += ' (too large)';
+      }
       groups.push(...contextGroups);
     } else if (type === GrDiffBuilder.ContextButtonType.ABOVE) {
-      text = `+${context} above`;
       groups = hideInContextControl(contextGroups, context, numLines);
+      if (this.useNewContextControls) {
+        text = `+${context}`;
+        button.classList.add('aboveButton');
+        button.setAttribute('aria-label', `Show ${context} lines above`);
+      } else {
+        text = `+${context} above`;
+      }
     } else if (type === GrDiffBuilder.ContextButtonType.BELOW) {
-      text = `+${context} below`;
       groups = hideInContextControl(contextGroups, 0, numLines - context);
+      if (this.useNewContextControls) {
+        text = `+${context}`;
+        button.classList.add('belowButton');
+        button.setAttribute('aria-label', `Show ${context} lines below`);
+      } else {
+        text = `+${context} below`;
+      }
     }
     const textSpan = this._createElement('span', 'showContext');
     textSpan.textContent = text;
     button.appendChild(textSpan);
 
-    button.addEventListener('tap', e => {
-      const event = e as ContextEvent;
-      event.detail = {
-        groups,
-        section,
-        numLines,
-      };
-      // Let it bubble up the DOM tree.
-    });
+    if (requiresLoad) {
+      button.addEventListener('tap', e => {
+        e.stopPropagation();
+        const firstRange = groups[0].lineRange;
+        const lastRange = groups[groups.length - 1].lineRange;
+        const lineRange = {
+          left: {start: firstRange.left.start, end: lastRange.left.end},
+          right: {start: firstRange.right.start, end: lastRange.right.end},
+        };
+        button.dispatchEvent(
+          new CustomEvent<ContentLoadNeededEventDetail>('content-load-needed', {
+            detail: {
+              lineRange,
+            },
+            bubbles: true,
+            composed: true,
+          })
+        );
+      });
+    } else {
+      button.addEventListener('tap', e => {
+        const event = e as ContextEvent;
+        event.detail = {
+          groups,
+          section,
+          numLines,
+        };
+        // Let it bubble up the DOM tree.
+      });
+    }
 
     return button;
   }
@@ -400,14 +643,13 @@
     side: Side
   ) {
     const td = this._createElement('td');
+    td.classList.add(side);
     if (line.type === GrDiffLineType.BLANK) {
       return td;
     }
     if (line.type === GrDiffLineType.BOTH || line.type === type) {
-      // Both td and button need a number of classes/attributes for various
-      // selectors to work.
-      this._decorateLineEl(td, number, side);
       td.classList.add('lineNum');
+      td.dataset['value'] = number.toString();
 
       if (this._prefs.show_file_comment_button === false && number === 'FILE') {
         return td;
@@ -416,11 +658,13 @@
       const button = this._createElement('button');
       td.appendChild(button);
       button.tabIndex = -1;
-      this._decorateLineEl(button, number, side);
-
       button.classList.add('lineNumButton');
-
+      button.classList.add(side);
+      button.dataset['value'] = number.toString();
       button.textContent = number === 'FILE' ? 'File' : number.toString();
+      if (number === 'FILE') {
+        button.setAttribute('aria-label', 'Add file comment');
+      }
 
       // Add aria-labels for valid line numbers.
       // For unified diff, this method will be called with number set to 0 for
@@ -438,11 +682,6 @@
     return td;
   }
 
-  _decorateLineEl(el: HTMLElement, number: LineNumber, side: Side) {
-    el.classList.add(side);
-    el.dataset['value'] = number.toString();
-  }
-
   _createTextEl(
     lineNumberEl: HTMLElement | null,
     line: GrDiffLine,
@@ -659,6 +898,17 @@
     }
   }
 
+  _createMoveDescription(movedIn: boolean, group: GrDiffGroup) {
+    if (group.moveDetails?.range) {
+      const {changed, range} = group.moveDetails;
+      const moveLabel = 'Moved' + (changed ? ' and changed' : '');
+      const direction = movedIn ? 'from' : 'to';
+      const lineDetails = `lines ${range.start} - ${range.end}`;
+      return `${moveLabel} ${direction} ${lineDetails}`;
+    }
+    return movedIn ? 'Moved in' : 'Moved out';
+  }
+
   _buildMoveControls(group: GrDiffGroup) {
     const movedIn = group.adds.length > 0;
     const {
@@ -668,16 +918,14 @@
     } = this._getMoveControlsConfig();
 
     let controlsClass;
-    let descriptionText;
     let descriptionIndex;
+    const descriptionText = this._createMoveDescription(movedIn, group);
     if (movedIn) {
       controlsClass = 'movedIn';
       descriptionIndex = movedInIndex;
-      descriptionText = 'Moved in';
     } else {
       controlsClass = 'movedOut';
       descriptionIndex = movedOutIndex;
-      descriptionText = 'Moved out';
     }
     const controls = document.createElement('tr');
     const cells = [...Array(numberOfCells).keys()].map(() =>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
index 59a0370..43ed77f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -17,8 +17,11 @@
 
 import '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {
+  AbortStop,
   CursorMoveResult,
   GrCursorManager,
+  Stop,
+  isTargetable,
 } from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom';
@@ -32,6 +35,8 @@
 import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
 import {PolymerDomWrapper} from '../../../types/types';
 import {GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {GrDiff} from '../gr-diff/gr-diff';
+import {fireAlert} from '../../../utils/event-util';
 
 const DiffViewMode = {
   SIDE_BY_SIDE: 'SIDE_BY_SIDE',
@@ -46,15 +51,6 @@
 // Time in which pressing n key again after the toast navigates to next file
 const NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS = 5000;
 
-// TODO(TS): Use proper GrDiff type once that file is converted to TS.
-interface GrDiff extends HTMLElement {
-  path: string;
-  addDraftAtLine: (element: HTMLElement) => void;
-  createRangeComment: () => void;
-  getCursorStops: () => HTMLElement[];
-  isRangeSelected: () => boolean;
-}
-
 export interface GrDiffCursor {
   $: {
     cursorManager: GrCursorManager;
@@ -69,14 +65,6 @@
     return htmlTemplate;
   }
 
-  private _boundHandleWindowScroll: () => void;
-
-  private _boundHandleDiffRenderStart: () => void;
-
-  private _boundHandleDiffRenderContent: () => void;
-
-  private _boundHandleDiffLineSelected: (e: Event) => void;
-
   private _preventAutoScrollOnManualScroll = false;
 
   private lastDisplayedNavigateToNextFileToast: number | null = null;
@@ -115,15 +103,6 @@
   @property({type: Boolean})
   _listeningForScroll = false;
 
-  constructor() {
-    super();
-    this._boundHandleWindowScroll = () => this._handleWindowScroll();
-    this._boundHandleDiffRenderStart = () => this._handleDiffRenderStart();
-    this._boundHandleDiffRenderContent = () => this._handleDiffRenderContent();
-    this._boundHandleDiffLineSelected = (e: Event) =>
-      this._handleDiffLineSelected(e);
-  }
-
   /** @override */
   ready() {
     super.ready();
@@ -160,6 +139,16 @@
     window.removeEventListener('scroll', this._boundHandleWindowScroll);
   }
 
+  // Don't remove - used by clients embedding gr-diff outside of Gerrit.
+  isAtStart() {
+    return this.$.cursorManager.isAtStart();
+  }
+
+  // Don't remove - used by clients embedding gr-diff outside of Gerrit.
+  isAtEnd() {
+    return this.$.cursorManager.isAtEnd();
+  }
+
   moveLeft() {
     this.side = Side.LEFT;
     if (this._isTargetBlank()) {
@@ -176,21 +165,21 @@
 
   moveDown() {
     if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-      this.$.cursorManager.next({
+      return this.$.cursorManager.next({
         filter: (row: Element) => this._rowHasSide(row),
       });
     } else {
-      this.$.cursorManager.next();
+      return this.$.cursorManager.next();
     }
   }
 
   moveUp() {
     if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-      this.$.cursorManager.previous({
+      return this.$.cursorManager.previous({
         filter: (row: Element) => this._rowHasSide(row),
       });
     } else {
-      this.$.cursorManager.previous();
+      return this.$.cursorManager.previous();
     }
   }
 
@@ -204,7 +193,10 @@
     }
   }
 
-  moveToNextChunk(clipToTop?: boolean, navigateToNextFile?: boolean) {
+  moveToNextChunk(
+    clipToTop?: boolean,
+    navigateToNextFile?: boolean
+  ): CursorMoveResult {
     const result = this.$.cursorManager.next({
       filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
       getTargetHeight: target =>
@@ -219,7 +211,7 @@
     if (
       navigateToNextFile &&
       result === CursorMoveResult.CLIPPED &&
-      this.$.cursorManager.isAtEnd()
+      this.isAtEnd()
     ) {
       if (
         this.lastDisplayedNavigateToNextFileToast &&
@@ -234,41 +226,38 @@
             bubbles: true,
           })
         );
+      } else {
+        this.lastDisplayedNavigateToNextFileToast = Date.now();
+        fireAlert(this, 'Press n again to navigate to next unreviewed file');
       }
-      this.lastDisplayedNavigateToNextFileToast = Date.now();
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Press n again to navigate to next unreviewed file',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
     }
 
     this._fixSide();
+    return result;
   }
 
-  moveToPreviousChunk() {
-    this.$.cursorManager.previous({
+  moveToPreviousChunk(): CursorMoveResult {
+    const result = this.$.cursorManager.previous({
       filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
     });
     this._fixSide();
+    return result;
   }
 
-  moveToNextCommentThread() {
-    this.$.cursorManager.next({
+  moveToNextCommentThread(): CursorMoveResult {
+    const result = this.$.cursorManager.next({
       filter: (row: HTMLElement) => this._rowHasThread(row),
     });
     this._fixSide();
+    return result;
   }
 
-  moveToPreviousCommentThread() {
-    this.$.cursorManager.previous({
+  moveToPreviousCommentThread(): CursorMoveResult {
+    const result = this.$.cursorManager.previous({
       filter: (row: HTMLElement) => this._rowHasThread(row),
     });
     this._fixSide();
+    return result;
   }
 
   moveToLineNumber(number: number, side: Side, path?: string) {
@@ -345,13 +334,13 @@
     this._scrollMode = ScrollMode.KEEP_VISIBLE;
   }
 
-  _handleWindowScroll() {
+  private _boundHandleWindowScroll = () => {
     if (this._preventAutoScrollOnManualScroll) {
       this._scrollMode = ScrollMode.NEVER;
       this._focusOnMove = false;
       this._preventAutoScrollOnManualScroll = false;
     }
-  }
+  };
 
   reInitAndUpdateStops() {
     this.reInit();
@@ -363,25 +352,29 @@
     this.reInitCursor();
   }
 
-  _handleDiffRenderStart() {
-    this._preventAutoScrollOnManualScroll = true;
-  }
+  private boundHandleDiffLoadingChanged = () => {
+    this._updateStops();
+  };
 
-  _handleDiffRenderContent() {
+  private _boundHandleDiffRenderStart = () => {
+    this._preventAutoScrollOnManualScroll = true;
+  };
+
+  private _boundHandleDiffRenderContent = () => {
     this._updateStops();
     // When done rendering, turn focus on move and automatic scrolling back on
     this._focusOnMove = true;
     this._preventAutoScrollOnManualScroll = false;
-  }
+  };
 
-  _handleDiffLineSelected(event: Event) {
+  private _boundHandleDiffLineSelected = (event: Event) => {
     const customEvent = event as CustomEvent;
     this.moveToLineNumber(
       customEvent.detail.number,
       customEvent.detail.side,
       customEvent.detail.path
     );
-  }
+  };
 
   createCommentInPlace() {
     const diffWithRangeSelected = this.diffs.find(diff =>
@@ -404,7 +397,6 @@
    * {leftSide: true, number: 321} for line 321 of the base patch.
    * Returns null if an address is not available.
    *
-   * @return
    */
   getAddress() {
     if (!this.diffRow) {
@@ -433,7 +425,7 @@
 
     return {
       leftSide: cell.matches('.left'),
-      number: parseInt(number, 10),
+      number: Number(number),
     };
   }
 
@@ -457,11 +449,12 @@
 
   _isFirstRowOfChunk(row: HTMLElement) {
     const parentClassList = (row.parentNode as HTMLElement).classList;
-    return (
-      parentClassList.contains('section') &&
-      parentClassList.contains('delta') &&
-      !row.previousSibling
-    );
+    const isInChunk =
+      parentClassList.contains('section') && parentClassList.contains('delta');
+    const previousRow = row.previousSibling as HTMLElement;
+    const firstContentRow =
+      !previousRow || previousRow.classList.contains('moveControls');
+    return isInChunk && firstContentRow;
   }
 
   _rowHasThread(row: HTMLElement): boolean {
@@ -530,7 +523,7 @@
 
   _updateStops() {
     this.$.cursorManager.stops = this.diffs.reduce(
-      (stops: HTMLElement[], diff) => stops.concat(diff.getCursorStops()),
+      (stops: Stop[], diff) => stops.concat(diff.getCursorStops()),
       []
     );
   }
@@ -560,6 +553,10 @@
       // might be the same.
       for (i = 0; i < splice?.removed.length; i++) {
         splice.removed[i].removeEventListener(
+          'loading-changed',
+          this.boundHandleDiffLoadingChanged
+        );
+        splice.removed[i].removeEventListener(
           'render-start',
           this._boundHandleDiffRenderStart
         );
@@ -575,6 +572,10 @@
 
       for (i = splice.index; i < splice.index + splice.addedCount; i++) {
         this.diffs[i].addEventListener(
+          'loading-changed',
+          this.boundHandleDiffLoadingChanged
+        );
+        this.diffs[i].addEventListener(
           'render-start',
           this._boundHandleDiffRenderStart
         );
@@ -595,21 +596,18 @@
     side: Side,
     path?: string
   ): HTMLElement | undefined {
-    let stops;
+    let stops: Array<HTMLElement | AbortStop>;
     if (path) {
       const diff = this.diffs.filter(diff => diff.path === 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];
-      }
-    }
-    return undefined;
+    // Sadly needed for type narrowing to understand that the result is always
+    // targetable.
+    const targetableStops: HTMLElement[] = stops.filter(isTargetable);
+    const selector = `.lineNum.${side}[data-value="${targetNumber}"]`;
+    return targetableStops.find(stop => stop.querySelector(selector));
   }
 }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
index d4b9d27..718e11b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
@@ -19,8 +19,9 @@
 import '../gr-diff/gr-diff.js';
 import './gr-diff-cursor.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {listenOnce} from '../../../test/test-utils.js';
+import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
 
 const basicFixture = fixtureFromTemplate(html`
   <gr-diff></gr-diff>
@@ -33,6 +34,7 @@
 suite('gr-diff-cursor tests', () => {
   let cursorElement;
   let diffElement;
+  let diff;
 
   setup(done => {
     const fixtureElems = basicFixture.instantiate();
@@ -58,9 +60,10 @@
     };
     diffElement.addEventListener('render', setupDone);
 
+    diff = getMockDiffResponse();
     restAPI.getDiffPreferences().then(prefs => {
       diffElement.prefs = prefs;
-      diffElement.diff = getMockDiffResponse();
+      diffElement.diff = diff;
     });
   });
 
@@ -98,14 +101,14 @@
   test('cursor scroll behavior', () => {
     assert.equal(cursorElement._scrollMode, 'keep-visible');
 
-    cursorElement._handleDiffRenderStart();
+    diffElement.dispatchEvent(new Event('render-start'));
     assert.isTrue(cursorElement._focusOnMove);
 
-    cursorElement._handleWindowScroll();
+    window.dispatchEvent(new Event('scroll'));
     assert.equal(cursorElement._scrollMode, 'never');
     assert.isFalse(cursorElement._focusOnMove);
 
-    cursorElement._handleDiffRenderContent();
+    diffElement.dispatchEvent(new Event('render-content'));
     assert.isTrue(cursorElement._focusOnMove);
 
     cursorElement.reInitCursor();
@@ -115,7 +118,7 @@
   test('moves to selected line', () => {
     const moveToNumStub = sinon.stub(cursorElement, 'moveToLineNumber');
 
-    cursorElement._handleDiffLineSelected(
+    diffElement.dispatchEvent(
         new CustomEvent('line-selected', {
           detail: {number: '123', side: 'right', path: 'some/file'},
         }));
@@ -223,6 +226,109 @@
     assert.equal(cursorElement.side, 'left');
   });
 
+  // To be removed as soon due_to_move (deprecated) is removed
+  suite('moved chunks (dueToMove=true)', () => {
+    setup(done => {
+      const renderHandler = function() {
+        diffElement.removeEventListener('render', renderHandler);
+        cursorElement.reInitCursor();
+        done();
+      };
+      diffElement.addEventListener('render', renderHandler);
+      diffElement.diff = {...diff, content: [
+        {
+          ab: [
+            'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ',
+          ],
+        },
+        {
+          b: [
+            'Nullam neque, ligula ac, id blandit.',
+            'Sagittis tincidunt torquent, tempor nunc amet.',
+            'At rhoncus id.',
+          ],
+          due_to_move: true,
+        },
+        {
+          ab: [
+            'Sem nascetur, erat ut, non in.',
+          ],
+        },
+        {
+          a: [
+            'Nullam neque, ligula ac, id blandit.',
+            'Sagittis tincidunt torquent, tempor nunc amet.',
+            'At rhoncus id.',
+          ],
+          due_to_move: true,
+        },
+        {
+          ab: [
+            'Arcu eget, rhoncus amet cursus, ipsum elementum.',
+          ],
+        },
+      ]};
+    });
+
+    test('renders moveControls with simple descriptions', () => {
+      const [movedIn, movedOut] = diffElement.root
+          .querySelectorAll('.dueToMove .moveControls');
+      assert.equal(movedIn.textContent, 'Moved in');
+      assert.equal(movedOut.textContent, 'Moved out');
+    });
+  });
+
+  suite('moved chunks (moveDetails)', () => {
+    setup(done => {
+      const renderHandler = function() {
+        diffElement.removeEventListener('render', renderHandler);
+        cursorElement.reInitCursor();
+        done();
+      };
+      diffElement.addEventListener('render', renderHandler);
+      diffElement.diff = {...diff, content: [
+        {
+          ab: [
+            'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ',
+          ],
+        },
+        {
+          b: [
+            'Nullam neque, ligula ac, id blandit.',
+            'Sagittis tincidunt torquent, tempor nunc amet.',
+            'At rhoncus id.',
+          ],
+          move_details: {changed: false, range: {start: 4, end: 6}},
+        },
+        {
+          ab: [
+            'Sem nascetur, erat ut, non in.',
+          ],
+        },
+        {
+          a: [
+            'Nullam neque, ligula ac, id blandit.',
+            'Sagittis tincidunt torquent, tempor nunc amet.',
+            'At rhoncus id.',
+          ],
+          move_details: {changed: false, range: {start: 2, end: 4}},
+        },
+        {
+          ab: [
+            'Arcu eget, rhoncus amet cursus, ipsum elementum.',
+          ],
+        },
+      ]};
+    });
+
+    test('renders moveControls with simple descriptions', () => {
+      const [movedIn, movedOut] = diffElement.root
+          .querySelectorAll('.dueToMove .moveControls');
+      assert.equal(movedIn.textContent, 'Moved from lines 4 - 6');
+      assert.equal(movedOut.textContent, 'Moved to lines 2 - 4');
+    });
+  });
+
   test('navigate to next unreviewed file via moveToNextChunk', () => {
     const cursorManager =
         cursorElement.shadowRoot.querySelector('#cursorManager');
@@ -404,6 +510,12 @@
     });
   });
 
+  test('updates stops when loading changes', () => {
+    sinon.spy(cursorElement, '_updateStops');
+    diffElement.dispatchEvent(new Event('loading-changed'));
+    assert.isTrue(cursorElement._updateStops.called);
+  });
+
   suite('gr-diff-cursor event tests', () => {
     let someEmptyDiv;
 
@@ -421,5 +533,78 @@
       someEmptyDiv.appendChild(cursorElement);
     });
   });
+
+  suite('multi diff', () => {
+    const multiDiffFixture = fixtureFromTemplate(html`
+      <gr-diff></gr-diff>
+      <gr-diff></gr-diff>
+      <gr-diff></gr-diff>
+      <gr-diff-cursor></gr-diff-cursor>
+      <gr-rest-api-interface></gr-rest-api-interface>
+    `);
+
+    let diffElements;
+
+    setup(async () => {
+      const fixtureElems = multiDiffFixture.instantiate();
+      diffElements = fixtureElems.slice(0, 3);
+      cursorElement = fixtureElems[3];
+      const restAPI = fixtureElems[4];
+
+      // Register the diff with the cursor.
+      cursorElement.push('diffs', ...diffElements);
+
+      await restAPI.getDiffPreferences().then(prefs => {
+        for (const el of diffElements) {
+          el.prefs = prefs;
+        }
+      });
+    });
+
+    function getTargetDiffIndex() {
+      // Mocha has a bug where when `assert.equals` fails, it will try to
+      // JSON.stringify the operands, which fails when they are cyclic structures
+      // like GrDiffElement. The failure is difficult to attribute to a specific
+      // assertion because of the async nature assertion errors are handled and
+      // can cause the test simply timing out, causing a lot of debugging headache.
+      // Working with indices circumvents the problem.
+      return diffElements.indexOf(cursorElement.getTargetDiffElement());
+    }
+
+    test('do not skip loading diffs', async () => {
+      const diffRenderedPromises =
+          diffElements.map(diffEl => listenOnce(diffEl, 'render'));
+
+      diffElements[0].diff = getMockDiffResponse();
+      diffElements[2].diff = getMockDiffResponse();
+      await Promise.all([diffRenderedPromises[0], diffRenderedPromises[2]]);
+
+      const lastLine = diffElements[0].diff.meta_b.lines;
+
+      // Goto second last line of the first diff
+      cursorElement.moveToLineNumber(lastLine - 1, 'right');
+      assert.equal(
+          cursorElement.getTargetLineElement().textContent, lastLine - 1);
+
+      // Can move down until we reach the loading file
+      cursorElement.moveDown();
+      assert.equal(getTargetDiffIndex(), 0);
+      assert.equal(cursorElement.getTargetLineElement().textContent, lastLine);
+
+      // Cannot move down while still loading the diff we would switch to
+      cursorElement.moveDown();
+      assert.equal(getTargetDiffIndex(), 0);
+      assert.equal(cursorElement.getTargetLineElement().textContent, lastLine);
+
+      // Diff 1 finishing to load
+      diffElements[1].diff = getMockDiffResponse();
+      await diffRenderedPromises[1];
+
+      // Now we can go down
+      cursorElement.moveDown();
+      assert.equal(getTargetDiffIndex(), 1);
+      assert.equal(cursorElement.getTargetLineElement().textContent, 'File');
+    });
+  });
 });
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index 49826bb..4b4f429 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -31,45 +31,41 @@
   isMergeParent,
   isNumber,
 } from '../../../utils/patch-set-util';
-import {
-  Comment,
-  isDraft,
-  PatchSetFile,
-  sortComments,
-  TwoSidesComments,
-  UIComment,
-} from '../gr-comment-api/gr-comment-api';
+import {CommentThread} from '../../../utils/comment-util';
 import {customElement, observe, property} from '@polymer/decorators';
 import {
   CommitRange,
   CoverageRange,
   DiffLayer,
   DiffLayerListener,
+  PatchSetFile,
 } from '../../../types/types';
 import {
   Base64ImageFile,
   BlameInfo,
+  ChangeInfo,
   CommentRange,
-  DiffInfo,
-  DiffPreferencesInfo,
   NumericChangeId,
   PatchRange,
   PatchSetNum,
   RepoName,
 } from '../../../types/common';
+import {
+  DiffInfo,
+  DiffPreferencesInfo,
+  IgnoreWhitespaceType,
+} from '../../../types/diff';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
 import {GrDiff, LineOfInterest} from '../gr-diff/gr-diff';
 import {GrSyntaxLayer} from '../gr-syntax-layer/gr-syntax-layer';
-import {
-  DiffViewMode,
-  IgnoreWhitespaceType,
-  Side,
-} from '../../../constants/constants';
+import {DiffViewMode, Side, CommentSide} from '../../../constants/constants';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
-import {LineNumber} from '../gr-diff/gr-diff-line';
+import {LineNumber, FILE} from '../gr-diff/gr-diff-line';
 import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {firePageError, fireAlert} from '../../../utils/event-util';
 
 const MSG_EMPTY_BLAME = 'No blame information for this diff.';
 
@@ -107,20 +103,6 @@
   afterNumber?: LineNumber;
 }
 
-// TODO(TS): Consolidate this with the CommentThread interface of comment-api.
-// What is being used here is just a local object for collecting all the data
-// that is needed to create a GrCommentThread component, see
-// _createThreadElement().
-interface CommentThread {
-  comments: UIComment[];
-  // In the context of a diff each thread must have a side!
-  commentSide: Side;
-  patchNum?: PatchSetNum;
-  lineNum?: LineNumber;
-  isOnParent?: boolean;
-  range?: CommentRange;
-}
-
 export interface GrDiffHost {
   $: {
     restAPI: RestApiService & Element;
@@ -166,6 +148,9 @@
   @property({type: Number})
   changeNum?: NumericChangeId;
 
+  @property({type: Object})
+  change?: ChangeInfo;
+
   @property({type: Boolean})
   noAutoRender = false;
 
@@ -206,8 +191,8 @@
   @property({type: Boolean})
   noRenderOnPrefsChange = false;
 
-  @property({type: Object, observer: '_commentsChanged'})
-  comments?: TwoSidesComments;
+  @property({type: Object, observer: '_threadsChanged'})
+  threads?: CommentThread[];
 
   @property({type: Boolean})
   lineWrapping = false;
@@ -231,9 +216,6 @@
   @property({type: Boolean})
   _loggedIn = false;
 
-  @property({type: Boolean})
-  _loading = false;
-
   @property({type: String})
   _errorMessage: string | null = null;
 
@@ -272,6 +254,8 @@
 
   private readonly reporting = appContext.reportingService;
 
+  private readonly flags = appContext.flagsService;
+
   /** @override */
   created() {
     super.created();
@@ -285,11 +269,12 @@
       'create-comment',
       e => this._handleCreateComment(e)
     );
-    this.addEventListener('comment-discard', e =>
-      this._handleCommentDiscard(e)
+    this.addEventListener('comment-discard', () =>
+      this._handleCommentSaveOrDiscard()
     );
-    this.addEventListener('comment-update', e => this._handleCommentUpdate(e));
-    this.addEventListener('comment-save', e => this._handleCommentSave(e));
+    this.addEventListener('comment-save', () =>
+      this._handleCommentSaveOrDiscard()
+    );
     this.addEventListener('render-start', () => this._handleRenderStart());
     this.addEventListener('render-content', () => this._handleRenderContent());
     this.addEventListener('normalize-range', event =>
@@ -325,25 +310,16 @@
   /**
    * @param shouldReportMetric indicate a new Diff Page. This is a
    * signal to report metrics event that started on location change.
-   * @return
    */
-  reload(shouldReportMetric?: boolean) {
+  async reload(shouldReportMetric?: boolean) {
     this.clear();
     if (!this.path) throw new Error('Missing required "path" property.');
     if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
-    this._loading = true;
+    this.diff = undefined;
     this._errorMessage = null;
     const whitespaceLevel = this._getIgnoreWhitespace();
 
-    const layers: DiffLayer[] = [this.$.syntaxLayer];
-    // Get layers from plugins (if any).
-    for (const pluginLayer of this.$.jsAPI.getDiffLayers(
-      this.path,
-      this.changeNum
-    )) {
-      layers.push(pluginLayer);
-    }
-    this._layers = layers;
+    this._layers = this._getLayers(this.path, this.changeNum);
 
     if (shouldReportMetric) {
       // We listen on render viewport only on DiffPage (on paramsChanged)
@@ -352,67 +328,58 @@
 
     this._coverageRanges = [];
     this._getCoverageData();
-    const diffRequest = this._getDiff()
-      .then(diff => {
-        this._loadedWhitespaceLevel = whitespaceLevel;
-        this._reportDiff(diff);
-        return diff;
-      })
-      .catch(e => {
-        this._handleGetDiffError(e);
-        return null;
-      });
 
-    const assetRequest = diffRequest.then(diff => {
-      // If the diff is null, then it's failed to load.
-      if (!diff) {
-        return null;
+    try {
+      const diff = await this._getDiff();
+      this._loadedWhitespaceLevel = whitespaceLevel;
+      this._reportDiff(diff);
+
+      await this._loadDiffAssets(diff);
+
+      // Not waiting for coverage ranges intentionally as
+      // plugin loading should not block the content rendering
+
+      this.filesWeblinks = this._getFilesWeblinks(diff);
+      this.diff = diff;
+      const event = await this._onRenderOnce();
+      if (shouldReportMetric) {
+        // We report diffViewContentDisplayed only on reload caused
+        // by params changed - expected only on Diff Page.
+        this.reporting.diffViewContentDisplayed();
       }
-
-      return this._loadDiffAssets(diff);
-    });
-
-    // Not waiting for coverage ranges intentionally as
-    // plugin loading should not block the content rendering
-    return Promise.all([diffRequest, assetRequest])
-      .then(results => {
-        const diff = results[0];
-        if (!diff) {
-          return Promise.resolve();
+      const needsSyntaxHighlighting = !!event.detail?.contentRendered;
+      if (needsSyntaxHighlighting) {
+        this.reporting.time(TimingLabel.SYNTAX);
+        try {
+          await this.$.syntaxLayer.process();
+        } finally {
+          this.reporting.timeEnd(TimingLabel.SYNTAX);
         }
-        this.filesWeblinks = this._getFilesWeblinks(diff);
-        return new Promise(resolve => {
-          const callback = (event: CustomEvent) => {
-            const needsSyntaxHighlighting =
-              event.detail && event.detail.contentRendered;
-            if (needsSyntaxHighlighting) {
-              this.reporting.time(TimingLabel.SYNTAX);
-              this.$.syntaxLayer.process().finally(() => {
-                this.reporting.timeEnd(TimingLabel.SYNTAX);
-                this.reporting.timeEnd(TimingLabel.TOTAL);
-                resolve();
-              });
-            } else {
-              this.reporting.timeEnd(TimingLabel.TOTAL);
-              resolve();
-            }
-            this.removeEventListener('render', callback);
-            if (shouldReportMetric) {
-              // We report diffViewContentDisplayed only on reload caused
-              // by params changed - expected only on Diff Page.
-              this.reporting.diffViewContentDisplayed();
-            }
-          };
-          this.addEventListener('render', callback);
-          this.diff = diff;
-        });
-      })
-      .catch(err => {
-        console.warn('Error encountered loading diff:', err);
-      })
-      .then(() => {
-        this._loading = false;
-      });
+      }
+    } catch (e) {
+      if (e instanceof Response) {
+        this._handleGetDiffError(e);
+      } else {
+        console.warn('Error encountered loading diff:', e);
+      }
+    } finally {
+      this.reporting.timeEnd(TimingLabel.TOTAL);
+    }
+  }
+
+  private _getLayers(path: string, changeNum: NumericChangeId): DiffLayer[] {
+    // Get layers from plugins (if any).
+    return [this.$.syntaxLayer, ...this.$.jsAPI.getDiffLayers(path, changeNum)];
+  }
+
+  private _onRenderOnce(): Promise<CustomEvent> {
+    return new Promise<CustomEvent>(resolve => {
+      const callback = (event: CustomEvent) => {
+        this.removeEventListener('render', callback);
+        resolve(event);
+      };
+      this.addEventListener('render', callback);
+    });
   }
 
   clear() {
@@ -422,61 +389,67 @@
 
   _getCoverageData() {
     if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
+    if (!this.change) throw new Error('Missing required "change" prop.');
     if (!this.path) throw new Error('Missing required "path" prop.');
     if (!this.patchRange) throw new Error('Missing required "patchRange".');
     const changeNum = this.changeNum;
+    const change = this.change;
     const path = this.path;
     // Coverage providers do not provide data for EDIT and PARENT patch sets.
-    const basePatchNum = isNumber(this.patchRange.basePatchNum)
-      ? this.patchRange.basePatchNum
-      : undefined;
-    const patchNum = isNumber(this.patchRange.patchNum)
-      ? this.patchRange.patchNum
-      : undefined;
+
+    const toNumberOnly = (patchNum: PatchSetNum) =>
+      isNumber(patchNum) ? patchNum : undefined;
+
+    const basePatchNum = toNumberOnly(this.patchRange.basePatchNum);
+    const patchNum = toNumberOnly(this.patchRange.patchNum);
     this.$.jsAPI
-      .getCoverageAnnotationApi()
-      .then(coverageAnnotationApi => {
-        if (!coverageAnnotationApi) return;
-        const provider = coverageAnnotationApi.getCoverageProvider();
-        if (!provider) return;
-        return provider(changeNum, path, basePatchNum, patchNum).then(
-          coverageRanges => {
-            if (!this.patchRange) throw new Error('Missing "patchRange".');
-            if (
-              !coverageRanges ||
-              changeNum !== this.changeNum ||
-              path !== this.path ||
-              basePatchNum !== this.patchRange.basePatchNum ||
-              patchNum !== this.patchRange.patchNum
-            ) {
-              return;
-            }
+      .getCoverageAnnotationApis()
+      .then(coverageAnnotationApis => {
+        coverageAnnotationApis.forEach(coverageAnnotationApi => {
+          const provider = coverageAnnotationApi.getCoverageProvider();
+          if (!provider) return;
+          provider(changeNum, path, basePatchNum, patchNum, change)
+            .then(coverageRanges => {
+              if (!this.patchRange) throw new Error('Missing "patchRange".');
+              if (
+                !coverageRanges ||
+                changeNum !== this.changeNum ||
+                change !== this.change ||
+                path !== this.path ||
+                basePatchNum !== toNumberOnly(this.patchRange.basePatchNum) ||
+                patchNum !== toNumberOnly(this.patchRange.patchNum)
+              ) {
+                return;
+              }
 
-            const existingCoverageRanges = this._coverageRanges;
-            this._coverageRanges = coverageRanges;
+              const existingCoverageRanges = this._coverageRanges;
+              this._coverageRanges = coverageRanges;
 
-            // Notify with existing coverage ranges
-            // in case there is some existing coverage data that needs to be removed
-            existingCoverageRanges.forEach(range => {
-              coverageAnnotationApi.notify(
-                path,
-                range.code_range.start_line,
-                range.code_range.end_line,
-                range.side
-              );
+              // Notify with existing coverage ranges in case there is some
+              // existing coverage data that needs to be removed
+              existingCoverageRanges.forEach(range => {
+                coverageAnnotationApi.notify(
+                  path,
+                  range.code_range.start_line,
+                  range.code_range.end_line,
+                  range.side
+                );
+              });
+
+              // Notify with new coverage data
+              coverageRanges.forEach(range => {
+                coverageAnnotationApi.notify(
+                  path,
+                  range.code_range.start_line,
+                  range.code_range.end_line,
+                  range.side
+                );
+              });
+            })
+            .catch(err => {
+              console.warn('Applying coverage from provider failed: ', err);
             });
-
-            // Notify with new coverage data
-            coverageRanges.forEach(range => {
-              coverageAnnotationApi.notify(
-                path,
-                range.code_range.start_line,
-                range.code_range.end_line,
-                range.side
-              );
-            });
-          }
-        );
+        });
       })
       .catch(err => {
         console.warn('Loading coverage ranges failed: ', err);
@@ -534,13 +507,7 @@
       .getBlame(this.changeNum, this.patchRange.patchNum, this.path, true)
       .then(blame => {
         if (!blame || !blame.length) {
-          this.dispatchEvent(
-            new CustomEvent('show-alert', {
-              detail: {message: MSG_EMPTY_BLAME},
-              composed: true,
-              bubbles: true,
-            })
-          );
+          fireAlert(this, MSG_EMPTY_BLAME);
           return Promise.reject(MSG_EMPTY_BLAME);
         }
 
@@ -639,13 +606,7 @@
       return;
     }
 
-    this.dispatchEvent(
-      new CustomEvent('page-error', {
-        detail: {response},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    firePageError(this, response);
   }
 
   /**
@@ -712,60 +673,18 @@
     return isImageDiff(diff);
   }
 
-  _commentsChanged(newComments: TwoSidesComments) {
-    const allComments = [];
-    for (const side of [Side.LEFT, Side.RIGHT]) {
-      // This is needed by the threading.
-      for (const comment of newComments[side]) {
-        comment.__commentSide = side;
-      }
-      allComments.push(...newComments[side]);
-    }
+  _threadsChanged(threads: CommentThread[]) {
     // Currently, the only way this is ever changed here is when the initial
-    // comments are loaded, so it's okay performance wise to clear the threads
+    // threads are loaded, so it's okay performance wise to clear the threads
     // and recreate them. If this changes in future, we might want to reuse
     // some DOM nodes here.
     this._clearThreads();
-    const threads = this._createThreads(allComments);
     for (const thread of threads) {
       const threadEl = this._createThreadElement(thread);
       this._attachThreadElement(threadEl);
     }
   }
 
-  _createThreads(comments: UIComment[]): CommentThread[] {
-    const sortedComments = sortComments(comments);
-    const threads = [];
-    for (const comment of sortedComments) {
-      // If the comment is in reply to another comment, find that comment's
-      // thread and append to it.
-      if (comment.in_reply_to) {
-        const thread = threads.find(thread =>
-          thread.comments.some(c => c.id === comment.in_reply_to)
-        );
-        if (thread) {
-          thread.comments.push(comment);
-          continue;
-        }
-      }
-
-      // Otherwise, this comment starts its own thread.
-      if (!comment.__commentSide) throw new Error('Missing "__commentSide".');
-      const newThread: CommentThread = {
-        comments: [comment],
-        commentSide: comment.__commentSide,
-        patchNum: comment.patch_set,
-        lineNum: comment.line,
-        isOnParent: comment.side === 'PARENT',
-      };
-      if (comment.range) {
-        newThread.range = {...comment.range};
-      }
-      threads.push(newThread);
-    }
-    return threads;
-  }
-
   _computeIsBlameLoaded(blame: BlameInfo[] | null) {
     return !!blame;
   }
@@ -781,13 +700,14 @@
   }
 
   _handleCreateComment(e: CustomEvent) {
-    const {lineNum, side, patchNum, isOnParent, range} = e.detail;
+    const {lineNum, side, patchNum, range, path, commentSide} = e.detail;
     const threadEl = this._getOrCreateThread(
       patchNum,
       lineNum,
       side,
-      range,
-      isOnParent
+      commentSide,
+      path,
+      range
     );
     threadEl.addOrEditDraft(lineNum, range);
 
@@ -801,19 +721,21 @@
   _getOrCreateThread(
     patchNum: PatchSetNum,
     lineNum: LineNumber | undefined,
-    commentSide: Side,
-    range?: CommentRange,
-    isOnParent?: boolean
+    diffSide: Side,
+    commentSide: CommentSide,
+    path: string,
+    range?: CommentRange
   ): GrCommentThread {
-    let threadEl = this._getThreadEl(lineNum, commentSide, range);
+    let threadEl = this._getThreadEl(lineNum, diffSide, range);
     if (!threadEl) {
       threadEl = this._createThreadElement({
         comments: [],
+        path,
+        diffSide,
         commentSide,
         patchNum,
-        lineNum,
+        line: lineNum,
         range,
-        isOnParent,
       });
       this._attachThreadElement(threadEl);
     }
@@ -834,18 +756,18 @@
   _createThreadElement(thread: CommentThread) {
     const threadEl = document.createElement('gr-comment-thread');
     threadEl.className = 'comment-thread';
-    threadEl.setAttribute('slot', `${thread.commentSide}-${thread.lineNum}`);
+    threadEl.setAttribute('slot', `${thread.diffSide}-${thread.line}`);
     threadEl.comments = thread.comments;
-    threadEl.commentSide = thread.commentSide;
-    threadEl.isOnParent = !!thread.isOnParent;
+    threadEl.commentSide = thread.diffSide;
+    threadEl.isOnParent = thread.commentSide === CommentSide.PARENT;
     threadEl.parentIndex = this._parentIndex;
     // Use path before renmaing when comment added on the left when comparing
     // two patch sets (not against base)
     if (
       this.file &&
       this.file.basePath &&
-      thread.commentSide === Side.LEFT &&
-      !thread.isOnParent
+      thread.diffSide === Side.LEFT &&
+      !threadEl.isOnParent
     ) {
       threadEl.path = this.file.basePath;
     } else {
@@ -855,7 +777,7 @@
     threadEl.patchNum = thread.patchNum;
     threadEl.showPatchset = false;
     // GrCommentThread does not understand 'FILE', but requires undefined.
-    threadEl.lineNum = thread.lineNum !== 'FILE' ? thread.lineNum : undefined;
+    threadEl.lineNum = thread.line !== 'FILE' ? thread.line : undefined;
     threadEl.projectName = this.projectName;
     threadEl.range = thread.range;
     const threadDiscardListener = (e: Event) => {
@@ -887,7 +809,9 @@
     }
     function matchesRange(threadEl: GrCommentThread) {
       const rangeAtt = threadEl.getAttribute('range');
-      const threadRange = rangeAtt ? JSON.parse(rangeAtt) : undefined;
+      const threadRange = rangeAtt
+        ? (JSON.parse(rangeAtt) as CommentRange)
+        : undefined;
       return rangesEqual(threadRange, range);
     }
 
@@ -919,19 +843,16 @@
     function matchesFileComment(threadEl: GrCommentThread) {
       return (
         threadEl.getAttribute('comment-side') === side &&
-        // line/range comments have 1-based line set, if line is falsy it's
-        // a file comment
-        !threadEl.getAttribute('line-num')
+        threadEl.getAttribute('line-num') === FILE
       );
     }
 
     // Select the appropriate matchers for the desired side and line
-    // If side is BOTH, we want both the left and right matcher.
     const matchers: ((thread: GrCommentThread) => boolean)[] = [];
-    if (side !== Side.RIGHT) {
+    if (side === Side.LEFT) {
       matchers.push(matchesLeftLine);
     }
-    if (side !== Side.LEFT) {
+    if (side === Side.RIGHT) {
       matchers.push(matchesRightLine);
     }
     if (lineInfo.afterNumber === 'FILE' || lineInfo.beforeNumber === 'FILE') {
@@ -944,7 +865,7 @@
 
   _getIgnoreWhitespace(): IgnoreWhitespaceType {
     if (!this.prefs || !this.prefs.ignore_whitespace) {
-      return IgnoreWhitespaceType.IGNORE_NONE;
+      return 'IGNORE_NONE';
     }
     return this.prefs.ignore_whitespace;
   }
@@ -996,79 +917,12 @@
       : null;
   }
 
-  _handleCommentSave(e: CustomEvent) {
-    const comment = e.detail.comment;
-    const side = e.detail.comment.__commentSide;
-    const idx = this._findDraftIndex(comment, side);
-    this.set(['comments', side, idx], comment);
-    this._handleCommentSaveOrDiscard();
-  }
-
-  _handleCommentDiscard(e: CustomEvent) {
-    const comment = e.detail.comment;
-    this._removeComment(comment);
-    this._handleCommentSaveOrDiscard();
-  }
-
-  _handleCommentUpdate(e: CustomEvent) {
-    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);
-    }
-    if (idx !== -1) {
-      // Update draft or comment.
-      this.set(['comments', side, idx], comment);
-    } else {
-      // Create new draft.
-      this.push(['comments', side], comment);
-    }
-  }
-
   _handleCommentSaveOrDiscard() {
     this.dispatchEvent(
       new CustomEvent('diff-comments-modified', {bubbles: true, composed: true})
     );
   }
 
-  _removeComment(comment: UIComment) {
-    const side = comment.__commentSide;
-    if (!side) throw new Error('Missing required "side" in comment.');
-    this._removeCommentFromSide(comment, side);
-  }
-
-  _removeCommentFromSide(comment: Comment, side: Side) {
-    let idx = this._findCommentIndex(comment, side);
-    if (idx === -1) {
-      idx = this._findDraftIndex(comment, side);
-    }
-    if (idx !== -1) {
-      this.splice('comments.' + side, idx, 1);
-    }
-  }
-
-  _findCommentIndex(comment: Comment, side: Side) {
-    if (!comment.id || !this.comments || !this.comments[side]) {
-      return -1;
-    }
-    return this.comments[side].findIndex(item => item.id === comment.id);
-  }
-
-  _findDraftIndex(comment: Comment, side: Side) {
-    if (
-      !isDraft(comment) ||
-      !comment.__draftID ||
-      !this.comments ||
-      !this.comments[side]
-    ) {
-      return -1;
-    }
-    return this.comments[side].findIndex(
-      item => isDraft(item) && item.__draftID === comment.__draftID
-    );
-  }
-
   _isSyntaxHighlightingEnabled(
     preferenceChangeRecord?: PolymerDeepPropertyChange<
       DiffPreferencesInfo,
@@ -1076,18 +930,26 @@
     >,
     diff?: DiffInfo
   ) {
-    if (
-      !preferenceChangeRecord ||
-      !preferenceChangeRecord.base ||
-      !preferenceChangeRecord.base.syntax_highlighting ||
-      !diff
-    ) {
+    if (!preferenceChangeRecord?.base?.syntax_highlighting || !diff) {
       return false;
     }
-    return (
-      !this._anyLineTooLong(diff) &&
-      this.$.diff.getDiffLength(diff) <= SYNTAX_MAX_DIFF_LENGTH
-    );
+    if (this._anyLineTooLong(diff)) {
+      fireAlert(
+        this,
+        `A line is longer than ${SYNTAX_MAX_LINE_LENGTH}.` +
+          ' Syntax Highlighting was turned off.'
+      );
+      return false;
+    }
+    if (this.$.diff.getDiffLength(diff) > SYNTAX_MAX_DIFF_LENGTH) {
+      fireAlert(
+        this,
+        `A diff is longer than ${SYNTAX_MAX_DIFF_LENGTH}.` +
+          ' Syntax Highlighting was turned off.'
+      );
+      return false;
+    }
+    return true;
   }
 
   /**
@@ -1205,6 +1067,10 @@
   _showNewlineWarningRight(diff?: DiffInfo) {
     return this._hasTrailingNewlines(diff, false) === false;
   }
+
+  _useNewContextControls() {
+    return this.flags.isEnabled(KnownExperimentId.NEW_CONTEXT_CONTROLS);
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
index aae7808..9921dd6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
@@ -24,7 +24,6 @@
     patch-range="[[patchRange]]"
     path="[[path]]"
     prefs="[[prefs]]"
-    project-name="[[projectName]]"
     display-line="[[displayLine]]"
     is-image-diff="[[isImageDiff]]"
     hidden$="[[hidden]]"
@@ -33,7 +32,6 @@
     view-mode="[[viewMode]]"
     line-of-interest="[[lineOfInterest]]"
     logged-in="[[_loggedIn]]"
-    loading="[[_loading]]"
     error-message="[[_errorMessage]]"
     base-image="[[_baseImage]]"
     revision-image="[[_revisionImage]]"
@@ -43,6 +41,7 @@
     diff="[[diff]]"
     show-newline-warning-left="[[_showNewlineWarningLeft(diff)]]"
     show-newline-warning-right="[[_showNewlineWarningRight(diff)]]"
+    use-new-context-controls="[[_useNewContextControls()]]"
   >
   </gr-diff>
   <gr-syntax-layer
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index ac96ba2..402cb52 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -20,8 +20,10 @@
 import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {sortComments} from '../gr-comment-api/gr-comment-api.js';
-import {Side} from '../../../constants/constants.js';
+import {sortComments, createCommentThreads} from '../../../utils/comment-util.js';
+import {Side, CommentSide} from '../../../constants/constants.js';
+import {createChange} from '../../../test/test-data-generators.js';
+import {FILE} from '../gr-diff/gr-diff-line.js';
 
 const basicFixture = fixtureFromElement('gr-diff-host');
 
@@ -54,214 +56,27 @@
     });
     test('plugin layers requested', () => {
       element.patchRange = {};
+      element.change = createChange();
       element.reload();
       assert(element.$.jsAPI.getDiffLayers.called);
     });
   });
 
-  suite('handle comment-update', () => {
-    setup(() => {
-      sinon.stub(element, '_commentsChanged');
-      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.dispatchEvent(
-          new CustomEvent('comment-update', {
-            detail: {comment},
-            composed: true, bubbles: true,
-          }));
-      assert.include(element.comments.left, comment);
-    });
-
-    test('discarding a draft', () => {
-      const draftID = 'tempID';
-      const id = 'savedID';
-      const comment = {
-        __draft: true,
-        __draftID: draftID,
-        side: 'PARENT',
-        __commentSide: 'left',
-      };
-      const diffCommentsModifiedStub = sinon.stub();
-      element.addEventListener('diff-comments-modified',
-          diffCommentsModifiedStub);
-      element.comments.left.push(comment);
-      comment.id = id;
-      element.dispatchEvent(
-          new CustomEvent('comment-discard', {
-            detail: {comment},
-            composed: true, bubbles: true,
-          }));
-      const drafts = element.comments.left
-          .filter(item => item.__draftID === draftID);
-      assert.equal(drafts.length, 0);
-      assert.isTrue(diffCommentsModifiedStub.called);
-    });
-
-    test('saving a draft', () => {
-      const draftID = 'tempID';
-      const id = 'savedID';
-      const comment = {
-        __draft: true,
-        __draftID: draftID,
-        side: 'PARENT',
-        __commentSide: 'left',
-      };
-      const diffCommentsModifiedStub = sinon.stub();
-      element.addEventListener('diff-comments-modified',
-          diffCommentsModifiedStub);
-      element.comments.left.push(comment);
-      comment.id = id;
-      element.dispatchEvent(
-          new CustomEvent('comment-save', {
-            detail: {comment},
-            composed: true, bubbles: true,
-          }));
-      const drafts = element.comments.left
-          .filter(item => item.__draftID === draftID);
-      assert.equal(drafts.length, 1);
-      assert.equal(drafts[0].id, id);
-      assert.isTrue(diffCommentsModifiedStub.called);
-    });
-  });
-
-  test('remove comment', () => {
-    sinon.stub(element, '_commentsChanged');
-    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'},
-      ],
-    };
-
-    // Using JSON.stringify because Safari 9.1 (11601.5.17.1) doesn’t seem
-    // to believe that one object deepEquals another even when they do :-/.
-    assert.equal(JSON.stringify(element.comments), JSON.stringify({
-      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'},
-      ],
-    }));
-
-    element._removeComment({id: 'bc2', side: 'PARENT',
-      __commentSide: 'left'});
-    assert.deepEqual(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: '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'},
-      ],
-    });
-
-    element._removeComment({id: 'd2', __commentSide: 'right'});
-    assert.deepEqual(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: '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'},
-      ],
-    });
-  });
-
   test('thread-discard handling', () => {
-    const threads = element._createThreads([
+    const threads = createCommentThreads([
       {
         id: 4711,
         __commentSide: 'left',
         updated: '2015-12-20 15:01:20.396000000',
+        patch_set: 1,
+        path: 'some/path',
       },
       {
         id: 42,
         __commentSide: 'left',
         updated: '2017-12-20 15:01:20.396000000',
+        patch_set: 1,
+        path: 'some/path',
       },
     ]);
     element._parentIndex = 1;
@@ -311,7 +126,7 @@
           'Diff Content Render'));
     });
 
-    test('ends total and syntax timer after syntax layer processing', done => {
+    test('ends total and syntax timer after syntax layer', async () => {
       sinon.stub(element.reporting, 'diffViewContentDisplayed');
       let notifySyntaxProcessed;
       sinon.stub(element.$.syntaxLayer, 'process').returns(new Promise(
@@ -321,44 +136,41 @@
       sinon.stub(element.$.restAPI, 'getDiff').returns(
           Promise.resolve({content: []}));
       element.patchRange = {};
+      element.change = createChange();
       element.$.restAPI.getDiffPreferences().then(prefs => {
         element.prefs = prefs;
         return element.reload(true);
       });
       // Multiple cascading microtasks are scheduled.
-      setTimeout(() => {
-        notifySyntaxProcessed();
-        // Assert after the notification task is processed.
-        Promise.resolve().then(() => {
-          assert.isTrue(element.reporting.timeEnd.calledWithExactly(
-              'Diff Total Render'));
-          assert.isTrue(element.reporting.timeEnd.calledWithExactly(
-              'Diff Syntax Render'));
-          assert.isTrue(element.reporting.diffViewContentDisplayed.called);
-          done();
-        });
-      });
+      await flush();
+      notifySyntaxProcessed();
+      // Multiple cascading microtasks are scheduled.
+      await flush();
+      assert.isTrue(element.reporting.timeEnd.calledWithExactly(
+          'Diff Total Render'));
+      assert.isTrue(element.reporting.timeEnd.calledWithExactly(
+          'Diff Syntax Render'));
+      assert.isTrue(element.reporting.diffViewContentDisplayed.called);
     });
 
-    test('ends total timer w/ no syntax layer processing', done => {
+    test('ends total timer w/ no syntax layer processing', async () => {
       sinon.stub(element.$.restAPI, 'getDiff').returns(
           Promise.resolve({content: []}));
       element.patchRange = {};
+      element.change = createChange();
       element.reload();
       // Multiple cascading microtasks are scheduled.
-      setTimeout(() => {
-        // Reporting can be called with other parameters (ex. PluginsLoaded),
-        // but only 'Diff Total Render' is important in this test.
-        assert.equal(
-            element.reporting.timeEnd.getCalls()
-                .filter(call => call.calledWithExactly('Diff Total Render'))
-                .length,
-            1);
-        done();
-      });
+      await flush();
+      // Reporting can be called with other parameters (ex. PluginsLoaded),
+      // but only 'Diff Total Render' is important in this test.
+      assert.equal(
+          element.reporting.timeEnd.getCalls()
+              .filter(call => call.calledWithExactly('Diff Total Render'))
+              .length,
+          1);
     });
 
-    test('completes reload promise after syntax layer processing', done => {
+    test('completes reload promise after syntax layer processing', async () => {
       let notifySyntaxProcessed;
       sinon.stub(element.$.syntaxLayer, 'process').returns(new Promise(
           resolve => {
@@ -367,6 +179,7 @@
       sinon.stub(element.$.restAPI, 'getDiff').returns(
           Promise.resolve({content: []}));
       element.patchRange = {};
+      element.change = createChange();
       let reloadComplete = false;
       element.$.restAPI.getDiffPreferences()
           .then(prefs => {
@@ -377,15 +190,12 @@
             reloadComplete = true;
           });
       // Multiple cascading microtasks are scheduled.
-      setTimeout(() => {
-        assert.isFalse(reloadComplete);
-        notifySyntaxProcessed();
-        // Assert after the notification task is processed.
-        setTimeout(() => {
-          assert.isTrue(reloadComplete);
-          done();
-        });
-      });
+      await flush();
+      assert.isFalse(reloadComplete);
+      notifySyntaxProcessed();
+      // Assert after the notification task is processed.
+      await flush();
+      assert.isTrue(reloadComplete);
     });
   });
 
@@ -395,6 +205,14 @@
     // Stub the network calls into requests that never resolve.
     sinon.stub(element, '_getDiff').callsFake(() => new Promise(() => {}));
     element.patchRange = {};
+    element.change = createChange();
+
+    // Needs to be set to something first for it to cancel.
+    element.diff = {
+      content: [{
+        a: ['foo'],
+      }],
+    };
 
     element.reload();
     assert.isTrue(cancelStub.called);
@@ -405,6 +223,7 @@
       getLoggedIn = false;
       element = basicFixture.instantiate();
       element.changeNum = 123;
+      element.change = createChange();
       element.path = 'some/path';
     });
 
@@ -468,9 +287,9 @@
 
     test('reload resolves on error', () => {
       const onErrStub = sinon.stub(element, '_handleGetDiffError');
-      const error = {ok: false, status: 500};
+      const error = new Response(null, {ok: false, status: 500});
       sinon.stub(element.$.restAPI, 'getDiff').callsFake(
-          (changeNum, basePatchNum, patchNum, path, onErr) => {
+          (changeNum, basePatchNum, patchNum, path, whitespace, onErr) => {
             onErr(error);
           });
       element.patchRange = {};
@@ -536,6 +355,7 @@
             );
 
         element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
+        element.change = createChange();
         element.comments = {
           left: [],
           right: [],
@@ -832,7 +652,7 @@
   test('delegates cancel()', () => {
     const stub = sinon.stub(element.$.diff, 'cancel');
     element.patchRange = {};
-    element.reload();
+    element.cancel();
     assert.isTrue(stub.calledOnce);
     assert.equal(stub.lastCall.args.length, 0);
   });
@@ -981,12 +801,6 @@
     assert.equal(element.$.diff.changeNum, 12345);
   });
 
-  test('passes in projectName', () => {
-    const value = 'Gerrit';
-    element.projectName = value;
-    assert.equal(element.$.diff.projectName, value);
-  });
-
   test('passes in displayLine', () => {
     const value = true;
     element.displayLine = value;
@@ -1160,6 +974,8 @@
         updated: '2015-12-23 15:00:20.396000000',
         line: 1,
         __commentSide: 'left',
+        patch_set: 1,
+        path: 'some/path',
       }, {
         id: 'jacks_reply',
         message: 'i like you, too',
@@ -1167,6 +983,8 @@
         __commentSide: 'left',
         line: 1,
         in_reply_to: 'sallys_confession',
+        patch_set: 1,
+        path: 'some/path',
       },
       {
         id: 'new_draft',
@@ -1174,25 +992,27 @@
         __commentSide: 'left',
         __draft: true,
         updated: '2015-12-20 15:01:20.396000000',
+        patch_set: 1,
+        path: 'some/path',
       },
     ];
 
-    const actualThreads = element._createThreads(comments);
+    const actualThreads = createCommentThreads(comments);
 
     assert.equal(actualThreads.length, 2);
 
-    assert.equal(actualThreads[0].commentSide, 'left');
+    assert.equal(actualThreads[0].diffSide, 'left');
     assert.equal(actualThreads[0].comments.length, 2);
     assert.deepEqual(actualThreads[0].comments[0], comments[0]);
     assert.deepEqual(actualThreads[0].comments[1], comments[1]);
-    assert.equal(actualThreads[0].patchNum, undefined);
-    assert.equal(actualThreads[0].lineNum, 1);
+    assert.equal(actualThreads[0].patchNum, 1);
+    assert.equal(actualThreads[0].line, 1);
 
-    assert.equal(actualThreads[1].commentSide, 'left');
+    assert.equal(actualThreads[1].diffSide, 'left');
     assert.equal(actualThreads[1].comments.length, 1);
     assert.deepEqual(actualThreads[1].comments[0], comments[2]);
-    assert.equal(actualThreads[1].patchNum, undefined);
-    assert.equal(actualThreads[1].lineNum, undefined);
+    assert.equal(actualThreads[1].patchNum, 1);
+    assert.equal(actualThreads[1].line, FILE);
   });
 
   test('_createThreads inherits patchNum and range', () => {
@@ -1207,15 +1027,20 @@
         end_character: 2,
       },
       patch_set: 5,
+      path: '/p',
       __commentSide: 'left',
       line: 1,
     }];
 
     const expectedThreads = [
       {
-        commentSide: 'left',
+        diffSide: 'left',
+        commentSide: CommentSide.REVISION,
+        path: '/p',
+        rootId: 'betsys_confession',
         comments: [{
           id: 'betsys_confession',
+          path: '/p',
           message: 'i like you, jack',
           updated: '2015-12-24 15:00:10.396000000',
           range: {
@@ -1235,13 +1060,12 @@
           end_line: 1,
           end_character: 2,
         },
-        lineNum: 1,
-        isOnParent: false,
+        line: 1,
       },
     ];
 
     assert.deepEqual(
-        element._createThreads(comments),
+        createCommentThreads(comments),
         expectedThreads);
   });
 
@@ -1253,55 +1077,31 @@
             message: 'i like you, jack',
             updated: '2015-12-23 15:00:20.396000000',
             __commentSide: 'left',
+            path: '/p',
           }, {
             id: 'jacks_reply',
             message: 'i like you, too',
             updated: '2015-12-24 15:01:20.396000000',
             __commentSide: 'left',
+            path: '/p',
           },
         ];
-        assert.equal(element._createThreads(comments).length, 2);
-      });
-
-  test('_createThreads derives isOnParent using  side from first comment',
-      () => {
-        const 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:01:20.396000000',
-            __commentSide: 'left',
-            in_reply_to: 'sallys_confession',
-          },
-        ];
-
-        assert.equal(element._createThreads(comments)[0].isOnParent, false);
-
-        comments[0].side = 'REVISION';
-        assert.equal(element._createThreads(comments)[0].isOnParent, false);
-
-        comments[0].side = 'PARENT';
-        assert.equal(element._createThreads(comments)[0].isOnParent, true);
+        assert.equal(createCommentThreads(comments).length, 2);
       });
 
   test('_getOrCreateThread', () => {
-    const commentSide = 'left';
+    const diffSide = 'left';
+    const commentSide = CommentSide.PARENT;
 
     assert.isOk(element._getOrCreateThread('2', 3,
-        commentSide, undefined, false));
+        diffSide, commentSide, '/p'));
 
     let threads = dom(element.$.diff)
         .queryDistributedElements('gr-comment-thread');
 
     assert.equal(threads.length, 1);
-    assert.equal(threads[0].commentSide, commentSide);
+    assert.equal(threads[0].commentSide, diffSide);
     assert.equal(threads[0].range, undefined);
-    assert.equal(threads[0].isOnParent, false);
     assert.equal(threads[0].patchNum, 2);
 
     // Try to fetch a thread with a different range.
@@ -1313,63 +1113,62 @@
     };
 
     assert.isOk(element._getOrCreateThread(
-        '3', 1, commentSide, range, true));
+        '3', 1, diffSide, commentSide, '/p', range));
 
     threads = dom(element.$.diff)
         .queryDistributedElements('gr-comment-thread');
 
     assert.equal(threads.length, 2);
-    assert.equal(threads[1].commentSide, commentSide);
+    assert.equal(threads[1].commentSide, diffSide);
     assert.equal(threads[1].range, range);
-    assert.equal(threads[1].isOnParent, true);
     assert.equal(threads[1].patchNum, 3);
   });
 
-  test('thread should use old file path if first created' +
+  test('thread should use old file path if first created ' +
    'on patch set (left) before renaming', () => {
-    const commentSide = 'left';
+    const diffSide = 'left';
     element.file = {basePath: 'file_renamed.txt', path: element.path};
 
     assert.isOk(element._getOrCreateThread('2', 3,
-        commentSide, undefined, /* isOnParent= */ false));
+        diffSide, CommentSide.REVISION, '/p'));
 
     const threads = dom(element.$.diff)
         .queryDistributedElements('gr-comment-thread');
 
     assert.equal(threads.length, 1);
-    assert.equal(threads[0].commentSide, commentSide);
+    assert.equal(threads[0].commentSide, diffSide);
     assert.equal(threads[0].path, element.file.basePath);
   });
 
   test('thread should use new file path if first created' +
    'on patch set (right) after renaming', () => {
-    const commentSide = 'right';
+    const diffSide = 'right';
     element.file = {basePath: 'file_renamed.txt', path: element.path};
 
     assert.isOk(element._getOrCreateThread('2', 3,
-        commentSide, undefined, /* isOnParent= */ false));
+        diffSide, CommentSide.REVISION, '/p'));
 
     const threads = dom(element.$.diff)
         .queryDistributedElements('gr-comment-thread');
 
     assert.equal(threads.length, 1);
-    assert.equal(threads[0].commentSide, commentSide);
+    assert.equal(threads[0].commentSide, diffSide);
     assert.equal(threads[0].path, element.file.path);
   });
 
   test('thread should use new file path if first created' +
    'on patch set (left) but is base', () => {
-    const commentSide = 'left';
+    const diffSide = 'left';
     element.file = {basePath: 'file_renamed.txt', path: element.path};
 
     assert.isOk(element._getOrCreateThread('2', 3,
-        commentSide, undefined, /* isOnParent= */ true));
+        diffSide, CommentSide.PARENT, '/p', undefined));
 
     const threads = dom(element.$.diff)
         .queryDistributedElements('gr-comment-thread');
 
     assert.equal(threads.length, 1);
-    assert.equal(threads[0].commentSide, commentSide);
+    assert.equal(threads[0].commentSide, diffSide);
     assert.equal(threads[0].path, element.file.path);
   });
 
@@ -1404,8 +1203,6 @@
     r5.setAttribute('comment-side', 'right');
 
     const threadEls = [l3, l5, r3, r5];
-    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line),
-        [l3, r5]);
     assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
         Side.LEFT), [l3]);
     assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
@@ -1424,10 +1221,6 @@
     r.setAttribute('line-num', 'FILE');
 
     const threadEls = [l, r];
-    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line),
-        [l, r]);
-    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-        Side.BOTH), [l, r]);
     assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
         Side.LEFT), [l]);
     assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
@@ -1446,6 +1239,7 @@
       element.patchRange = {};
       element.prefs = prefs;
       element.changeNum = 123;
+      element.change = createChange();
       element.path = 'some/path';
     });
 
@@ -1473,18 +1267,16 @@
       assert.isFalse(element.$.syntaxLayer.enabled);
     });
 
-    test('starts syntax layer processing on render event', done => {
+    test('starts syntax layer processing on render event', async () => {
       sinon.stub(element.$.syntaxLayer, 'process')
           .returns(Promise.resolve());
       sinon.stub(element.$.restAPI, 'getDiff').returns(
           Promise.resolve({content: []}));
       element.reload();
-      setTimeout(() => {
-        element.dispatchEvent(
-            new CustomEvent('render', {bubbles: true, composed: true}));
-        assert.isTrue(element.$.syntaxLayer.process.called);
-        done();
-      });
+      await flush();
+      element.dispatchEvent(
+          new CustomEvent('render', {bubbles: true, composed: true}));
+      assert.isTrue(element.$.syntaxLayer.process.called);
     });
   });
 
@@ -1502,6 +1294,7 @@
         }],
       };
       element.patchRange = {};
+      element.change = createChange();
       element.prefs = prefs;
     });
 
@@ -1530,8 +1323,8 @@
     setup(() => {
       notifyStub = sinon.stub();
       stub('gr-js-api-interface', {
-        getCoverageAnnotationApi() {
-          return Promise.resolve({
+        getCoverageAnnotationApis() {
+          return Promise.resolve([{
             notify: notifyStub,
             getCoverageProvider() {
               return () => Promise.resolve([
@@ -1553,11 +1346,12 @@
                 },
               ]);
             },
-          });
+          }]);
         },
       });
       element = basicFixture.instantiate();
       element.changeNum = 123;
+      element.change = createChange();
       element.path = 'some/path';
       const prefs = {
         line_length: 10,
@@ -1574,10 +1368,10 @@
       element.prefs = prefs;
     });
 
-    test('getCoverageAnnotationApi should be called', done => {
+    test('getCoverageAnnotationApis should be called', done => {
       element.reload();
       flush(() => {
-        assert.isTrue(element.$.jsAPI.getCoverageAnnotationApi.calledOnce);
+        assert.isTrue(element.$.jsAPI.getCoverageAnnotationApis.calledOnce);
         done();
       });
     });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
index c66af58..8829afc 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
@@ -26,7 +26,7 @@
 import {GrDiffPreferences} from '../../shared/gr-diff-preferences/gr-diff-preferences';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {DiffPreferencesInfo} from '../../../types/common';
+import {DiffPreferencesInfo} from '../../../types/diff';
 
 export interface GrDiffPreferencesDialog {
   $: {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts
index 9c942a3..787fe30 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts
@@ -48,27 +48,32 @@
     }
   </style>
   <gr-overlay id="diffPrefsOverlay" with-backdrop="">
-    <div class$="diffHeader [[_computeHeaderClass(_diffPrefsChanged)]]">
-      Diff Preferences
-    </div>
-    <gr-diff-preferences
-      id="diffPreferences"
-      diff-prefs="{{_editableDiffPrefs}}"
-      has-unsaved-changes="{{_diffPrefsChanged}}"
-    ></gr-diff-preferences>
-    <div class="diffActions">
-      <gr-button id="cancelButton" link="" on-click="_handleCancelDiff">
-        Cancel
-      </gr-button>
-      <gr-button
-        id="saveButton"
-        link=""
-        primary=""
-        on-click="_handleSaveDiffPreferences"
-        disabled$="[[!_diffPrefsChanged]]"
+    <div role="dialog" aria-labelledby="diffPreferencesTitle">
+      <h1
+        class$="diffHeader [[_computeHeaderClass(_diffPrefsChanged)]]"
+        id="diffPreferencesTitle"
       >
-        Save
-      </gr-button>
+        Diff Preferences
+      </h1>
+      <gr-diff-preferences
+        id="diffPreferences"
+        diff-prefs="{{_editableDiffPrefs}}"
+        has-unsaved-changes="{{_diffPrefsChanged}}"
+      ></gr-diff-preferences>
+      <div class="diffActions">
+        <gr-button id="cancelButton" link="" on-click="_handleCancelDiff">
+          Cancel
+        </gr-button>
+        <gr-button
+          id="saveButton"
+          link=""
+          primary=""
+          on-click="_handleSaveDiffPreferences"
+          disabled$="[[!_diffPrefsChanged]]"
+        >
+          Save
+        </gr-button>
+      </div>
     </div>
   </gr-overlay>
 `;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts
index 39e12ca..77b5499 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts
@@ -30,7 +30,7 @@
 } from '../gr-diff/gr-diff-group';
 import {CancelablePromise, util} from '../../../scripts/util';
 import {customElement, property} from '@polymer/decorators';
-import {DiffContent} from '../../../types/common';
+import {DiffContent} from '../../../types/diff';
 import {Side} from '../../../constants/constants';
 
 const WHOLE_FILE = -1;
@@ -274,7 +274,7 @@
   }
 
   _isCollapsibleChunk(chunk: DiffContent) {
-    return (chunk.ab || chunk.common) && !chunk.keyLocation;
+    return (chunk.ab || chunk.common || chunk.skip) && !chunk.keyLocation;
   }
 
   /**
@@ -307,8 +307,10 @@
       state.lineNums.right + 1
     );
 
-    if (this.context !== WHOLE_FILE) {
-      const hiddenStart = state.chunkIndex === 0 ? 0 : this.context;
+    const hasSkippedGroup = !!groups.find(g => g.skip);
+    if (this.context !== WHOLE_FILE || hasSkippedGroup) {
+      const contextNumLines = this.context > 0 ? this.context : 0;
+      const hiddenStart = state.chunkIndex === 0 ? 0 : contextNumLines;
       const hiddenEnd =
         lineCount -
         (firstUncollapsibleChunkIndex === chunks.length ? 0 : this.context);
@@ -326,7 +328,11 @@
   }
 
   _commonChunkLength(chunk: DiffContent) {
+    if (chunk.skip) {
+      return chunk.skip;
+    }
     console.assert(!!chunk.ab || !!chunk.common);
+
     console.assert(
       !chunk.a || (!!chunk.b && chunk.a.length === chunk.b.length),
       'common chunk needs same number of a and b lines: ',
@@ -354,13 +360,22 @@
     offsetLeft: number,
     offsetRight: number
   ): GrDiffGroup {
-    const type = chunk.ab ? GrDiffGroupType.BOTH : GrDiffGroupType.DELTA;
+    const type =
+      chunk.ab || chunk.skip ? GrDiffGroupType.BOTH : GrDiffGroupType.DELTA;
     const lines = this._linesFromChunk(chunk, offsetLeft, offsetRight);
     const group = new GrDiffGroup(type, lines);
     group.keyLocation = !!chunk.keyLocation;
     group.dueToRebase = !!chunk.due_to_rebase;
-    group.dueToMove = !!chunk.due_to_move;
+    group.moveDetails =
+      chunk.move_details || (chunk.due_to_move ? {changed: false} : undefined);
+    group.skip = chunk.skip;
     group.ignoredWhitespaceOnly = !!chunk.common;
+    if (chunk.skip) {
+      group.lineRange = {
+        left: {start: offsetLeft, end: offsetLeft + chunk.skip - 1},
+        right: {start: offsetRight, end: offsetRight + chunk.skip - 1},
+      };
+    }
     return group;
   }
 
@@ -497,7 +512,7 @@
 
     for (const chunk of chunks) {
       // If it isn't a common chunk, append it as-is and update line numbers.
-      if (!chunk.ab && !chunk.common) {
+      if (!chunk.ab && !chunk.skip && !chunk.common) {
         if (chunk.a) {
           leftLineNum += chunk.a.length;
         }
@@ -522,7 +537,13 @@
       leftLineNum += numLines;
       rightLineNum += numLines;
 
-      if (chunk.ab) {
+      if (chunk.skip) {
+        result.push({
+          ...chunk,
+          skip: chunk.skip,
+          keyLocation: false,
+        });
+      } else if (chunk.ab) {
         result.push(
           ...this._splitAtChunkEnds(chunk.ab, chunkEnds).map(
             ({lines, keyLocation}) => {
@@ -680,6 +701,9 @@
       if (chunk.due_to_move) {
         subChunk.due_to_move = true;
       }
+      if (chunk.move_details) {
+        subChunk.move_details = chunk.move_details;
+      }
       return subChunk;
     });
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
index 3a074bb..ce7a3c4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
@@ -171,6 +171,56 @@
         });
       });
 
+      test('at the beginning with skip chunks', async () => {
+        element.context = 10;
+        const content = [
+          {ab: new Array(20)
+              .fill('all work and no play make jack a dull boy')},
+          {skip: 43900},
+          {ab: new Array(30)
+              .fill('some other content')},
+          {a: ['some other content']},
+        ];
+
+        await element.process(content);
+
+        const groups = element.groups;
+
+        // group[0] is the file group
+
+        const commonGroup = groups[1];
+
+        // Hidden context before
+        assert.equal(commonGroup.type, GrDiffGroupType.CONTEXT_CONTROL);
+        assert.instanceOf(commonGroup.contextGroups[0], GrDiffGroup);
+        assert.equal(commonGroup.contextGroups[0].lines.length, 20);
+        for (const l of commonGroup.contextGroups[0].lines) {
+          assert.equal(l.text, 'all work and no play make jack a dull boy');
+        }
+
+        // Skipped group
+        const skipGroup = commonGroup.contextGroups[1];
+        assert.equal(skipGroup.skip, 43900);
+        const expectedRange = {
+          left: {start: 21, end: 43920},
+          right: {start: 21, end: 43920},
+        };
+        assert.deepEqual(skipGroup.lineRange, expectedRange);
+
+        // Hidden context after
+        assert.equal(commonGroup.contextGroups[2].lines.length, 20);
+        for (const l of commonGroup.contextGroups[2].lines) {
+          assert.equal(l.text, 'some other content');
+        }
+
+        // Displayed lines
+        assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[2].lines.length, 10);
+        for (const l of groups[2].lines) {
+          assert.equal(l.text, 'some other content');
+        }
+      });
+
       test('at the beginning, smaller than context', () => {
         element.context = 10;
         const content = [
@@ -414,6 +464,55 @@
       });
     });
 
+    test('in the middle with skip chunks', async () => {
+      element.context = 10;
+      const content = [
+        {a: ['all work and no play make andybons a dull boy']},
+        {ab: new Array(20)
+            .fill('all work and no play make jill a dull girl')},
+        {skip: 60},
+        {ab: new Array(20)
+            .fill('all work and no play make jill a dull girl')},
+        {a: ['all work and no play make andybons a dull boy']},
+      ];
+
+      await element.process(content);
+
+      const groups = element.groups;
+
+      // group[0] is the file group
+      // group[1] is the chunk with a
+      // group[2] is the displayed part of ab before
+
+      const commonGroup = groups[3];
+
+      // Hidden context before
+      assert.equal(commonGroup.type, GrDiffGroupType.CONTEXT_CONTROL);
+      assert.instanceOf(commonGroup.contextGroups[0], GrDiffGroup);
+      assert.equal(commonGroup.contextGroups[0].lines.length, 10);
+      for (const l of commonGroup.contextGroups[0].lines) {
+        assert.equal(
+            l.text, 'all work and no play make jill a dull girl');
+      }
+
+      // Skipped group
+      const skipGroup = commonGroup.contextGroups[1];
+      assert.equal(skipGroup.skip, 60);
+      const expectedRange = {
+        left: {start: 22, end: 81},
+        right: {start: 21, end: 80},
+      };
+      assert.deepEqual(skipGroup.lineRange, expectedRange);
+
+      // Hidden context after
+      assert.equal(commonGroup.contextGroups[2].lines.length, 10);
+      for (const l of commonGroup.contextGroups[2].lines) {
+        assert.equal(
+            l.text, 'all work and no play make jill a dull girl');
+      }
+      // group[4] is the displayed part of the second ab
+    });
+
     test('break up common diff chunks', () => {
       element.keyLocations = {
         left: {1: true},
@@ -671,6 +770,51 @@
             state.lineNums.right + rows.length);
       });
 
+      test('WHOLE_FILE with skip chunks still get collapsed', () => {
+        element.context = WHOLE_FILE;
+        const lineNums = {left: 10, right: 100};
+        const state = {
+          lineNums,
+          chunkIndex: 1,
+        };
+        const skip = 10000;
+        const chunks = [
+          {a: ['foo']},
+          {skip},
+          {ab: rows},
+          {a: ['bar']},
+        ];
+        const result = element._processNext(state, chunks);
+        // Results in one, uncollapsed group with all rows.
+        assert.equal(result.groups.length, 1);
+        assert.equal(result.groups[0].type, GrDiffGroupType.CONTEXT_CONTROL);
+
+        // Skip and ab group are hidden in the same context control
+        assert.equal(result.groups[0].contextGroups.length, 2);
+        const [skippedGroup, abGroup] = result.groups[0].contextGroups;
+
+        // Line numbers are set correctly.
+        assert.deepEqual(
+            skippedGroup.lineRange,
+            {
+              left: {start: lineNums.left + 1, end: lineNums.left + skip},
+              right: {start: lineNums.right + 1, end: lineNums.right + skip},
+            });
+
+        assert.deepEqual(
+            abGroup.lineRange,
+            {
+              left: {
+                start: lineNums.left + skip + 1,
+                end: lineNums.left + skip + rows.length,
+              },
+              right: {
+                start: lineNums.right + skip + 1,
+                end: lineNums.right + skip + rows.length,
+              },
+            });
+      });
+
       test('with context', () => {
         element.context = 10;
         const state = {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts
index fab2b59..03740ba 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts
@@ -27,7 +27,7 @@
 } from '../gr-diff-highlight/gr-range-normalizer';
 import {descendedFromClass, querySelectorAll} from '../../../utils/dom-util';
 import {customElement, property, observe} from '@polymer/decorators';
-import {DiffInfo} from '../../../types/common';
+import {DiffInfo} from '../../../types/diff';
 import {Side} from '../../../constants/constants';
 import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
 
@@ -241,13 +241,13 @@
         range.endContainer.classList.contains('right'));
     const startLineDataValue = startLineEl.getAttribute('data-value');
     if (!startLineDataValue) return;
-    const startLineNum = parseInt(startLineDataValue, 10);
+    const startLineNum = Number(startLineDataValue);
     let endLineNum;
     if (endsAtOtherEmptySide) {
       endLineNum = startLineNum + 1;
     } else if (endLineEl) {
       const endLineDataValue = endLineEl.getAttribute('data-value');
-      if (endLineDataValue) endLineNum = parseInt(endLineDataValue, 10);
+      if (endLineDataValue) endLineNum = Number(endLineDataValue);
     }
 
     return this._getRangeFromDiff(
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
deleted file mode 100644
index 57a9e97..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ /dev/null
@@ -1,1592 +0,0 @@
-/**
- * @license
- * 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.
- */
-import '@polymer/iron-dropdown/iron-dropdown.js';
-import '@polymer/iron-input/iron-input.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-dropdown/gr-dropdown.js';
-import '../../shared/gr-dropdown-list/gr-dropdown-list.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-select/gr-select.js';
-import '../../shared/revision-info/revision-info.js';
-import '../gr-comment-api/gr-comment-api.js';
-import '../gr-diff-cursor/gr-diff-cursor.js';
-import '../gr-apply-fix-dialog/gr-apply-fix-dialog.js';
-import '../gr-diff-host/gr-diff-host.js';
-import '../gr-diff-mode-selector/gr-diff-mode-selector.js';
-import '../gr-diff-preferences-dialog/gr-diff-preferences-dialog.js';
-import '../gr-patch-range-select/gr-patch-range-select.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-diff-view_html.js';
-import {KeyboardShortcutMixin, Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {RevisionInfo} from '../../shared/revision-info/revision-info.js';
-import {appContext} from '../../../services/app-context.js';
-import {
-  computeAllPatchSets,
-  computeLatestPatchNum,
-  patchNumEquals,
-  SPECIAL_PATCH_SET_NUM,
-} from '../../../utils/patch-set-util.js';
-import {
-  addUnmodifiedFiles, computeDisplayPath, computeTruncatedPath,
-  isMagicPath, specialFilePathCompare,
-} from '../../../utils/path-list-util.js';
-import {changeBaseURL, changeIsOpen} from '../../../utils/change-util.js';
-
-const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
-const MSG_LOADING_BLAME = 'Loading blame...';
-const MSG_LOADED_BLAME = 'Blame loaded';
-
-const PARENT = 'PARENT';
-
-const DiffSides = {
-  LEFT: 'left',
-  RIGHT: 'right',
-};
-
-const DiffViewMode = {
-  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-  UNIFIED: 'UNIFIED_DIFF',
-};
-
-/**
- * @extends PolymerElement
- */
-class GrDiffView extends KeyboardShortcutMixin(
-    GestureEventListeners(LegacyElementMixin(PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-diff-view'; }
-  /**
-   * Fired when the title of the page should change.
-   *
-   * @event title-change
-   */
-
-  /**
-   * Fired when user tries to navigate away while comments are pending save.
-   *
-   * @event show-alert
-   */
-
-  static get properties() {
-    return {
-    /**
-     * URL params passed from the router.
-     */
-      params: {
-        type: Object,
-        observer: '_paramsChanged',
-      },
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-      /**
-       * @type {{ diffMode: (string|undefined) }}
-       */
-      changeViewState: {
-        type: Object,
-        notify: true,
-        value() { return {}; },
-        observer: '_changeViewStateChanged',
-      },
-      disableDiffPrefs: {
-        type: Boolean,
-        value: false,
-      },
-      _diffPrefsDisabled: {
-        type: Boolean,
-        computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
-      },
-      /** @type {?} */
-      _patchRange: Object,
-      /** @type {?} */
-      _commitRange: Object,
-      /**
-       * @type {{
-       *  subject: string,
-       *  project: string,
-       *  revisions: string,
-       * }}
-       */
-      _change: Object,
-      /** @type {?} */
-      _changeComments: Object,
-      _changeNum: String,
-      /**
-       * This is a DiffInfo object.
-       * This is retrieved and owned by a child component.
-       */
-      _diff: Object,
-      // An array specifically formatted to be used in a gr-dropdown-list
-      // element for selected a file to view.
-      _formattedFiles: {
-        type: Array,
-        computed: '_formatFilesForDropdown(_files, ' +
-          '_patchRange.patchNum, _changeComments)',
-      },
-      // An sorted array of files, as returned by the rest API.
-      _fileList: {
-        type: Array,
-        computed: '_getSortedFileList(_files)',
-      },
-      /**
-       * Contains information about files as returned by the rest API.
-       *
-       * @type {{ sortedFileList: Array<string>, changeFilesByPath: Object }}
-       */
-      _files: {
-        type: Object,
-        value() { return {sortedFileList: [], changeFilesByPath: {}}; },
-      },
-
-      /** @type {Gerrit.FileRange} */
-      _file: {
-        type: Object,
-        computed: '_getCurrentFile(_files, _path)',
-      },
-
-      _path: {
-        type: String,
-        observer: '_pathChanged',
-      },
-      _fileNum: {
-        type: Number,
-        computed: '_computeFileNum(_path, _formattedFiles)',
-      },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-      },
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _prefs: Object,
-      _projectConfig: Object,
-      _userPrefs: Object,
-      _diffMode: {
-        type: String,
-        computed: '_getDiffViewMode(changeViewState.diffMode, _userPrefs)',
-      },
-      _isImageDiff: Boolean,
-      // The return type is FilesWebLinks from gr-patch-range-select.
-      _filesWeblinks: Object,
-
-      /**
-       * Map of paths in the current change and patch range that have comments
-       * or drafts or robot comments.
-       */
-      _commentMap: Object,
-
-      _commentsForDiff: Object,
-
-      /**
-       * Object to contain the path of the next and previous file in the current
-       * change and patch range that has comments.
-       */
-      _commentSkips: {
-        type: Object,
-        computed: '_computeCommentSkips(_commentMap, _fileList, _path)',
-      },
-      _editMode: {
-        type: Boolean,
-        computed: '_computeEditMode(_patchRange.*)',
-      },
-      _isBlameLoaded: Boolean,
-      _isBlameLoading: {
-        type: Boolean,
-        value: false,
-      },
-      _allPatchSets: {
-        type: Array,
-        computed: '_computeAllPatchSets(_change, _change.revisions.*)',
-      },
-      _revisionInfo: {
-        type: Object,
-        computed: '_getRevisionInfo(_change)',
-      },
-      _reviewedFiles: {
-        type: Object,
-        value: () => new Set(),
-      },
-      // line number on the diff which should be scrolled to upon loading
-      _focusLineNum: Number,
-    };
-  }
-
-  static get observers() {
-    return [
-      '_getProjectConfig(_change.project)',
-      '_getFiles(_changeNum, _patchRange.*, _changeComments)',
-      '_setReviewedObserver(_loggedIn, params.*, _prefs, _patchRange.*)',
-      '_recomputeComments(_files.changeFilesByPath,' +
-      '_path, _patchRange, _projectConfig)',
-    ];
-  }
-
-  get keyBindings() {
-    return {
-      esc: '_handleEscKey',
-    };
-  }
-
-  keyboardShortcuts() {
-    return {
-      [Shortcut.LEFT_PANE]: '_handleLeftPane',
-      [Shortcut.RIGHT_PANE]: '_handleRightPane',
-      [Shortcut.NEXT_LINE]: '_handleNextLineOrFileWithComments',
-      [Shortcut.PREV_LINE]: '_handlePrevLineOrFileWithComments',
-      [Shortcut.VISIBLE_LINE]: '_handleVisibleLine',
-      [Shortcut.NEXT_FILE_WITH_COMMENTS]:
-          '_handleNextLineOrFileWithComments',
-      [Shortcut.PREV_FILE_WITH_COMMENTS]:
-          '_handlePrevLineOrFileWithComments',
-      [Shortcut.NEW_COMMENT]: '_handleNewComment',
-      [Shortcut.SAVE_COMMENT]: null, // DOC_ONLY binding
-      [Shortcut.NEXT_FILE]: '_handleNextFile',
-      [Shortcut.PREV_FILE]: '_handlePrevFile',
-      [Shortcut.NEXT_CHUNK]: '_handleNextChunkOrCommentThread',
-      [Shortcut.NEXT_COMMENT_THREAD]: '_handleNextChunkOrCommentThread',
-      [Shortcut.PREV_CHUNK]: '_handlePrevChunkOrCommentThread',
-      [Shortcut.PREV_COMMENT_THREAD]: '_handlePrevChunkOrCommentThread',
-      [Shortcut.OPEN_REPLY_DIALOG]:
-          '_handleOpenReplyDialogOrToggleLeftPane',
-      [Shortcut.TOGGLE_LEFT_PANE]:
-          '_handleOpenReplyDialogOrToggleLeftPane',
-      [Shortcut.OPEN_DOWNLOAD_DIALOG]:
-          '_handleOpenDownloadDialog',
-      [Shortcut.UP_TO_CHANGE]: '_handleUpToChange',
-      [Shortcut.OPEN_DIFF_PREFS]: '_handleCommaKey',
-      [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
-      [Shortcut.TOGGLE_FILE_REVIEWED]: '_throttledToggleFileReviewed',
-      [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_handleExpandAllDiffContext',
-      [Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
-      [Shortcut.TOGGLE_BLAME]: '_handleToggleBlame',
-      [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
-          '_handleToggleHideAllCommentThreads',
-      [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
-      [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
-      [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
-      [Shortcut.DIFF_RIGHT_AGAINST_LATEST]:
-        '_handleDiffRightAgainstLatest',
-      [Shortcut.DIFF_BASE_AGAINST_LATEST]:
-        '_handleDiffBaseAgainstLatest',
-
-      // Final two are actually handled by gr-comment-thread.
-      [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
-      [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
-    };
-  }
-
-  constructor() {
-    super();
-    this.reporting = appContext.reportingService;
-    this.flagsService = appContext.flagsService;
-  }
-
-  connectedCallback() {
-    super.connectedCallback();
-    this._throttledToggleFileReviewed = this._throttleWrap(e =>
-      this._handleToggleFileReviewed(e));
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
-    });
-
-    this.addEventListener('open-fix-preview',
-        e => this._onOpenFixPreview(e));
-    this.$.cursor.push('diffs', this.$.diffHost);
-    this._onRenderHandler = () => {
-      this.$.cursor.reInitCursor();
-    };
-    this.$.diffHost.addEventListener('render', this._onRenderHandler);
-  }
-
-  detached() {
-    this.$.diffHost.removeEventListener('render', this._onRenderHandler);
-  }
-
-  _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
-  }
-
-  _getProjectConfig(project) {
-    if (!project) return;
-    return this.$.restAPI.getProjectConfig(project).then(
-        config => {
-          this._projectConfig = config;
-        });
-  }
-
-  _getChangeDetail(changeNum) {
-    return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
-      this._change = change;
-      return change;
-    });
-  }
-
-  _getChangeEdit(changeNum) {
-    return this.$.restAPI.getChangeEdit(this._changeNum);
-  }
-
-  _getSortedFileList(files) {
-    if (!files) return [];
-    return files.sortedFileList;
-  }
-
-  /**
-   * @param {!Object} files
-   * @param {string} path
-   * @returns {!Gerrit.FileRange}
-   */
-  _getCurrentFile(files, path) {
-    if ([files, path].includes(undefined)) return;
-    const fileInfo = files.changeFilesByPath[path];
-    const fileRange = {path};
-    if (fileInfo && fileInfo.old_path) {
-      fileRange.basePath = fileInfo.old_path;
-    }
-    return fileRange;
-  }
-
-  _getFiles(changeNum, patchRangeRecord, changeComments) {
-    // Polymer 2: check for undefined
-    if ([changeNum, patchRangeRecord, patchRangeRecord.base, changeComments]
-        .some(arg => arg === undefined)) {
-      return Promise.resolve();
-    }
-
-    if (!patchRangeRecord.base.patchNum) {
-      return Promise.resolve();
-    }
-
-    const patchRange = patchRangeRecord.base;
-    return this.$.restAPI.getChangeFiles(
-        changeNum, patchRange).then(changeFiles => {
-      if (!changeFiles) return;
-      const commentedPaths = changeComments.getPaths(patchRange);
-      const files = {...changeFiles};
-      addUnmodifiedFiles(files, commentedPaths);
-      this._files = {
-        sortedFileList: Object.keys(files).sort(specialFilePathCompare),
-        changeFilesByPath: files,
-      };
-    });
-  }
-
-  _getDiffPreferences() {
-    return this.$.restAPI.getDiffPreferences().then(prefs => {
-      this._prefs = prefs;
-    });
-  }
-
-  _getPreferences() {
-    return this.$.restAPI.getPreferences();
-  }
-
-  _getWindowWidth() {
-    return window.innerWidth;
-  }
-
-  _handleReviewedChange(e) {
-    this._setReviewed(dom(e).rootTarget.checked);
-  }
-
-  _setReviewed(reviewed) {
-    if (this._editMode) { return; }
-    this.$.reviewed.checked = reviewed;
-    if (!this._patchRange.patchNum) return;
-    this._saveReviewedState(reviewed).catch(err => {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {message: ERR_REVIEW_STATUS},
-        composed: true, bubbles: true,
-      }));
-      throw err;
-    });
-  }
-
-  _saveReviewedState(reviewed) {
-    return this.$.restAPI.saveFileReviewed(this._changeNum,
-        this._patchRange.patchNum, this._path, reviewed);
-  }
-
-  _handleToggleFileReviewed(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this._setReviewed(!this.$.reviewed.checked);
-  }
-
-  _handleEscKey(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this.$.diffHost.displayLine = false;
-  }
-
-  _handleLeftPane(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-    e.preventDefault();
-    this.$.cursor.moveLeft();
-  }
-
-  _handleRightPane(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-    e.preventDefault();
-    this.$.cursor.moveRight();
-  }
-
-  _handlePrevLineOrFileWithComments(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    if (e.detail.keyboardEvent.shiftKey &&
-        e.detail.keyboardEvent.keyCode === 75) { // 'K'
-      this._moveToPreviousFileWithComment();
-      return;
-    }
-    if (this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this.$.diffHost.displayLine = true;
-    this.$.cursor.moveUp();
-  }
-
-  _handleVisibleLine(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-    e.preventDefault();
-    this.$.cursor.moveToVisibleArea();
-  }
-
-  _onOpenFixPreview(e) {
-    this.$.applyFixDialog.open(e);
-  }
-
-  _handleNextLineOrFileWithComments(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    if (e.detail.keyboardEvent.shiftKey &&
-        e.detail.keyboardEvent.keyCode === 74) { // 'J'
-      this._moveToNextFileWithComment();
-      return;
-    }
-    if (this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this.$.diffHost.displayLine = true;
-    this.$.cursor.moveDown();
-  }
-
-  _moveToPreviousFileWithComment() {
-    if (!this._commentSkips) { return; }
-
-    // If there is no previous diff with comments, then return to the change
-    // view.
-    if (!this._commentSkips.previous) {
-      this._navToChangeView();
-      return;
-    }
-
-    GerritNav.navigateToDiff(this._change, this._commentSkips.previous,
-        this._patchRange.patchNum, this._patchRange.basePatchNum);
-  }
-
-  _moveToNextFileWithComment() {
-    if (!this._commentSkips) { return; }
-
-    // If there is no next diff with comments, then return to the change view.
-    if (!this._commentSkips.next) {
-      this._navToChangeView();
-      return;
-    }
-
-    GerritNav.navigateToDiff(this._change, this._commentSkips.next,
-        this._patchRange.patchNum, this._patchRange.basePatchNum);
-  }
-
-  _handleNewComment(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-    e.preventDefault();
-    this.$.cursor.createCommentInPlace();
-  }
-
-  _handlePrevFile(e) {
-    // Check for meta key to avoid overriding native chrome shortcut.
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.getKeyboardEvent(e).metaKey) { return; }
-
-    e.preventDefault();
-    this._navToFile(this._path, this._fileList, -1);
-  }
-
-  _handleNextFile(e) {
-    // Check for meta key to avoid overriding native chrome shortcut.
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.getKeyboardEvent(e).metaKey) { return; }
-
-    e.preventDefault();
-    this._navToFile(this._path, this._fileList, 1);
-  }
-
-  _handleNextChunkOrCommentThread(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-    e.preventDefault();
-    if (e.detail.keyboardEvent.shiftKey) {
-      this.$.cursor.moveToNextCommentThread();
-    } else {
-      if (this.modifierPressed(e)) { return; }
-      // navigate to next file if key is not being held down
-      this.$.cursor.moveToNextChunk(/* opt_clipToTop = */false,
-          /* opt_navigateToNextFile = */!e.detail.keyboardEvent.repeat);
-    }
-  }
-
-  _handlePrevChunkOrCommentThread(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-    e.preventDefault();
-    if (e.detail.keyboardEvent.shiftKey) {
-      this.$.cursor.moveToPreviousCommentThread();
-    } else {
-      if (this.modifierPressed(e)) { return; }
-      this.$.cursor.moveToPreviousChunk();
-    }
-  }
-
-  _handleOpenReplyDialogOrToggleLeftPane(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-    if (e.detail.keyboardEvent.shiftKey) { // Hide left diff.
-      e.preventDefault();
-      this.$.diffHost.toggleLeftDiff();
-      return;
-    }
-
-    if (this.modifierPressed(e)) { return; }
-
-    if (!this._loggedIn) { return; }
-
-    this.set('changeViewState.showReplyDialog', true);
-    e.preventDefault();
-    this._navToChangeView();
-  }
-
-  _handleOpenDownloadDialog(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    if (this.modifierPressed(e)) { return; }
-    this.set('changeViewState.showDownloadDialog', true);
-    e.preventDefault();
-    this._navToChangeView();
-  }
-
-  _handleUpToChange(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this._navToChangeView();
-  }
-
-  _handleCommaKey(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-    if (this._diffPrefsDisabled) { return; }
-
-    e.preventDefault();
-    this.$.diffPreferencesDialog.open();
-  }
-
-  _handleToggleDiffMode(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    if (this._getDiffViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-      this.$.modeSelect.setMode(DiffViewMode.UNIFIED);
-    } else {
-      this.$.modeSelect.setMode(DiffViewMode.SIDE_BY_SIDE);
-    }
-  }
-
-  _navToChangeView() {
-    if (!this._changeNum || !this._patchRange.patchNum) { return; }
-    this._navigateToChange(
-        this._change,
-        this._patchRange,
-        this._change && this._change.revisions);
-  }
-
-  _navToFile(path, fileList, direction) {
-    const newPath = this._getNavLinkPath(path, fileList, direction);
-    if (!newPath) { return; }
-
-    if (newPath.up) {
-      this._navigateToChange(
-          this._change,
-          this._patchRange,
-          this._change && this._change.revisions);
-      return;
-    }
-
-    GerritNav.navigateToDiff(this._change, newPath.path,
-        this._patchRange.patchNum, this._patchRange.basePatchNum);
-  }
-
-  /**
-   * @param {?string} path The path of the current file being shown.
-   * @param {!Array<string>} fileList The list of files in this change and
-   *     patch range.
-   * @param {number} direction Either 1 (next file) or -1 (prev file).
-   * @param {(number|boolean)} opt_noUp Whether to return to the change view
-   *     when advancing the file goes outside the bounds of fileList.
-   *
-   * @return {?string} The next URL when proceeding in the specified
-   *     direction.
-   */
-  _computeNavLinkURL(change, path, fileList, direction, opt_noUp) {
-    const newPath = this._getNavLinkPath(path, fileList, direction, opt_noUp);
-    if (!newPath) { return null; }
-
-    if (newPath.up) {
-      return this._getChangePath(
-          this._change,
-          this._patchRange,
-          this._change && this._change.revisions);
-    }
-    return this._getDiffUrl(this._change, this._patchRange, newPath.path);
-  }
-
-  _goToEditFile() {
-    // TODO(taoalpha): add a shortcut for editing
-    const cursorAddress = this.$.cursor.getAddress();
-    const editUrl = GerritNav.getEditUrlForDiff(
-        this._change,
-        this._path,
-        this._patchRange.patchNum,
-        cursorAddress && cursorAddress.number
-    );
-    return GerritNav.navigateToRelativeUrl(editUrl);
-  }
-
-  /**
-   * Gives an object representing the target of navigating either left or
-   * right through the change. The resulting object will have one of the
-   * following forms:
-   *   * {path: "<target file path>"} - When another file path should be the
-   *     result of the navigation.
-   *   * {up: true} - When the result of navigating should go back to the
-   *     change view.
-   *   * null - When no navigation is possible for the given direction.
-   *
-   * @param {?string} path The path of the current file being shown.
-   * @param {!Array<string>} fileList The list of files in this change and
-   *     patch range.
-   * @param {number} direction Either 1 (next file) or -1 (prev file).
-   * @param {?number|boolean=} opt_noUp Whether to return to the change view
-   *     when advancing the file goes outside the bounds of fileList.
-   * @return {?Object}
-   */
-  _getNavLinkPath(path, fileList, direction, opt_noUp) {
-    if (!path || !fileList || fileList.length === 0) { return null; }
-
-    let idx = fileList.indexOf(path);
-    if (idx === -1) {
-      const file = direction > 0 ?
-        fileList[0] :
-        fileList[fileList.length - 1];
-      return {path: file};
-    }
-
-    idx += direction;
-    // Redirect to the change view if opt_noUp isn’t truthy and idx falls
-    // outside the bounds of [0, fileList.length).
-    if (idx < 0 || idx > fileList.length - 1) {
-      if (opt_noUp) { return null; }
-      return {up: true};
-    }
-
-    return {path: fileList[idx]};
-  }
-
-  _getReviewedFiles(changeNum, patchNum) {
-    return this.$.restAPI.getReviewedFiles(changeNum, patchNum)
-        .then(files => {
-          this._reviewedFiles = new Set(files);
-          return this._reviewedFiles;
-        });
-  }
-
-  _getReviewedStatus(editMode, changeNum, patchNum, path) {
-    if (editMode) { return Promise.resolve(false); }
-    return this._getReviewedFiles(changeNum, patchNum)
-        .then(files => files.has(path));
-  }
-
-  _initLineOfInterestAndCursor(leftSide) {
-    this.$.diffHost.lineOfInterest =
-      this._getLineOfInterest({
-        leftSide,
-      });
-    this._initCursor({
-      leftSide,
-    });
-  }
-
-  _displayDiffBaseAgainstLeftToast() {
-    this.dispatchEvent(new CustomEvent('show-alert', {
-      detail: {
-        // \u2190 = ←
-        message: `Patchset ${this._patchRange.basePatchNum} vs ` +
-          `${this._patchRange.patchNum} selected. Press v + \u2190 to view `
-          + `Base vs ${this._patchRange.basePatchNum}`,
-      },
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _displayDiffAgainstLatestToast(latestPatchNum) {
-    const leftPatchset = patchNumEquals(
-        this._patchRange.basePatchNum, 'PARENT')
-      ? 'Base' : `Patchset ${this._patchRange.basePatchNum}`;
-    this.dispatchEvent(new CustomEvent('show-alert', {
-      detail: {
-        // \u2191 = ↑
-        message: `${leftPatchset} vs
-            ${this._patchRange.patchNum} selected\n. Press v + \u2191 to view
-            ${leftPatchset} vs Patchset ${latestPatchNum}`,
-      },
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _displayToasts() {
-    if (!patchNumEquals(this._patchRange.basePatchNum, 'PARENT')) {
-      this._displayDiffBaseAgainstLeftToast();
-      return;
-    }
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (!patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
-      this._displayDiffAgainstLatestToast(latestPatchNum);
-      return;
-    }
-  }
-
-  _initCommitRange() {
-    let commit;
-    let baseCommit;
-    if (!this._patchRange || !this._patchRange.patchNum) return;
-    for (const commitSha in this._change.revisions) {
-      if (!this._change.revisions.hasOwnProperty(commitSha)) continue;
-      const revision = this._change.revisions[commitSha];
-      const patchNum = revision._number.toString();
-      if (patchNum === this._patchRange.patchNum) {
-        commit = commitSha;
-        const commitObj = revision.commit || {};
-        const parents = commitObj.parents || [];
-        if (this._patchRange.basePatchNum === PARENT && parents.length) {
-          baseCommit = parents[parents.length - 1].commit;
-        }
-      } else if (patchNum === this._patchRange.basePatchNum) {
-        baseCommit = commitSha;
-      }
-    }
-    this._commitRange = {commit, baseCommit};
-  }
-
-  _initPatchRange() {
-    let leftSide;
-    if (this.params.commentId) {
-      const comment = this._changeComments.findCommentById(
-          this.params.commentId);
-      if (!comment) {
-        this.dispatchEvent(new CustomEvent('show-alert', {
-          detail: {
-            message: 'comment not found',
-          },
-          composed: true, bubbles: true,
-        }));
-        GerritNav.navigateToChange(this._change);
-        return;
-      }
-      this._path = comment.path;
-      const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-      if (patchNumEquals(latestPatchNum, comment.patch_set)) {
-        this._patchRange = {
-          patchNum: latestPatchNum,
-          basePatchNum: PARENT,
-        };
-        leftSide = comment.__commentSide === 'left';
-      } else {
-        this._patchRange = {
-          patchNum: latestPatchNum,
-          basePatchNum: comment.patch_set,
-        };
-        // comment is now on the left side since we are showing
-        // comment.patch_set vs latest
-        leftSide = true;
-      }
-      this._focusLineNum = comment.line;
-    } else {
-      if (this.params.path) {
-        this._path = this.params.path;
-      }
-      if (this.params.patchNum) {
-        this._patchRange = {
-          patchNum: this.params.patchNum,
-          basePatchNum: this.params.basePatchNum || PARENT,
-        };
-      }
-      if (this.params.lineNum) {
-        this._focusLineNum = this.params.lineNum;
-        leftSide = this.params.leftSide;
-      }
-    }
-    this._initLineOfInterestAndCursor(leftSide);
-    this._commentMap = this._getPaths(this._patchRange);
-
-    this._commentsForDiff = this._getCommentsForPath(this._path,
-        this._patchRange, this._projectConfig);
-  }
-
-  _isFileUnchanged(diff) {
-    if (!diff || !diff.content) return false;
-    return !diff.content.some(content =>
-      (content.a && !content.common) ||
-        (content.b && !content.common)
-    );
-  }
-
-  _paramsChanged(value) {
-    if (value.view !== GerritNav.View.DIFF) { return; }
-
-    this._change = undefined;
-    this._files = undefined;
-    this._path = undefined;
-    this._patchRange = undefined;
-    this._commitRange = undefined;
-    this._changeComments = undefined;
-    this._focusLineNum = undefined;
-
-    if (value.changeNum && value.project) {
-      this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
-    }
-
-    this._changeNum = value.changeNum;
-    this.classList.remove('hideComments');
-
-    // When navigating away from the page, there is a possibility that the
-    // patch number is no longer a part of the URL (say when navigating to
-    // the top-level change info view) and therefore undefined in `params`.
-    // If route is of type /comment/<commentId>/ then no patchNum is present
-    if (!value.patchNum && !value.commentLink) {
-      console.warn('invalid url, no patchNum found');
-      return;
-    }
-
-    const promises = [];
-
-    promises.push(this._getDiffPreferences());
-
-    promises.push(this._getPreferences().then(prefs => {
-      this._userPrefs = prefs;
-    }));
-
-    promises.push(this._getChangeDetail(this._changeNum));
-    promises.push(this._loadComments());
-
-    promises.push(this._getChangeEdit(this._changeNum));
-
-    this.$.diffHost.cancel();
-    this.$.diffHost.clearDiffContent();
-    this._loading = true;
-    return Promise.all(promises)
-        .then(r => {
-          this._loading = false;
-          this._initPatchRange();
-          this._initCommitRange();
-          this.$.diffHost.comments = this._commentsForDiff;
-          const edit = r[4];
-          if (edit) {
-            this.set('_change.revisions.' + edit.commit.commit, {
-              _number: SPECIAL_PATCH_SET_NUM.EDIT,
-              basePatchNum: edit.base_patch_set_number,
-              commit: edit.commit,
-            });
-          }
-          return this.$.diffHost.reload(true);
-        })
-        .then(() => {
-          this.reporting.diffViewFullyLoaded();
-          // If diff view displayed has not ended yet, it ends here.
-          this.reporting.diffViewDisplayed();
-        })
-        .then(() => {
-          const fileUnchanged = this._isFileUnchanged(this._diff);
-          if (fileUnchanged && value.commentLink) {
-            this.dispatchEvent(new CustomEvent('show-alert', {
-              detail: {
-                message: `File is unchanged between Patchset
-                  ${this._patchRange.basePatchNum} and
-                  ${this._patchRange.patchNum}. Showing diff of Base vs
-                  ${this._patchRange.basePatchNum}`,
-              },
-              composed: true, bubbles: true,
-            }));
-            GerritNav.navigateToDiff(
-                this._change, this._path, this._patchRange.basePatchNum,
-                'PARENT', this._focusLineNum);
-            return;
-          }
-          if (value.commentLink) {
-            this._displayToasts();
-          }
-          // If the blame was loaded for a previous file and user navigates to
-          // another file, then we load the blame for this file too
-          if (this._isBlameLoaded) this._loadBlame();
-        });
-  }
-
-  _changeViewStateChanged(changeViewState) {
-    if (changeViewState.diffMode === null) {
-      // If screen size is small, always default to unified view.
-      this.$.restAPI.getPreferences().then(prefs => {
-        this.set('changeViewState.diffMode', prefs.default_diff_view);
-      });
-    }
-  }
-
-  _setReviewedObserver(_loggedIn, paramsRecord, _prefs, patchRangeRecord) {
-    // Polymer 2: check for undefined
-    if ([_loggedIn, paramsRecord, _prefs, patchRangeRecord,
-      patchRangeRecord.base].includes(
-        undefined)) {
-      return;
-    }
-    const patchRange = patchRangeRecord.base;
-    const params = paramsRecord.base || {};
-    if (!_loggedIn) { return; }
-
-    if (_prefs.manual_review) {
-      // Checkbox state needs to be set explicitly only when manual_review
-      // is specified.
-
-      if (patchRange.patchNum) {
-        this._getReviewedStatus(this.editMode, this._changeNum,
-            patchRange.patchNum, this._path).then(status => {
-          this.$.reviewed.checked = status;
-        });
-      }
-      return;
-    }
-
-    if (params.view === GerritNav.View.DIFF) {
-      this._setReviewed(true);
-    }
-  }
-
-  /**
-   * If the params specify a diff address then configure the diff cursor.
-   */
-  _initCursor(params) {
-    if (this._focusLineNum === undefined) { return; }
-    if (params.leftSide) {
-      this.$.cursor.side = DiffSides.LEFT;
-    } else {
-      this.$.cursor.side = DiffSides.RIGHT;
-    }
-    this.$.cursor.initialLineNumber = this._focusLineNum;
-  }
-
-  _getLineOfInterest(params) {
-    // If there is a line number specified, pass it along to the diff so that
-    // it will not get collapsed.
-    if (!this._focusLineNum) { return null; }
-    return {number: this._focusLineNum, leftSide: params.leftSide};
-  }
-
-  _pathChanged(path) {
-    if (path) {
-      this.dispatchEvent(new CustomEvent('title-change', {
-        detail: {title: computeTruncatedPath(path)},
-        composed: true, bubbles: true,
-      }));
-    }
-
-    if (!this._fileList || this._fileList.length == 0) { return; }
-
-    this.set('changeViewState.selectedFileIndex',
-        this._fileList.indexOf(path));
-  }
-
-  _getDiffUrl(change, patchRange, path) {
-    if ([change, patchRange, path].includes(undefined)) {
-      return '';
-    }
-    return GerritNav.getUrlForDiff(change, path, patchRange.patchNum,
-        patchRange.basePatchNum);
-  }
-
-  _patchRangeStr(patchRange) {
-    let patchStr = patchRange.patchNum;
-    if (patchRange.basePatchNum != null &&
-        patchRange.basePatchNum != PARENT) {
-      patchStr = patchRange.basePatchNum + '..' + patchRange.patchNum;
-    }
-    return patchStr;
-  }
-
-  /**
-   * When the latest patch of the change is selected (and there is no base
-   * patch) then the patch range need not appear in the URL. Return a patch
-   * range object with undefined values when a range is not needed.
-   *
-   * @param {!Object} patchRange
-   * @param {!Object} revisions
-   * @return {!Object}
-   */
-  _getChangeUrlRange(patchRange, revisions) {
-    let patchNum = undefined;
-    let basePatchNum = undefined;
-    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) {
-      patchNum = patchRange.patchNum;
-      basePatchNum = patchRange.basePatchNum;
-    }
-    return {patchNum, basePatchNum};
-  }
-
-  _getChangePath(change, patchRange, revisions) {
-    if ([change, patchRange].includes(undefined)) {
-      return '';
-    }
-    const range = this._getChangeUrlRange(patchRange, revisions);
-    return GerritNav.getUrlForChange(change, range.patchNum,
-        range.basePatchNum);
-  }
-
-  _navigateToChange(change, patchRange, revisions) {
-    const range = this._getChangeUrlRange(patchRange, revisions);
-    GerritNav.navigateToChange(change, range.patchNum, range.basePatchNum);
-  }
-
-  _computeChangePath(change, patchRangeRecord, revisions) {
-    return this._getChangePath(change, patchRangeRecord.base, revisions);
-  }
-
-  _formatFilesForDropdown(files, patchNum, changeComments) {
-    // Polymer 2: check for undefined
-    if ([
-      files,
-      patchNum,
-      changeComments,
-    ].includes(undefined)) {
-      return;
-    }
-
-    if (!files) { return; }
-    const dropdownContent = [];
-    for (const path of files.sortedFileList) {
-      dropdownContent.push({
-        text: computeDisplayPath(path),
-        mobileText: computeTruncatedPath(path),
-        value: path,
-        bottomText: this._computeCommentString(changeComments, patchNum,
-            path, files.changeFilesByPath[path]),
-      });
-    }
-    return dropdownContent;
-  }
-
-  _computeCommentString(changeComments, patchNum, path, changeFileInfo) {
-    const unresolvedCount = changeComments.computeUnresolvedNum({patchNum,
-      path});
-    const commentCount = changeComments.computeCommentCount({patchNum, path});
-    const commentString = GrCountStringFormatter.computePluralString(
-        commentCount, 'comment');
-    const unresolvedString = GrCountStringFormatter.computeString(
-        unresolvedCount, 'unresolved');
-
-    const unmodifiedString = changeFileInfo.status === 'U' ? 'no changes': '';
-
-    return [
-      unmodifiedString,
-      commentString,
-      unresolvedString]
-        .filter(v => v && v.length > 0).join(', ');
-  }
-
-  _computePrefsButtonHidden(prefs, prefsDisabled) {
-    return prefsDisabled || !prefs;
-  }
-
-  _handleFileChange(e) {
-    // This is when it gets set initially.
-    const path = e.detail.value;
-    if (path === this._path) {
-      return;
-    }
-
-    GerritNav.navigateToDiff(this._change, path, this._patchRange.patchNum,
-        this._patchRange.basePatchNum);
-  }
-
-  _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);
-  }
-
-  _handlePatchChange(e) {
-    const {basePatchNum, patchNum} = e.detail;
-    if (patchNumEquals(basePatchNum, this._patchRange.basePatchNum) &&
-        patchNumEquals(patchNum, this._patchRange.patchNum)) { return; }
-    GerritNav.navigateToDiff(
-        this._change, this._path, patchNum, basePatchNum);
-  }
-
-  _handlePrefsTap(e) {
-    e.preventDefault();
-    this.$.diffPreferencesDialog.open();
-  }
-
-  /**
-   * _getDiffViewMode: Get the diff view (side-by-side or unified) based on
-   * the current state.
-   *
-   * The expected behavior is to use the mode specified in the user's
-   * preferences unless they have manually chosen the alternative view or they
-   * are on a mobile device. If the user navigates up to the change view, it
-   * should clear this choice and revert to the preference the next time a
-   * diff is viewed.
-   *
-   * Use side-by-side if the user is not logged in.
-   *
-   * @return {string}
-   */
-  _getDiffViewMode() {
-    if (this.changeViewState.diffMode) {
-      return this.changeViewState.diffMode;
-    } else if (this._userPrefs) {
-      this.set('changeViewState.diffMode', this._userPrefs.default_diff_view);
-      return this._userPrefs.default_diff_view;
-    } else {
-      return 'SIDE_BY_SIDE';
-    }
-  }
-
-  _computeModeSelectHideClass(_diff) {
-    return _diff.binary ? 'hide' : '';
-  }
-
-  _onLineSelected(e, detail) {
-    if (!this._change) { return; }
-    const number = detail.number;
-    // for on-comment-anchor-tap side can be PARENT/REVISIONS
-    // for on-line-selected side can be LEFT/RIGHT
-    const leftSide = detail.side === 'LEFT' || detail.side === 'PARENT';
-    const url = GerritNav.getUrlForDiffById(this._changeNum,
-        this._change.project, this._path, this._patchRange.patchNum,
-        this._patchRange.basePatchNum, number, leftSide);
-    history.replaceState(null, '', url);
-  }
-
-  _computeDownloadDropdownLinks(
-      project, changeNum, patchRange, path, diff) {
-    if (!patchRange || !patchRange.patchNum) { return []; }
-
-    const links = [
-      {
-        url: this._computeDownloadPatchLink(
-            project, changeNum, patchRange, path),
-        name: 'Patch',
-      },
-    ];
-
-    if (diff && diff.meta_a) {
-      let leftPath = path;
-      if (diff.change_type === 'RENAMED') {
-        leftPath = diff.meta_a.name;
-      }
-      links.push(
-          {
-            url: this._computeDownloadFileLink(
-                project, changeNum, patchRange, leftPath, true),
-            name: 'Left Content',
-          }
-      );
-    }
-
-    if (diff && diff.meta_b) {
-      links.push(
-          {
-            url: this._computeDownloadFileLink(
-                project, changeNum, patchRange, path, false),
-            name: 'Right Content',
-          }
-      );
-    }
-
-    return links;
-  }
-
-  _computeDownloadFileLink(
-      project, changeNum, patchRange, path, isBase) {
-    let patchNum = patchRange.patchNum;
-
-    const comparedAgainsParent = patchRange.basePatchNum === 'PARENT';
-
-    if (isBase && !comparedAgainsParent) {
-      patchNum = patchRange.basePatchNum;
-    }
-
-    let url = changeBaseURL(project, changeNum, patchNum) +
-        `/files/${encodeURIComponent(path)}/download`;
-
-    if (isBase && comparedAgainsParent) {
-      url += '?parent=1';
-    }
-
-    return url;
-  }
-
-  _computeDownloadPatchLink(project, changeNum, patchRange, path) {
-    let url = changeBaseURL(project, changeNum, patchRange.patchNum);
-    url += '/patch?zip&path=' + encodeURIComponent(path);
-    return url;
-  }
-
-  _loadComments() {
-    return this.$.commentAPI.loadAll(this._changeNum).then(comments => {
-      this._changeComments = comments;
-    });
-  }
-
-  _recomputeComments(files, path, patchRange, projectConfig) {
-    // Polymer 2: check for undefined
-    if ([
-      files,
-      path,
-      patchRange,
-      projectConfig,
-    ].includes(undefined)) {
-      return undefined;
-    }
-
-    const file = files[path];
-    if (file && file.old_path) {
-      this._commentsForDiff = this._changeComments.getCommentsBySideForFile(
-          {path, basePath: file.old_path},
-          patchRange,
-          projectConfig);
-
-      this.$.diffHost.comments = this._commentsForDiff;
-    }
-  }
-
-  _getPaths(patchRange) {
-    return this._changeComments.getPaths(patchRange);
-  }
-
-  _getCommentsForPath(path, patchRange, projectConfig) {
-    return this._changeComments.getCommentsBySideForPath(path, patchRange,
-        projectConfig);
-  }
-
-  _getDiffDrafts() {
-    return this.$.restAPI.getDiffDrafts(this._changeNum);
-  }
-
-  _computeCommentSkips(commentMap, fileList, path) {
-    // Polymer 2: check for undefined
-    if ([
-      commentMap,
-      fileList,
-      path,
-    ].includes(undefined)) {
-      return undefined;
-    }
-
-    const skips = {previous: null, next: null};
-    if (!fileList.length) { return skips; }
-    const pathIndex = fileList.indexOf(path);
-
-    // Scan backward for the previous file.
-    for (let i = pathIndex - 1; i >= 0; i--) {
-      if (commentMap[fileList[i]]) {
-        skips.previous = fileList[i];
-        break;
-      }
-    }
-
-    // Scan forward for the next file.
-    for (let i = pathIndex + 1; i < fileList.length; i++) {
-      if (commentMap[fileList[i]]) {
-        skips.next = fileList[i];
-        break;
-      }
-    }
-
-    return skips;
-  }
-
-  _computeContainerClass(editMode) {
-    return editMode ? 'editMode' : '';
-  }
-
-  /**
-   * @param {!Object} patchRangeRecord
-   */
-  _computeEditMode(patchRangeRecord) {
-    const patchRange = patchRangeRecord.base || {};
-    return patchNumEquals(patchRange.patchNum, SPECIAL_PATCH_SET_NUM.EDIT);
-  }
-
-  _computeBlameToggleLabel(loaded, loading) {
-    if (loaded) { return 'Hide blame'; }
-    return 'Show blame';
-  }
-
-  _loadBlame() {
-    this._isBlameLoading = true;
-    this.dispatchEvent(new CustomEvent('show-alert', {
-      detail: {message: MSG_LOADING_BLAME},
-      composed: true, bubbles: true,
-    }));
-    this.$.diffHost.loadBlame()
-        .then(() => {
-          this._isBlameLoading = false;
-          this.dispatchEvent(new CustomEvent('show-alert', {
-            detail: {message: MSG_LOADED_BLAME},
-            composed: true, bubbles: true,
-          }));
-        })
-        .catch(() => {
-          this._isBlameLoading = false;
-        });
-  }
-
-  /**
-   * Load and display blame information if it has not already been loaded.
-   * Otherwise hide it.
-   */
-  _toggleBlame() {
-    if (this._isBlameLoaded) {
-      this.$.diffHost.clearBlame();
-      return;
-    }
-    this._loadBlame();
-  }
-
-  _handleToggleBlame(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-      this.modifierPressed(e)) { return; }
-    this._toggleBlame();
-  }
-
-  _handleToggleHideAllCommentThreads(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-      this.modifierPressed(e)) { return; }
-    this.toggleClass('hideComments');
-  }
-
-  _handleDiffAgainstBase(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    if (patchNumEquals(this._patchRange.basePatchNum,
-        SPECIAL_PATCH_SET_NUM.PARENT)) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {
-          message: 'Base is already selected.',
-        },
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    GerritNav.navigateToDiff(
-        this._change, this._path, this._patchRange.patchNum);
-  }
-
-  _handleDiffBaseAgainstLeft(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    if (patchNumEquals(this._patchRange.basePatchNum,
-        SPECIAL_PATCH_SET_NUM.PARENT)) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {
-          message: 'Left is already base.',
-        },
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    GerritNav.navigateToDiff(this._change, this._path,
-        this._patchRange.basePatchNum);
-  }
-
-  _handleDiffAgainstLatest(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {
-          message: 'Latest is already selected.',
-        },
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-
-    GerritNav.navigateToDiff(
-        this._change, this._path, latestPatchNum,
-        this._patchRange.basePatchNum);
-  }
-
-  _handleDiffRightAgainstLatest(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {
-          message: 'Right is already latest.',
-        },
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    GerritNav.navigateToDiff(this._change, this._path, latestPatchNum,
-        this._patchRange.patchNum);
-  }
-
-  _handleDiffBaseAgainstLatest(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
-      patchNumEquals(this._patchRange.basePatchNum,
-          SPECIAL_PATCH_SET_NUM.PARENT)) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {
-          message: 'Already diffing base against latest.',
-        },
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    GerritNav.navigateToDiff(this._change, this._path, latestPatchNum);
-  }
-
-  _computeBlameLoaderClass(isImageDiff, path) {
-    return !isMagicPath(path) && !isImageDiff ? 'show' : '';
-  }
-
-  _getRevisionInfo(change) {
-    return new RevisionInfo(change);
-  }
-
-  _computeFileNum(file, files) {
-    // Polymer 2: check for undefined
-    if ([file, files].includes(undefined)) {
-      return undefined;
-    }
-
-    return files.findIndex(({value}) => value === file) + 1;
-  }
-
-  /**
-   * @param {number} fileNum
-   * @param {!Array<string>} files
-   * @return {string}
-   */
-  _computeFileNumClass(fileNum, files) {
-    if (files && fileNum > 0) {
-      return 'show';
-    }
-    return '';
-  }
-
-  _handleExpandAllDiffContext(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    this.$.diffHost.expandAllContext();
-  }
-
-  _computeDiffPrefsDisabled(disableDiffPrefs, loggedIn) {
-    return disableDiffPrefs || !loggedIn;
-  }
-
-  _handleNextUnreviewedFile(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    this._setReviewed(true);
-    // Ensure that the currently viewed file always appears in unreviewedFiles
-    // so we resolve the right "next" file.
-    const unreviewedFiles = this._fileList
-        .filter(file =>
-          (file === this._path || !this._reviewedFiles.has(file)));
-    this._navToFile(this._path, unreviewedFiles, 1);
-  }
-
-  _handleReloadingDiffPreference() {
-    this._getDiffPreferences();
-  }
-
-  _computeCanEdit(loggedIn, changeChangeRecord) {
-    if ([changeChangeRecord, changeChangeRecord.base]
-        .some(arg => arg === undefined)) {
-      return false;
-    }
-    return loggedIn && changeIsOpen(changeChangeRecord.base);
-  }
-
-  _computeIsLoggedIn(loggedIn) {
-    return loggedIn ? true : false;
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeAllPatchSets(change) {
-    return computeAllPatchSets(change);
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeDisplayPath(path) {
-    return computeDisplayPath(path);
-  }
-}
-
-customElements.define(GrDiffView.is, GrDiffView);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
new file mode 100644
index 0000000..bb0ed61
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -0,0 +1,1867 @@
+/**
+ * @license
+ * 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.
+ */
+import '@polymer/iron-dropdown/iron-dropdown';
+import '@polymer/iron-input/iron-input';
+import '../../../styles/shared-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-dropdown/gr-dropdown';
+import '../../shared/gr-dropdown-list/gr-dropdown-list';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import '../../shared/revision-info/revision-info';
+import '../gr-comment-api/gr-comment-api';
+import '../gr-diff-cursor/gr-diff-cursor';
+import '../gr-apply-fix-dialog/gr-apply-fix-dialog';
+import '../gr-diff-host/gr-diff-host';
+import '../gr-diff-mode-selector/gr-diff-mode-selector';
+import '../gr-diff-preferences-dialog/gr-diff-preferences-dialog';
+import '../gr-patch-range-select/gr-patch-range-select';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-diff-view_html';
+import {
+  KeyboardShortcutMixin,
+  Shortcut,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {appContext} from '../../../services/app-context';
+import {
+  computeAllPatchSets,
+  computeLatestPatchNum,
+  patchNumEquals,
+  PatchSet,
+} from '../../../utils/patch-set-util';
+import {
+  addUnmodifiedFiles,
+  computeDisplayPath,
+  computeTruncatedPath,
+  isMagicPath,
+  specialFilePathCompare,
+} from '../../../utils/path-list-util';
+import {changeBaseURL, changeIsOpen} from '../../../utils/change-util';
+import {customElement, observe, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrDiffHost} from '../gr-diff-host/gr-diff-host';
+import {
+  DropdownItem,
+  GrDropdownList,
+} from '../../shared/gr-dropdown-list/gr-dropdown-list';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {ChangeComments, GrCommentApi} from '../gr-comment-api/gr-comment-api';
+import {GrDiffModeSelector} from '../gr-diff-mode-selector/gr-diff-mode-selector';
+import {
+  ChangeInfo,
+  CommitId,
+  ConfigInfo,
+  EditInfo,
+  EditPatchSetNum,
+  ElementPropertyDeepChange,
+  FileInfo,
+  NumericChangeId,
+  ParentPatchSetNum,
+  PatchRange,
+  PatchSetNum,
+  PreferencesInfo,
+  RepoName,
+  RevisionInfo,
+} from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {ChangeViewState, CommitRange, FileRange} from '../../../types/types';
+import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {GrDiffCursor} from '../gr-diff-cursor/gr-diff-cursor';
+import {CommentSide, DiffViewMode, Side} from '../../../constants/constants';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {GrApplyFixDialog} from '../gr-apply-fix-dialog/gr-apply-fix-dialog';
+import {LineOfInterest} from '../gr-diff/gr-diff';
+import {RevisionInfo as RevisionInfoObj} from '../../shared/revision-info/revision-info';
+import {CommentMap} from '../../../utils/comment-util';
+import {AppElementParams} from '../../gr-app-types';
+import {CustomKeyboardEvent, OpenFixPreviewEvent} from '../../../types/events';
+import {PORTING_COMMENTS_DIFF_LATENCY_LABEL} from '../../../services/gr-reporting/gr-reporting';
+import {fireAlert, fireTitleChange} from '../../../utils/event-util';
+
+const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
+const MSG_LOADING_BLAME = 'Loading blame...';
+const MSG_LOADED_BLAME = 'Blame loaded';
+
+interface Files {
+  sortedFileList: string[];
+  changeFilesByPath: {[path: string]: FileInfo};
+}
+
+interface CommentSkips {
+  previous: string | null;
+  next: string | null;
+}
+
+export interface GrDiffView {
+  $: {
+    restAPI: RestApiService & Element;
+    commentAPI: GrCommentApi;
+    cursor: GrDiffCursor;
+    diffHost: GrDiffHost;
+    reviewed: HTMLInputElement;
+    dropdown: GrDropdownList;
+    diffPreferencesDialog: GrOverlay;
+    applyFixDialog: GrApplyFixDialog;
+    modeSelect: GrDiffModeSelector;
+  };
+}
+
+@customElement('gr-diff-view')
+export class GrDiffView extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the title of the page should change.
+   *
+   * @event title-change
+   */
+
+  /**
+   * Fired when user tries to navigate away while comments are pending save.
+   *
+   * @event show-alert
+   */
+
+  @property({type: Object, observer: '_paramsChanged'})
+  params?: AppElementParams;
+
+  @property({type: Object})
+  keyEventTarget: HTMLElement = document.body;
+
+  @property({type: Object, notify: true, observer: '_changeViewStateChanged'})
+  changeViewState: Partial<ChangeViewState> = {};
+
+  @property({type: Boolean})
+  disableDiffPrefs = false;
+
+  @property({
+    type: Boolean,
+    computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
+  })
+  _diffPrefsDisabled?: boolean;
+
+  @property({type: Object})
+  _patchRange?: PatchRange;
+
+  @property({type: Object})
+  _commitRange?: CommitRange;
+
+  @property({type: Object})
+  _change?: ChangeInfo;
+
+  @property({type: Object})
+  _changeComments?: ChangeComments;
+
+  @property({type: String})
+  _changeNum?: NumericChangeId;
+
+  @property({type: Object})
+  _diff?: DiffInfo;
+
+  @property({
+    type: Array,
+    computed:
+      '_formatFilesForDropdown(_files, ' +
+      '_patchRange.patchNum, _changeComments)',
+  })
+  _formattedFiles?: DropdownItem[];
+
+  @property({type: Array, computed: '_getSortedFileList(_files)'})
+  _fileList?: string[];
+
+  @property({type: Object})
+  _files: Files = {sortedFileList: [], changeFilesByPath: {}};
+
+  @property({type: Object, computed: '_getCurrentFile(_files, _path)'})
+  _file?: FileInfo;
+
+  @property({type: String, observer: '_pathChanged'})
+  _path?: string;
+
+  @property({type: Number, computed: '_computeFileNum(_path, _formattedFiles)'})
+  _fileNum?: number;
+
+  @property({type: Boolean})
+  _loggedIn = false;
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: Object})
+  _prefs?: DiffPreferencesInfo;
+
+  @property({type: Object})
+  _projectConfig?: ConfigInfo;
+
+  @property({type: Object})
+  _userPrefs?: PreferencesInfo;
+
+  @property({
+    type: String,
+    computed: '_getDiffViewMode(changeViewState.diffMode, _userPrefs)',
+  })
+  _diffMode?: string;
+
+  @property({type: Boolean})
+  _isImageDiff?: boolean;
+
+  @property({type: Object})
+  _filesWeblinks?: FilesWebLinks;
+
+  @property({type: Object})
+  _commentMap?: CommentMap;
+
+  @property({
+    type: Object,
+    computed: '_computeCommentSkips(_commentMap, _fileList, _path)',
+  })
+  _commentSkips?: CommentSkips;
+
+  @property({type: Boolean, computed: '_computeEditMode(_patchRange.*)'})
+  _editMode?: boolean;
+
+  @property({type: Boolean})
+  _isBlameLoaded?: boolean;
+
+  @property({type: Boolean})
+  _isBlameLoading = false;
+
+  @property({
+    type: Array,
+    computed: '_computeAllPatchSets(_change, _change.revisions.*)',
+  })
+  _allPatchSets?: PatchSet[] = [];
+
+  @property({type: Object, computed: '_getRevisionInfo(_change)'})
+  _revisionInfo?: RevisionInfoObj;
+
+  @property({type: Object})
+  _reviewedFiles = new Set<string>();
+
+  @property({type: Number})
+  _focusLineNum?: number;
+
+  get keyBindings() {
+    return {
+      esc: '_handleEscKey',
+    };
+  }
+
+  keyboardShortcuts() {
+    return {
+      [Shortcut.LEFT_PANE]: '_handleLeftPane',
+      [Shortcut.RIGHT_PANE]: '_handleRightPane',
+      [Shortcut.NEXT_LINE]: '_handleNextLineOrFileWithComments',
+      [Shortcut.PREV_LINE]: '_handlePrevLineOrFileWithComments',
+      [Shortcut.VISIBLE_LINE]: '_handleVisibleLine',
+      [Shortcut.NEXT_FILE_WITH_COMMENTS]: '_handleNextLineOrFileWithComments',
+      [Shortcut.PREV_FILE_WITH_COMMENTS]: '_handlePrevLineOrFileWithComments',
+      [Shortcut.NEW_COMMENT]: '_handleNewComment',
+      [Shortcut.SAVE_COMMENT]: null, // DOC_ONLY binding
+      [Shortcut.NEXT_FILE]: '_handleNextFile',
+      [Shortcut.PREV_FILE]: '_handlePrevFile',
+      [Shortcut.NEXT_CHUNK]: '_handleNextChunkOrCommentThread',
+      [Shortcut.NEXT_COMMENT_THREAD]: '_handleNextChunkOrCommentThread',
+      [Shortcut.PREV_CHUNK]: '_handlePrevChunkOrCommentThread',
+      [Shortcut.PREV_COMMENT_THREAD]: '_handlePrevChunkOrCommentThread',
+      [Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
+      [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
+      [Shortcut.OPEN_DOWNLOAD_DIALOG]: '_handleOpenDownloadDialog',
+      [Shortcut.UP_TO_CHANGE]: '_handleUpToChange',
+      [Shortcut.OPEN_DIFF_PREFS]: '_handleCommaKey',
+      [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
+      [Shortcut.TOGGLE_FILE_REVIEWED]: '_throttledToggleFileReviewed',
+      [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_handleExpandAllDiffContext',
+      [Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
+      [Shortcut.TOGGLE_BLAME]: '_handleToggleBlame',
+      [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
+        '_handleToggleHideAllCommentThreads',
+      [Shortcut.OPEN_FILE_LIST]: '_handleOpenFileList',
+      [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
+      [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
+      [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
+      [Shortcut.DIFF_RIGHT_AGAINST_LATEST]: '_handleDiffRightAgainstLatest',
+      [Shortcut.DIFF_BASE_AGAINST_LATEST]: '_handleDiffBaseAgainstLatest',
+
+      // Final two are actually handled by gr-comment-thread.
+      [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
+      [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
+    };
+  }
+
+  reporting = appContext.reportingService;
+
+  flagsService = appContext.flagsService;
+
+  _throttledToggleFileReviewed?: EventListener;
+
+  _onRenderHandler?: EventListener;
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+    this._throttledToggleFileReviewed = this._throttleWrap(e =>
+      this._handleToggleFileReviewed(e as CustomKeyboardEvent)
+    );
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+    });
+
+    this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
+    this.$.cursor.push('diffs', this.$.diffHost);
+    this._onRenderHandler = (_: Event) => {
+      this.$.cursor.reInitCursor();
+    };
+    this.$.diffHost.addEventListener('render', this._onRenderHandler);
+  }
+
+  /** @override */
+  detached() {
+    if (this._onRenderHandler) {
+      this.$.diffHost.removeEventListener('render', this._onRenderHandler);
+    }
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  @observe('_change.project')
+  _getProjectConfig(project?: RepoName) {
+    if (!project) return;
+    return this.$.restAPI.getProjectConfig(project).then(config => {
+      this._projectConfig = config;
+    });
+  }
+
+  _getChangeDetail(changeNum: NumericChangeId) {
+    return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
+      if (!change) throw new Error('Missing "change" in API response.');
+      this._change = change;
+      return change;
+    });
+  }
+
+  _getChangeEdit() {
+    if (!this._changeNum) throw new Error('Missing this._changeNum');
+    return this.$.restAPI.getChangeEdit(this._changeNum);
+  }
+
+  _getSortedFileList(files?: Files) {
+    if (!files) return [];
+    return files.sortedFileList;
+  }
+
+  _getCurrentFile(files?: Files, path?: string) {
+    if (!files || !path) return;
+    const fileInfo = files.changeFilesByPath[path];
+    const fileRange: FileRange = {path};
+    if (fileInfo && fileInfo.old_path) {
+      fileRange.basePath = fileInfo.old_path;
+    }
+    return fileRange;
+  }
+
+  @observe('_changeNum', '_patchRange.*', '_changeComments')
+  _getFiles(
+    changeNum: NumericChangeId,
+    patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>,
+    changeComments: ChangeComments
+  ) {
+    // Polymer 2: check for undefined
+    if (
+      [changeNum, patchRangeRecord, patchRangeRecord.base, changeComments].some(
+        arg => arg === undefined
+      )
+    ) {
+      return Promise.resolve();
+    }
+
+    if (!patchRangeRecord.base.patchNum) {
+      return Promise.resolve();
+    }
+
+    const patchRange = patchRangeRecord.base;
+    return this.$.restAPI
+      .getChangeFiles(changeNum, patchRange)
+      .then(changeFiles => {
+        if (!changeFiles) return;
+        const commentedPaths = changeComments.getPaths(patchRange);
+        const files = {...changeFiles};
+        addUnmodifiedFiles(files, commentedPaths);
+        this._files = {
+          sortedFileList: Object.keys(files).sort(specialFilePathCompare),
+          changeFilesByPath: files,
+        };
+      });
+  }
+
+  _getDiffPreferences() {
+    return this.$.restAPI.getDiffPreferences().then(prefs => {
+      this._prefs = prefs;
+    });
+  }
+
+  _getPreferences() {
+    return this.$.restAPI.getPreferences();
+  }
+
+  _getWindowWidth() {
+    return window.innerWidth;
+  }
+
+  _handleReviewedChange(e: Event) {
+    this._setReviewed(
+      ((dom(e) as EventApi).rootTarget as HTMLInputElement).checked
+    );
+  }
+
+  _setReviewed(reviewed: boolean) {
+    if (this._editMode) return;
+    this.$.reviewed.checked = reviewed;
+    if (!this._patchRange?.patchNum) return;
+    this._saveReviewedState(reviewed).catch(err => {
+      fireAlert(this, ERR_REVIEW_STATUS);
+      throw err;
+    });
+  }
+
+  _saveReviewedState(reviewed: boolean): Promise<Response | undefined> {
+    if (!this._changeNum) return Promise.resolve(undefined);
+    if (!this._patchRange?.patchNum) return Promise.resolve(undefined);
+    if (!this._path) return Promise.resolve(undefined);
+    return this.$.restAPI.saveFileReviewed(
+      this._changeNum,
+      this._patchRange?.patchNum,
+      this._path,
+      reviewed
+    );
+  }
+
+  _handleToggleFileReviewed(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+
+    e.preventDefault();
+    this._setReviewed(!this.$.reviewed.checked);
+  }
+
+  _handleEscKey(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+
+    e.preventDefault();
+    this.$.diffHost.displayLine = false;
+  }
+
+  _handleLeftPane(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+    e.preventDefault();
+    this.$.cursor.moveLeft();
+  }
+
+  _handleRightPane(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+    e.preventDefault();
+    this.$.cursor.moveRight();
+  }
+
+  _handlePrevLineOrFileWithComments(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+    if (
+      e.detail.keyboardEvent?.shiftKey &&
+      e.detail.keyboardEvent?.keyCode === 75
+    ) {
+      // 'K'
+      this._moveToPreviousFileWithComment();
+      return;
+    }
+    if (this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.diffHost.displayLine = true;
+    this.$.cursor.moveUp();
+  }
+
+  _handleVisibleLine(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+    e.preventDefault();
+    this.$.cursor.moveToVisibleArea();
+  }
+
+  _onOpenFixPreview(e: OpenFixPreviewEvent) {
+    this.$.applyFixDialog.open(e);
+  }
+
+  _handleNextLineOrFileWithComments(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+    if (
+      e.detail.keyboardEvent?.shiftKey &&
+      e.detail.keyboardEvent?.keyCode === 74
+    ) {
+      // 'J'
+      this._moveToNextFileWithComment();
+      return;
+    }
+    if (this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.diffHost.displayLine = true;
+    this.$.cursor.moveDown();
+  }
+
+  _moveToPreviousFileWithComment() {
+    if (!this._commentSkips) return;
+    if (!this._change) return;
+    if (!this._patchRange?.patchNum) return;
+
+    // If there is no previous diff with comments, then return to the change
+    // view.
+    if (!this._commentSkips.previous) {
+      this._navToChangeView();
+      return;
+    }
+
+    GerritNav.navigateToDiff(
+      this._change,
+      this._commentSkips.previous,
+      this._patchRange.patchNum,
+      this._patchRange.basePatchNum
+    );
+  }
+
+  _moveToNextFileWithComment() {
+    if (!this._commentSkips) return;
+    if (!this._change) return;
+    if (!this._patchRange?.patchNum) return;
+
+    // If there is no next diff with comments, then return to the change view.
+    if (!this._commentSkips.next) {
+      this._navToChangeView();
+      return;
+    }
+
+    GerritNav.navigateToDiff(
+      this._change,
+      this._commentSkips.next,
+      this._patchRange.patchNum,
+      this._patchRange.basePatchNum
+    );
+  }
+
+  _handleNewComment(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+
+    e.preventDefault();
+    this.classList.remove('hideComments');
+    this.$.cursor.createCommentInPlace();
+  }
+
+  _handlePrevFile(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    // Check for meta key to avoid overriding native chrome shortcut.
+    if (this.getKeyboardEvent(e).metaKey) return;
+    if (!this._path) return;
+    if (!this._fileList) return;
+
+    e.preventDefault();
+    this._navToFile(this._path, this._fileList, -1);
+  }
+
+  _handleNextFile(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    // Check for meta key to avoid overriding native chrome shortcut.
+    if (this.getKeyboardEvent(e).metaKey) return;
+    if (!this._path) return;
+    if (!this._fileList) return;
+
+    e.preventDefault();
+    this._navToFile(this._path, this._fileList, 1);
+  }
+
+  _handleNextChunkOrCommentThread(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+    e.preventDefault();
+    if (e.detail.keyboardEvent?.shiftKey) {
+      this.$.cursor.moveToNextCommentThread();
+    } else {
+      if (this.modifierPressed(e)) return;
+      // navigate to next file if key is not being held down
+      this.$.cursor.moveToNextChunk(
+        /* opt_clipToTop = */ false,
+        /* opt_navigateToNextFile = */ !e.detail.keyboardEvent?.repeat
+      );
+    }
+  }
+
+  _handlePrevChunkOrCommentThread(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+    e.preventDefault();
+    if (e.detail.keyboardEvent?.shiftKey) {
+      this.$.cursor.moveToPreviousCommentThread();
+    } else {
+      if (this.modifierPressed(e)) return;
+      this.$.cursor.moveToPreviousChunk();
+    }
+  }
+
+  _handleOpenReplyDialog(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+    if (!this._loggedIn) return;
+
+    this.set('changeViewState.showReplyDialog', true);
+    e.preventDefault();
+    this._navToChangeView();
+  }
+
+  _handleToggleLeftPane(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (!e.detail.keyboardEvent?.shiftKey) return;
+
+    e.preventDefault();
+    this.$.diffHost.toggleLeftDiff();
+  }
+
+  _handleOpenDownloadDialog(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+
+    this.set('changeViewState.showDownloadDialog', true);
+    e.preventDefault();
+    this._navToChangeView();
+  }
+
+  _handleUpToChange(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+
+    e.preventDefault();
+    this._navToChangeView();
+  }
+
+  _handleCommaKey(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+    if (this._diffPrefsDisabled) return;
+
+    e.preventDefault();
+    this.$.diffPreferencesDialog.open();
+  }
+
+  _handleToggleDiffMode(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+
+    e.preventDefault();
+    if (this._getDiffViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+      this.$.modeSelect.setMode(DiffViewMode.UNIFIED);
+    } else {
+      this.$.modeSelect.setMode(DiffViewMode.SIDE_BY_SIDE);
+    }
+  }
+
+  _navToChangeView() {
+    if (!this._changeNum || !this._patchRange?.patchNum) {
+      return;
+    }
+    this._navigateToChange(
+      this._change,
+      this._patchRange,
+      this._change && this._change.revisions
+    );
+  }
+
+  _navToFile(path: string, fileList: string[], direction: -1 | 1) {
+    const newPath = this._getNavLinkPath(path, fileList, direction);
+    if (!newPath) return;
+    if (!this._change) return;
+    if (!this._patchRange) return;
+
+    if (newPath.up) {
+      this._navigateToChange(
+        this._change,
+        this._patchRange,
+        this._change && this._change.revisions
+      );
+      return;
+    }
+
+    if (!newPath.path) return;
+    GerritNav.navigateToDiff(
+      this._change,
+      newPath.path,
+      this._patchRange.patchNum,
+      this._patchRange.basePatchNum
+    );
+  }
+
+  /**
+   * @param path The path of the current file being shown.
+   * @param fileList The list of files in this change and
+   * patch range.
+   * @param direction Either 1 (next file) or -1 (prev file).
+   * @param opt_noUp Whether to return to the change view
+   * when advancing the file goes outside the bounds of fileList.
+   * @return The next URL when proceeding in the specified
+   * direction.
+   */
+  _computeNavLinkURL(
+    change?: ChangeInfo,
+    path?: string,
+    fileList?: string[],
+    direction?: -1 | 1,
+    opt_noUp?: boolean
+  ) {
+    if (!change) return null;
+    if (!path) return null;
+    if (!fileList) return null;
+    if (!direction) return null;
+
+    const newPath = this._getNavLinkPath(path, fileList, direction, opt_noUp);
+    if (!newPath) {
+      return null;
+    }
+
+    if (newPath.up) {
+      return this._getChangePath(
+        this._change,
+        this._patchRange,
+        this._change && this._change.revisions
+      );
+    }
+    return this._getDiffUrl(this._change, this._patchRange, newPath.path);
+  }
+
+  _goToEditFile() {
+    if (!this._change) return;
+    if (!this._path) return;
+    if (!this._patchRange) return;
+
+    // TODO(taoalpha): add a shortcut for editing
+    const cursorAddress = this.$.cursor.getAddress();
+    const editUrl = GerritNav.getEditUrlForDiff(
+      this._change,
+      this._path,
+      this._patchRange.patchNum,
+      cursorAddress?.number
+    );
+    GerritNav.navigateToRelativeUrl(editUrl);
+  }
+
+  /**
+   * Gives an object representing the target of navigating either left or
+   * right through the change. The resulting object will have one of the
+   * following forms:
+   * * {path: "<target file path>"} - When another file path should be the
+   * result of the navigation.
+   * * {up: true} - When the result of navigating should go back to the
+   * change view.
+   * * null - When no navigation is possible for the given direction.
+   *
+   * @param path The path of the current file being shown.
+   * @param fileList The list of files in this change and
+   * patch range.
+   * @param direction Either 1 (next file) or -1 (prev file).
+   * @param opt_noUp Whether to return to the change view
+   * when advancing the file goes outside the bounds of fileList.
+   */
+  _getNavLinkPath(
+    path: string,
+    fileList: string[],
+    direction: -1 | 1,
+    opt_noUp?: boolean
+  ) {
+    if (!path || !fileList || fileList.length === 0) {
+      return null;
+    }
+
+    let idx = fileList.indexOf(path);
+    if (idx === -1) {
+      const file = direction > 0 ? fileList[0] : fileList[fileList.length - 1];
+      return {path: file};
+    }
+
+    idx += direction;
+    // Redirect to the change view if opt_noUp isn’t truthy and idx falls
+    // outside the bounds of [0, fileList.length).
+    if (idx < 0 || idx > fileList.length - 1) {
+      if (opt_noUp) {
+        return null;
+      }
+      return {up: true};
+    }
+
+    return {path: fileList[idx]};
+  }
+
+  _getReviewedFiles(
+    changeNum?: NumericChangeId,
+    patchNum?: PatchSetNum
+  ): Promise<Set<string>> {
+    if (!changeNum || !patchNum) return Promise.resolve(new Set<string>());
+    return this.$.restAPI.getReviewedFiles(changeNum, patchNum).then(files => {
+      this._reviewedFiles = new Set(files);
+      return this._reviewedFiles;
+    });
+  }
+
+  _getReviewedStatus(
+    editMode?: boolean,
+    changeNum?: NumericChangeId,
+    patchNum?: PatchSetNum,
+    path?: string
+  ) {
+    if (editMode || !path) {
+      return Promise.resolve(false);
+    }
+    return this._getReviewedFiles(changeNum, patchNum).then(files =>
+      files.has(path)
+    );
+  }
+
+  _initLineOfInterestAndCursor(leftSide: boolean) {
+    this.$.diffHost.lineOfInterest = this._getLineOfInterest(leftSide);
+    this._initCursor(leftSide);
+  }
+
+  _displayDiffBaseAgainstLeftToast() {
+    if (!this._patchRange) return;
+    fireAlert(
+      this,
+      `Patchset ${this._patchRange.basePatchNum} vs ` +
+        `${this._patchRange.patchNum} selected. Press v + \u2190 to view ` +
+        `Base vs ${this._patchRange.basePatchNum}`
+    );
+  }
+
+  _displayDiffAgainstLatestToast(latestPatchNum?: PatchSetNum) {
+    if (!this._patchRange) return;
+    const leftPatchset = patchNumEquals(
+      this._patchRange.basePatchNum,
+      ParentPatchSetNum
+    )
+      ? 'Base'
+      : `Patchset ${this._patchRange.basePatchNum}`;
+    fireAlert(
+      this,
+      `${leftPatchset} vs
+            ${this._patchRange.patchNum} selected\n. Press v + \u2191 to view
+            ${leftPatchset} vs Patchset ${latestPatchNum}`
+    );
+  }
+
+  _displayToasts() {
+    if (!this._patchRange) return;
+    if (!patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+      this._displayDiffBaseAgainstLeftToast();
+      return;
+    }
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (!patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+      this._displayDiffAgainstLatestToast(latestPatchNum);
+      return;
+    }
+  }
+
+  _initCommitRange() {
+    let commit: CommitId | undefined;
+    let baseCommit: CommitId | undefined;
+    if (!this._change) return;
+    if (!this._patchRange || !this._patchRange.patchNum) return;
+    for (const commitSha in this._change.revisions) {
+      if (!hasOwnProperty(this._change.revisions, commitSha)) continue;
+      const revision = this._change.revisions[commitSha];
+      const patchNum = revision._number;
+      if (patchNumEquals(patchNum, this._patchRange.patchNum)) {
+        commit = commitSha as CommitId;
+        const commitObj = revision.commit;
+        const parents = commitObj?.parents || [];
+        if (
+          patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum) &&
+          parents.length
+        ) {
+          baseCommit = parents[parents.length - 1].commit;
+        }
+      } else if (patchNumEquals(patchNum, this._patchRange.basePatchNum)) {
+        baseCommit = commitSha as CommitId;
+      }
+    }
+    this._commitRange = commit && baseCommit ? {commit, baseCommit} : undefined;
+  }
+
+  _updateUrlToDiffUrl(lineNum?: number, leftSide?: boolean) {
+    if (!this._change) return;
+    if (!this._patchRange) return;
+    if (!this._changeNum) return;
+    if (!this._path) return;
+    const url = GerritNav.getUrlForDiffById(
+      this._changeNum,
+      this._change.project,
+      this._path,
+      this._patchRange.patchNum,
+      this._patchRange.basePatchNum,
+      lineNum,
+      leftSide
+    );
+    history.replaceState(null, '', url);
+  }
+
+  _initPatchRange() {
+    let leftSide = false;
+    if (!this._change) return;
+    if (this.params?.view !== GerritView.DIFF) return;
+    if (this.params?.commentId) {
+      const comment = this._changeComments?.findCommentById(
+        this.params.commentId
+      );
+      if (!comment) {
+        fireAlert(this, 'comment not found');
+        GerritNav.navigateToChange(this._change);
+        return;
+      }
+      this._path = comment.path;
+      const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+      if (!comment.patch_set) throw new Error('Missing comment.patch_set');
+      if (!latestPatchNum) throw new Error('Missing _allPatchSets');
+      if (patchNumEquals(latestPatchNum, comment.patch_set)) {
+        this._patchRange = {
+          patchNum: latestPatchNum,
+          basePatchNum: ParentPatchSetNum,
+        };
+        leftSide = comment.__commentSide === 'left';
+      } else {
+        this._patchRange = {
+          patchNum: latestPatchNum,
+          basePatchNum: comment.patch_set,
+        };
+        // comment is now on the left side since we are showing
+        // comment.patch_set vs latest
+        leftSide = true;
+      }
+      this._focusLineNum = comment.line;
+    } else {
+      if (this.params.path) {
+        this._path = this.params.path;
+      }
+      if (this.params.patchNum) {
+        this._patchRange = {
+          patchNum: this.params.patchNum,
+          basePatchNum: this.params.basePatchNum || ParentPatchSetNum,
+        };
+      }
+      if (this.params.lineNum) {
+        this._focusLineNum = this.params.lineNum;
+        leftSide = !!this.params.leftSide;
+      }
+    }
+    if (!this._patchRange) throw new Error('Failed to initialize patchRange.');
+    this._initLineOfInterestAndCursor(leftSide);
+
+    if (this.params?.commentId) {
+      // url is of type /comment/{commentId} which isn't meaningful
+      this._updateUrlToDiffUrl(this._focusLineNum, leftSide);
+    }
+
+    this._commentMap = this._getPaths(this._patchRange);
+  }
+
+  _isFileUnchanged(diff: DiffInfo) {
+    if (!diff || !diff.content) return false;
+    return !diff.content.some(
+      content =>
+        (content.a && !content.common) || (content.b && !content.common)
+    );
+  }
+
+  _paramsChanged(value: AppElementParams) {
+    if (value.view !== GerritView.DIFF) {
+      return;
+    }
+
+    this._change = undefined;
+    this._files = {sortedFileList: [], changeFilesByPath: {}};
+    this._path = undefined;
+    this._patchRange = undefined;
+    this._commitRange = undefined;
+    this._changeComments = undefined;
+    this._focusLineNum = undefined;
+
+    if (value.changeNum && value.project) {
+      this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
+    }
+
+    this._changeNum = value.changeNum;
+    this.classList.remove('hideComments');
+
+    // When navigating away from the page, there is a possibility that the
+    // patch number is no longer a part of the URL (say when navigating to
+    // the top-level change info view) and therefore undefined in `params`.
+    // If route is of type /comment/<commentId>/ then no patchNum is present
+    if (!value.patchNum && !value.commentLink) {
+      console.warn('invalid url, no patchNum found');
+      return;
+    }
+
+    const portedCommentsPromise = this.$.commentAPI.getPortedComments(
+      value.changeNum,
+      value.patchNum || 'current'
+    );
+
+    const promises: Promise<unknown>[] = [];
+
+    promises.push(this._getDiffPreferences());
+
+    promises.push(
+      this._getPreferences().then(prefs => {
+        this._userPrefs = prefs;
+      })
+    );
+
+    promises.push(this._getChangeDetail(this._changeNum));
+    promises.push(this._loadComments());
+
+    promises.push(this._getChangeEdit());
+
+    this.$.diffHost.cancel();
+    this.$.diffHost.clearDiffContent();
+    this._loading = true;
+    return Promise.all(promises)
+      .then(r => {
+        this.reporting.time(PORTING_COMMENTS_DIFF_LATENCY_LABEL);
+        this._loading = false;
+        this._initPatchRange();
+        this._initCommitRange();
+        if (this._changeComments && this._path && this._patchRange) {
+          this.$.diffHost.threads = this._changeComments.getThreadsBySideForPath(
+            this._path,
+            this._patchRange,
+            this._projectConfig
+          );
+        }
+        portedCommentsPromise.then(() => {
+          this.reporting.timeEnd(PORTING_COMMENTS_DIFF_LATENCY_LABEL);
+        });
+        const edit = r[4] as EditInfo | undefined;
+        if (edit) {
+          this.set(`_change.revisions.${edit.commit.commit}`, {
+            _number: EditPatchSetNum,
+            basePatchNum: edit.base_patch_set_number,
+            commit: edit.commit,
+          });
+        }
+        return this.$.diffHost.reload(true);
+      })
+      .then(() => {
+        this.reporting.diffViewFullyLoaded();
+        // If diff view displayed has not ended yet, it ends here.
+        this.reporting.diffViewDisplayed();
+      })
+      .then(() => {
+        if (!this._diff) throw new Error('Missing this._diff');
+        const fileUnchanged = this._isFileUnchanged(this._diff);
+        if (fileUnchanged && value.commentLink) {
+          if (!this._change) throw new Error('Missing this._change');
+          if (!this._path) throw new Error('Missing this._path');
+          if (!this._patchRange) throw new Error('Missing this._patchRange');
+
+          if (this._patchRange.basePatchNum === ParentPatchSetNum) {
+            // file is unchanged between Base vs X
+            // hence should not show diff between Base vs Base
+            return;
+          }
+
+          fireAlert(
+            this,
+            `File is unchanged between Patchset
+                  ${this._patchRange.basePatchNum} and
+                  ${this._patchRange.patchNum}. Showing diff of Base vs
+                  ${this._patchRange.basePatchNum}`
+          );
+          GerritNav.navigateToDiff(
+            this._change,
+            this._path,
+            this._patchRange.basePatchNum,
+            ParentPatchSetNum,
+            this._focusLineNum
+          );
+          return;
+        }
+        if (value.commentLink) {
+          this._displayToasts();
+        }
+        // If the blame was loaded for a previous file and user navigates to
+        // another file, then we load the blame for this file too
+        if (this._isBlameLoaded) this._loadBlame();
+      });
+  }
+
+  _changeViewStateChanged(changeViewState: Partial<ChangeViewState>) {
+    if (changeViewState.diffMode === null) {
+      // If screen size is small, always default to unified view.
+      this.$.restAPI.getPreferences().then(prefs => {
+        if (prefs) {
+          this.set('changeViewState.diffMode', prefs.default_diff_view);
+        }
+      });
+    }
+  }
+
+  @observe('_loggedIn', 'params.*', '_prefs', '_patchRange.*')
+  _setReviewedObserver(
+    _loggedIn?: boolean,
+    paramsRecord?: ElementPropertyDeepChange<GrDiffView, 'params'>,
+    _prefs?: DiffPreferencesInfo,
+    patchRangeRecord?: ElementPropertyDeepChange<GrDiffView, '_patchRange'>
+  ) {
+    if (_loggedIn === undefined) return;
+    if (paramsRecord === undefined) return;
+    if (_prefs === undefined) return;
+    if (patchRangeRecord === undefined) return;
+    if (patchRangeRecord.base === undefined) return;
+
+    const patchRange = patchRangeRecord.base;
+    if (!_loggedIn) {
+      return;
+    }
+
+    if (_prefs.manual_review) {
+      // Checkbox state needs to be set explicitly only when manual_review
+      // is specified.
+
+      if (patchRange.patchNum) {
+        this._getReviewedStatus(
+          this._editMode,
+          this._changeNum,
+          patchRange.patchNum,
+          this._path
+        ).then((status: boolean) => {
+          this.$.reviewed.checked = status;
+        });
+      }
+      return;
+    }
+
+    if (paramsRecord.base?.view === GerritNav.View.DIFF) {
+      this._setReviewed(true);
+    }
+  }
+
+  /**
+   * If the params specify a diff address then configure the diff cursor.
+   */
+  _initCursor(leftSide: boolean) {
+    if (this._focusLineNum === undefined) {
+      return;
+    }
+    if (leftSide) {
+      this.$.cursor.side = Side.LEFT;
+    } else {
+      this.$.cursor.side = Side.RIGHT;
+    }
+    this.$.cursor.initialLineNumber = this._focusLineNum;
+  }
+
+  _getLineOfInterest(leftSide: boolean): LineOfInterest | undefined {
+    // If there is a line number specified, pass it along to the diff so that
+    // it will not get collapsed.
+    if (!this._focusLineNum) {
+      return undefined;
+    }
+
+    return {number: this._focusLineNum, leftSide};
+  }
+
+  _pathChanged(path: string) {
+    if (path) {
+      fireTitleChange(this, computeTruncatedPath(path));
+    }
+
+    if (!this._fileList || this._fileList.length === 0) return;
+
+    this.set('changeViewState.selectedFileIndex', this._fileList.indexOf(path));
+  }
+
+  _getDiffUrl(change?: ChangeInfo, patchRange?: PatchRange, path?: string) {
+    if (!change || !patchRange || !path) return '';
+    return GerritNav.getUrlForDiff(
+      change,
+      path,
+      patchRange.patchNum,
+      patchRange.basePatchNum
+    );
+  }
+
+  _patchRangeStr(patchRange: PatchRange) {
+    let patchStr = `${patchRange.patchNum}`;
+    if (
+      patchRange.basePatchNum &&
+      patchRange.basePatchNum !== ParentPatchSetNum
+    ) {
+      patchStr = `${patchRange.basePatchNum}..${patchRange.patchNum}`;
+    }
+    return patchStr;
+  }
+
+  /**
+   * When the latest patch of the change is selected (and there is no base
+   * patch) then the patch range need not appear in the URL. Return a patch
+   * range object with undefined values when a range is not needed.
+   */
+  _getChangeUrlRange(
+    patchRange?: PatchRange,
+    revisions?: {[revisionId: string]: RevisionInfo}
+  ) {
+    let patchNum = undefined;
+    let basePatchNum = undefined;
+    let latestPatchNum = -1;
+    for (const rev of Object.values(revisions || {})) {
+      if (typeof rev._number === 'number') {
+        latestPatchNum = Math.max(latestPatchNum, rev._number);
+      }
+    }
+    if (!patchRange) return {patchNum, basePatchNum};
+    if (
+      patchRange.basePatchNum !== ParentPatchSetNum ||
+      !patchNumEquals(patchRange.patchNum, latestPatchNum as PatchSetNum)
+    ) {
+      patchNum = patchRange.patchNum;
+      basePatchNum = patchRange.basePatchNum;
+    }
+    return {patchNum, basePatchNum};
+  }
+
+  _getChangePath(
+    change?: ChangeInfo,
+    patchRange?: PatchRange,
+    revisions?: {[revisionId: string]: RevisionInfo}
+  ) {
+    if (!change) return '';
+    if (!patchRange) return '';
+
+    const range = this._getChangeUrlRange(patchRange, revisions);
+    return GerritNav.getUrlForChange(
+      change,
+      range.patchNum,
+      range.basePatchNum
+    );
+  }
+
+  _navigateToChange(
+    change?: ChangeInfo,
+    patchRange?: PatchRange,
+    revisions?: {[revisionId: string]: RevisionInfo}
+  ) {
+    if (!change) return;
+    const range = this._getChangeUrlRange(patchRange, revisions);
+    GerritNav.navigateToChange(change, range.patchNum, range.basePatchNum);
+  }
+
+  _computeChangePath(
+    change?: ChangeInfo,
+    patchRangeRecord?: PolymerDeepPropertyChange<PatchRange, PatchRange>,
+    revisions?: {[revisionId: string]: RevisionInfo}
+  ) {
+    if (!patchRangeRecord) return '';
+    return this._getChangePath(change, patchRangeRecord.base, revisions);
+  }
+
+  _formatFilesForDropdown(
+    files?: Files,
+    patchNum?: PatchSetNum,
+    changeComments?: ChangeComments
+  ): DropdownItem[] {
+    if (!files) return [];
+    if (!patchNum) return [];
+    if (!changeComments) return [];
+
+    const dropdownContent: DropdownItem[] = [];
+    for (const path of files.sortedFileList) {
+      dropdownContent.push({
+        text: computeDisplayPath(path),
+        mobileText: computeTruncatedPath(path),
+        value: path,
+        bottomText: this._computeCommentString(
+          changeComments,
+          patchNum,
+          path,
+          files.changeFilesByPath[path]
+        ),
+      });
+    }
+    return dropdownContent;
+  }
+
+  _computeCommentString(
+    changeComments?: ChangeComments,
+    patchNum?: PatchSetNum,
+    path?: string,
+    changeFileInfo?: FileInfo
+  ) {
+    if (!changeComments) return '';
+    if (!path) return '';
+    if (!changeFileInfo) return '';
+
+    const unresolvedCount = changeComments.computeUnresolvedNum({
+      patchNum,
+      path,
+    });
+    const commentThreadCount = changeComments.computeCommentThreadCount({
+      patchNum,
+      path,
+    });
+    const commentThreadString = GrCountStringFormatter.computePluralString(
+      commentThreadCount,
+      'comment'
+    );
+    const unresolvedString = GrCountStringFormatter.computeString(
+      unresolvedCount,
+      'unresolved'
+    );
+
+    const unmodifiedString = changeFileInfo.status === 'U' ? 'no changes' : '';
+
+    return [unmodifiedString, commentThreadString, unresolvedString]
+      .filter(v => v && v.length > 0)
+      .join(', ');
+  }
+
+  _computePrefsButtonHidden(
+    prefs?: DiffPreferencesInfo,
+    prefsDisabled?: boolean
+  ) {
+    return prefsDisabled || !prefs;
+  }
+
+  _handleFileChange(e: CustomEvent) {
+    if (!this._change) return;
+    if (!this._patchRange) return;
+
+    // This is when it gets set initially.
+    const path = e.detail.value;
+    if (path === this._path) {
+      return;
+    }
+
+    GerritNav.navigateToDiff(
+      this._change,
+      path,
+      this._patchRange.patchNum,
+      this._patchRange.basePatchNum
+    );
+  }
+
+  _handlePatchChange(e: CustomEvent) {
+    if (!this._change) return;
+    if (!this._path) return;
+    if (!this._patchRange) return;
+
+    const {basePatchNum, patchNum} = e.detail;
+    if (
+      patchNumEquals(basePatchNum, this._patchRange.basePatchNum) &&
+      patchNumEquals(patchNum, this._patchRange.patchNum)
+    ) {
+      return;
+    }
+    GerritNav.navigateToDiff(this._change, this._path, patchNum, basePatchNum);
+  }
+
+  _handlePrefsTap(e: Event) {
+    e.preventDefault();
+    this.$.diffPreferencesDialog.open();
+  }
+
+  /**
+   * _getDiffViewMode: Get the diff view (side-by-side or unified) based on
+   * the current state.
+   *
+   * The expected behavior is to use the mode specified in the user's
+   * preferences unless they have manually chosen the alternative view or they
+   * are on a mobile device. If the user navigates up to the change view, it
+   * should clear this choice and revert to the preference the next time a
+   * diff is viewed.
+   *
+   * Use side-by-side if the user is not logged in.
+   */
+  _getDiffViewMode() {
+    if (this.changeViewState.diffMode) {
+      return this.changeViewState.diffMode;
+    } else if (this._userPrefs) {
+      this.set('changeViewState.diffMode', this._userPrefs.default_diff_view);
+      return this._userPrefs.default_diff_view;
+    } else {
+      return 'SIDE_BY_SIDE';
+    }
+  }
+
+  _computeModeSelectHideClass(diff?: DiffInfo) {
+    return !diff || diff.binary ? 'hide' : '';
+  }
+
+  _onLineSelected(
+    _: Event,
+    detail: {side: Side | CommentSide; number: number}
+  ) {
+    // for on-comment-anchor-tap side can be PARENT/REVISIONS
+    // for on-line-selected side can be left/right
+    this._updateUrlToDiffUrl(
+      detail.number,
+      detail.side === Side.LEFT || detail.side === CommentSide.PARENT
+    );
+  }
+
+  _computeDownloadDropdownLinks(
+    project?: RepoName,
+    changeNum?: NumericChangeId,
+    patchRange?: PatchRange,
+    path?: string,
+    diff?: DiffInfo
+  ) {
+    if (!project) return [];
+    if (!changeNum) return [];
+    if (!patchRange || !patchRange.patchNum) return [];
+    if (!path) return [];
+
+    const links = [
+      {
+        url: this._computeDownloadPatchLink(
+          project,
+          changeNum,
+          patchRange,
+          path
+        ),
+        name: 'Patch',
+      },
+    ];
+
+    if (diff && diff.meta_a) {
+      let leftPath = path;
+      if (diff.change_type === 'RENAMED') {
+        leftPath = diff.meta_a.name;
+      }
+      links.push({
+        url: this._computeDownloadFileLink(
+          project,
+          changeNum,
+          patchRange,
+          leftPath,
+          true
+        ),
+        name: 'Left Content',
+      });
+    }
+
+    if (diff && diff.meta_b) {
+      links.push({
+        url: this._computeDownloadFileLink(
+          project,
+          changeNum,
+          patchRange,
+          path,
+          false
+        ),
+        name: 'Right Content',
+      });
+    }
+
+    return links;
+  }
+
+  _computeDownloadFileLink(
+    project: RepoName,
+    changeNum: NumericChangeId,
+    patchRange: PatchRange,
+    path: string,
+    isBase?: boolean
+  ) {
+    let patchNum = patchRange.patchNum;
+
+    const comparedAgainsParent = patchRange.basePatchNum === 'PARENT';
+
+    if (isBase && !comparedAgainsParent) {
+      patchNum = patchRange.basePatchNum;
+    }
+
+    let url =
+      changeBaseURL(project, changeNum, patchNum) +
+      `/files/${encodeURIComponent(path)}/download`;
+
+    if (isBase && comparedAgainsParent) {
+      url += '?parent=1';
+    }
+
+    return url;
+  }
+
+  _computeDownloadPatchLink(
+    project: RepoName,
+    changeNum: NumericChangeId,
+    patchRange: PatchRange,
+    path: string
+  ) {
+    let url = changeBaseURL(project, changeNum, patchRange.patchNum);
+    url += '/patch?zip&path=' + encodeURIComponent(path);
+    return url;
+  }
+
+  _loadComments() {
+    if (!this._changeNum) throw new Error('Missing this._changeNum');
+    return this.$.commentAPI.loadAll(this._changeNum).then(comments => {
+      this._changeComments = comments;
+    });
+  }
+
+  @observe('_files.changeFilesByPath', '_path', '_patchRange', '_projectConfig')
+  _recomputeComments(
+    files?: {[path: string]: FileInfo},
+    path?: string,
+    patchRange?: PatchRange,
+    projectConfig?: ConfigInfo
+  ) {
+    if (!files) return;
+    if (!path) return;
+    if (!patchRange) return;
+    if (!projectConfig) return;
+    if (!this._changeComments) return;
+
+    const file = files[path];
+    if (file && file.old_path) {
+      this.$.diffHost.threads = this._changeComments.getThreadsBySideForFile(
+        {path, basePath: file.old_path},
+        patchRange,
+        projectConfig
+      );
+    }
+  }
+
+  _getPaths(patchRange: PatchRange) {
+    if (!this._changeComments) return {};
+    return this._changeComments.getPaths(patchRange);
+  }
+
+  _getDiffDrafts() {
+    if (!this._changeNum) throw new Error('Missing this._changeNum');
+
+    return this.$.restAPI.getDiffDrafts(this._changeNum);
+  }
+
+  _computeCommentSkips(
+    commentMap?: CommentMap,
+    fileList?: string[],
+    path?: string
+  ) {
+    if (!commentMap) return undefined;
+    if (!fileList) return undefined;
+    if (!path) return undefined;
+
+    const skips: CommentSkips = {previous: null, next: null};
+    if (!fileList.length) {
+      return skips;
+    }
+    const pathIndex = fileList.indexOf(path);
+
+    // Scan backward for the previous file.
+    for (let i = pathIndex - 1; i >= 0; i--) {
+      if (commentMap[fileList[i]]) {
+        skips.previous = fileList[i];
+        break;
+      }
+    }
+
+    // Scan forward for the next file.
+    for (let i = pathIndex + 1; i < fileList.length; i++) {
+      if (commentMap[fileList[i]]) {
+        skips.next = fileList[i];
+        break;
+      }
+    }
+
+    return skips;
+  }
+
+  _computeContainerClass(editMode: boolean) {
+    return editMode ? 'editMode' : '';
+  }
+
+  _computeEditMode(
+    patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>
+  ) {
+    const patchRange = patchRangeRecord.base || {};
+    return patchNumEquals(patchRange.patchNum, EditPatchSetNum);
+  }
+
+  _computeBlameToggleLabel(loaded?: boolean, loading?: boolean) {
+    return loaded && !loading ? 'Hide blame' : 'Show blame';
+  }
+
+  _loadBlame() {
+    this._isBlameLoading = true;
+    fireAlert(this, MSG_LOADING_BLAME);
+    this.$.diffHost
+      .loadBlame()
+      .then(() => {
+        this._isBlameLoading = false;
+        fireAlert(this, MSG_LOADED_BLAME);
+      })
+      .catch(() => {
+        this._isBlameLoading = false;
+      });
+  }
+
+  /**
+   * Load and display blame information if it has not already been loaded.
+   * Otherwise hide it.
+   */
+  _toggleBlame() {
+    if (this._isBlameLoaded) {
+      this.$.diffHost.clearBlame();
+      return;
+    }
+    this._loadBlame();
+  }
+
+  _handleToggleBlame(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+
+    this._toggleBlame();
+  }
+
+  _handleToggleHideAllCommentThreads(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+
+    this.toggleClass('hideComments');
+  }
+
+  _handleOpenFileList(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+    this.$.dropdown.open();
+  }
+
+  _handleDiffAgainstBase(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (!this._change) return;
+    if (!this._path) return;
+    if (!this._patchRange) return;
+
+    if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+      fireAlert(this, 'Base is already selected.');
+      return;
+    }
+    GerritNav.navigateToDiff(
+      this._change,
+      this._path,
+      this._patchRange.patchNum
+    );
+  }
+
+  _handleDiffBaseAgainstLeft(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (!this._change) return;
+    if (!this._path) return;
+    if (!this._patchRange) return;
+
+    if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+      fireAlert(this, 'Left is already base.');
+      return;
+    }
+    GerritNav.navigateToDiff(
+      this._change,
+      this._path,
+      this._patchRange.basePatchNum,
+      'PARENT' as PatchSetNum,
+      this.params?.view === GerritView.DIFF && this.params?.commentLink
+        ? this._focusLineNum
+        : undefined
+    );
+  }
+
+  _handleDiffAgainstLatest(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (!this._change) return;
+    if (!this._path) return;
+    if (!this._patchRange) return;
+
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+      fireAlert(this, 'Latest is already selected.');
+      return;
+    }
+
+    GerritNav.navigateToDiff(
+      this._change,
+      this._path,
+      latestPatchNum,
+      this._patchRange.basePatchNum
+    );
+  }
+
+  _handleDiffRightAgainstLatest(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (!this._change) return;
+    if (!this._path) return;
+    if (!this._patchRange) return;
+
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+      fireAlert(this, 'Right is already latest.');
+      return;
+    }
+    GerritNav.navigateToDiff(
+      this._change,
+      this._path,
+      latestPatchNum,
+      this._patchRange.patchNum
+    );
+  }
+
+  _handleDiffBaseAgainstLatest(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (!this._change) return;
+    if (!this._path) return;
+    if (!this._patchRange) return;
+
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (
+      patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
+      patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)
+    ) {
+      fireAlert(this, 'Already diffing base against latest.');
+      return;
+    }
+    GerritNav.navigateToDiff(this._change, this._path, latestPatchNum);
+  }
+
+  _computeBlameLoaderClass(isImageDiff?: boolean, path?: string) {
+    return !isMagicPath(path) && !isImageDiff ? 'show' : '';
+  }
+
+  _getRevisionInfo(change: ChangeInfo) {
+    return new RevisionInfoObj(change);
+  }
+
+  _computeFileNum(file?: string, files?: DropdownItem[]) {
+    if (!file || !files) return undefined;
+
+    return files.findIndex(({value}) => value === file) + 1;
+  }
+
+  _computeFileNumClass(fileNum?: number, files?: DropdownItem[]) {
+    if (files && fileNum && fileNum > 0) {
+      return 'show';
+    }
+    return '';
+  }
+
+  _handleExpandAllDiffContext(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+    this.$.diffHost.expandAllContext();
+  }
+
+  _computeDiffPrefsDisabled(disableDiffPrefs?: boolean, loggedIn?: boolean) {
+    return disableDiffPrefs || !loggedIn;
+  }
+
+  _handleNextUnreviewedFile(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (!this._path) return;
+    if (!this._fileList) return;
+    if (!this._reviewedFiles) return;
+
+    this._setReviewed(true);
+    // Ensure that the currently viewed file always appears in unreviewedFiles
+    // so we resolve the right "next" file.
+    const unreviewedFiles = this._fileList.filter(
+      file => file === this._path || !this._reviewedFiles.has(file)
+    );
+    this._navToFile(this._path, unreviewedFiles, 1);
+  }
+
+  _handleReloadingDiffPreference() {
+    this._getDiffPreferences();
+  }
+
+  _computeCanEdit(
+    loggedIn?: boolean,
+    changeChangeRecord?: PolymerDeepPropertyChange<ChangeInfo, ChangeInfo>
+  ) {
+    if (!changeChangeRecord?.base) return false;
+    return loggedIn && changeIsOpen(changeChangeRecord.base);
+  }
+
+  _computeIsLoggedIn(loggedIn: boolean) {
+    return loggedIn ? true : false;
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeAllPatchSets(change: ChangeInfo) {
+    return computeAllPatchSets(change);
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeDisplayPath(path: string) {
+    return computeDisplayPath(path);
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeTruncatedPath(path?: string) {
+    return path ? computeTruncatedPath(path) : '';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-view': GrDiffView;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
index ef3e848..be2dce5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
@@ -210,6 +210,9 @@
     }
   </style>
   <div class$="stickyHeader [[_computeContainerClass(_editMode)]]">
+    <h1 class="assistive-tech-only">
+      Diff of [[_computeTruncatedPath(_path)]]
+    </h1>
     <header>
       <div>
         <a
@@ -387,6 +390,7 @@
     </div>
   </div>
   <div class="loading" hidden$="[[!_loading]]">Loading...</div>
+  <h2 class="assistive-tech-only">Diff view</h2>
   <gr-diff-host
     id="diffHost"
     hidden=""
@@ -395,6 +399,7 @@
     files-weblinks="{{_filesWeblinks}}"
     diff="{{_diff}}"
     change-num="[[_changeNum]]"
+    change="[[_change]]"
     commit-range="[[_commitRange]]"
     patch-range="[[_patchRange]]"
     file="[[_file]]"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index 5429ea2..8e1e5c1 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -19,11 +19,16 @@
 import './gr-diff-view.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {ChangeStatus} from '../../../constants/constants.js';
-import {generateChange, TestKeyboardShortcutBinder} from '../../../test/test-utils';
+import {TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
 import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
 import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 import {_testOnly_findCommentById} from '../gr-comment-api/gr-comment-api.js';
 import {appContext} from '../../../services/app-context.js';
+import {GerritView} from '../../core/gr-navigation/gr-navigation.js';
+import {
+  createChange,
+  createRevisions,
+} from '../../../test/test-data-generators.js';
 
 const basicFixture = fixtureFromElement('gr-diff-view');
 
@@ -63,6 +68,7 @@
       kb.bindShortcut(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, 'shift+e');
       kb.bindShortcut(Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
       kb.bindShortcut(Shortcut.TOGGLE_BLAME, 'b');
+      kb.bindShortcut(Shortcut.OPEN_FILE_LIST, 'f');
     });
 
     suiteTeardown(() => {
@@ -82,7 +88,7 @@
       };
     }
 
-    setup(() => {
+    setup(async () => {
       clock = sinon.useFakeTimers();
       sinon.stub(appContext.flagsService, 'isEnabled').returns(true);
       stub('gr-rest-api-interface', {
@@ -118,16 +124,40 @@
         },
       });
       element = basicFixture.instantiate();
+      element._changeNum = '42';
+      element._path = 'some/path.txt';
+      element._change = {};
+      element._diff = {content: []};
+      element._patchRange = {
+        patchNum: 77,
+        basePatchNum: 'PARENT',
+      };
       sinon.stub(element.$.commentAPI, 'loadAll').returns(Promise.resolve({
-        _comments: {'/COMMIT_MSG': [{id: 'c1', line: 10, patch_set: 2,
-          __commentSide: 'left', path: '/COMMIT_MSG'}]},
-        computeCommentCount: () => {},
+        _comments: {'/COMMIT_MSG': [
+          {
+            id: 'c1',
+            line: 10,
+            patch_set: 2,
+            __commentSide: 'left',
+            path: '/COMMIT_MSG',
+          }, {
+            id: 'c3',
+            line: 10,
+            patch_set: 'PARENT',
+            __commentSide: 'left',
+            path: '/COMMIT_MSG',
+          },
+        ]},
+        computeCommentThreadCount: () => {},
         computeUnresolvedNum: () => {},
         getPaths: () => {},
-        getCommentsBySideForPath: () => {},
+        getThreadsBySideForPath: () => {},
+        getThreadsBySideForFile: () => {},
         findCommentById: _testOnly_findCommentById,
+
       }));
-      return element._loadComments();
+      await element._loadComments();
+      await flush();
     });
 
     teardown(() => {
@@ -144,8 +174,8 @@
       element.params = {
         view: GerritNav.View.DIFF,
         changeNum: '42',
-        patchNum: '2',
-        basePatchNum: '1',
+        patchNum: 2,
+        basePatchNum: 1,
         path: '/COMMIT_MSG',
       };
       return element._paramsChanged.returnValues[0].then(() => {
@@ -156,30 +186,36 @@
     test('comment route', () => {
       const initLineOfInterestAndCursorStub =
         sinon.stub(element, '_initLineOfInterestAndCursor');
+      const getUrlStub = sinon.stub(GerritNav, 'getUrlForDiffById');
+      const replaceStateStub = sinon.stub(history, 'replaceState');
       sinon.stub(element, '_getFiles');
       sinon.stub(element.reporting, 'diffViewDisplayed');
       sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
       sinon.spy(element, '_paramsChanged');
-      sinon.stub(element, '_getChangeDetail').returns(Promise.resolve(
-          generateChange({revisionsCount: 11})));
+      sinon.stub(element, '_getChangeDetail').returns(Promise.resolve({
+        ...createChange(),
+        revisions: createRevisions(11),
+      }));
       element.params = {
         view: GerritNav.View.DIFF,
         changeNum: '42',
         commentLink: true,
         commentId: 'c1',
       };
-      sinon.stub(element.$.diffHost, '_commentsChanged');
-      sinon.stub(element, '_getCommentsForPath').returns({
-        left: [{id: 'c1', __commentSide: 'left', line: 10}],
-        right: [{id: 'c2', __commentSide: 'right', line: 11}],
-      });
-      element._change = generateChange({revisionsCount: 11});
+      element._change = {
+        ...createChange(),
+        revisions: createRevisions(11),
+      };
       return element._paramsChanged.returnValues[0].then(() => {
         assert.isTrue(initLineOfInterestAndCursorStub.
             calledWithExactly(true));
         assert.equal(element._focusLineNum, 10);
         assert.equal(element._patchRange.patchNum, 11);
         assert.equal(element._patchRange.basePatchNum, 2);
+
+        assert.isTrue(replaceStateStub.called);
+        assert.isTrue(getUrlStub.calledWithExactly('42', 'test-project',
+            '/COMMIT_MSG', 11, 2, 10, true));
       });
     });
 
@@ -195,8 +231,8 @@
       element.params = {
         view: GerritNav.View.DIFF,
         changeNum: '42',
-        patchNum: '2',
-        basePatchNum: '1',
+        patchNum: 2,
+        basePatchNum: 1,
         path: '/COMMIT_MSG',
       };
       return element._paramsChanged.returnValues[0].then(() => {
@@ -213,8 +249,10 @@
           sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
           sinon.stub(element, '_isFileUnchanged').returns(true);
           sinon.spy(element, '_paramsChanged');
-          sinon.stub(element, '_getChangeDetail').returns(Promise.resolve(
-              generateChange({revisionsCount: 11})));
+          sinon.stub(element, '_getChangeDetail').returns(Promise.resolve({
+            ...createChange(),
+            revisions: createRevisions(11),
+          }));
           element.params = {
             view: GerritNav.View.DIFF,
             changeNum: '42',
@@ -222,22 +260,44 @@
             commentLink: true,
             commentId: 'c1',
           };
-          element._patchRange = {
-            patchNum: '2',
+          element._change = {
+            ...createChange(),
+            revisions: createRevisions(11),
           };
-          sinon.stub(element.$.diffHost, '_commentsChanged');
-          sinon.stub(element, '_getCommentsForPath').returns({
-            left: [{id: 'c1', __commentSide: 'left', line: 10, path:
-              '/COMMIT_MSG'}],
-            right: [{id: 'c2', __commentSide: 'right', line: 11}],
-          });
-          element._change = generateChange({revisionsCount: 11});
           return element._paramsChanged.returnValues[0].then(() => {
             assert.isTrue(diffNavStub.lastCall.calledWithExactly(
                 element._change, '/COMMIT_MSG', 2, 'PARENT', 10));
           });
         });
 
+    test('unchanged diff Base vs latest from comment does not navigate'
+        , () => {
+          const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+          sinon.stub(element.reporting, 'diffViewDisplayed');
+          sinon.stub(element, '_loadBlame');
+          sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
+          sinon.stub(element, '_isFileUnchanged').returns(true);
+          sinon.spy(element, '_paramsChanged');
+          sinon.stub(element, '_getChangeDetail').returns(Promise.resolve({
+            ...createChange(),
+            revisions: createRevisions(11),
+          }));
+          element.params = {
+            view: GerritNav.View.DIFF,
+            changeNum: '42',
+            path: '/COMMIT_MSG',
+            commentLink: true,
+            commentId: 'c3',
+          };
+          element._change = {
+            ...createChange(),
+            revisions: createRevisions(11),
+          };
+          return element._paramsChanged.returnValues[0].then(() => {
+            assert.isFalse(diffNavStub.called);
+          });
+        });
+
     test('_isFileUnchanged', () => {
       let diff = {
         content: [
@@ -269,13 +329,25 @@
       assert.equal(element._isFileUnchanged(diff), true);
     });
 
-    test('diff toast to go to latest is shown and not base', () => {
+    test('diff toast to go to latest is shown and not base', async () => {
       sinon.stub(element.reporting, 'diffViewDisplayed');
       sinon.stub(element, '_loadBlame');
       sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
       sinon.spy(element, '_paramsChanged');
-      sinon.stub(element, '_getChangeDetail').returns(Promise.resolve(
-          generateChange({revisionsCount: 11})));
+      element.$.restAPI.getDiffChangeDetail.restore();
+      sinon.stub(element.$.restAPI, 'getDiffChangeDetail')
+          .returns(
+              Promise.resolve({
+                ...createChange(),
+                revisions: createRevisions(11),
+              }));
+      element._patchRange = {
+        patchNum: 2,
+        basePatchNum: 1,
+      };
+      sinon.stub(element, '_isFileUnchanged').returns(false);
+      const toastStub =
+          sinon.stub(element, '_displayDiffBaseAgainstLeftToast');
       element.params = {
         view: GerritNav.View.DIFF,
         changeNum: '42',
@@ -283,12 +355,8 @@
         commentId: 'c1',
         commentLink: true,
       };
-      element._change = generateChange({revisionsCount: 11});
-      const toastStub =
-        sinon.stub(element, '_displayDiffBaseAgainstLeftToast');
-      return element._paramsChanged.returnValues[0].then(() => {
-        assert.isTrue(toastStub.called);
-      });
+      await element._paramsChanged.returnValues[0];
+      assert.isTrue(toastStub.called);
     });
 
     test('toggle left diff with a hotkey', () => {
@@ -302,7 +370,7 @@
       element._changeNum = '42';
       element._patchRange = {
         basePatchNum: PARENT,
-        patchNum: '10',
+        patchNum: 10,
       };
       element._change = {
         _number: 42,
@@ -325,20 +393,20 @@
 
       MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
       assert(diffNavStub.lastCall.calledWith(element._change, 'wheatley.md',
-          '10', PARENT), 'Should navigate to /c/42/10/wheatley.md');
+          10, PARENT), 'Should navigate to /c/42/10/wheatley.md');
       element._path = 'wheatley.md';
       assert.equal(element.changeViewState.selectedFileIndex, 2);
       assert.isTrue(element._loading);
 
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(diffNavStub.lastCall.calledWith(element._change, 'glados.txt',
-          '10', PARENT), 'Should navigate to /c/42/10/glados.txt');
+          10, PARENT), 'Should navigate to /c/42/10/glados.txt');
       element._path = 'glados.txt';
       assert.equal(element.changeViewState.selectedFileIndex, 1);
       assert.isTrue(element._loading);
 
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(diffNavStub.lastCall.calledWith(element._change, 'chell.go', '10',
+      assert(diffNavStub.lastCall.calledWith(element._change, 'chell.go', 10,
           PARENT), 'Should navigate to /c/42/10/chell.go');
       element._path = 'chell.go';
       assert.equal(element.changeViewState.selectedFileIndex, 0);
@@ -415,8 +483,8 @@
 
     test('diff against base', () => {
       element._patchRange = {
-        basePatchNum: '5',
-        patchNum: '10',
+        basePatchNum: 5,
+        patchNum: 10,
       };
       sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
       const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
@@ -427,10 +495,13 @@
     });
 
     test('diff against latest', () => {
-      element._change = generateChange({revisionsCount: 12});
+      element._change = {
+        ...createChange(),
+        revisions: createRevisions(12),
+      };
       element._patchRange = {
-        basePatchNum: '5',
-        patchNum: '10',
+        basePatchNum: 5,
+        patchNum: 10,
       };
       sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
       const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
@@ -441,22 +512,53 @@
     });
 
     test('_handleDiffBaseAgainstLeft', () => {
-      element._change = generateChange({revisionsCount: 10});
+      element._change = {
+        ...createChange(),
+        revisions: createRevisions(10),
+      };
       element._patchRange = {
         patchNum: 3,
         basePatchNum: 1,
       };
+      element.params = {};
       sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
       const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
       element._handleDiffBaseAgainstLeft(new CustomEvent(''));
       assert(diffNavStub.called);
       const args = diffNavStub.getCall(0).args;
       assert.equal(args[2], 1);
-      assert.isNotOk(args[3]);
+      assert.equal(args[3], 'PARENT');
+      assert.isNotOk(args[4]);
     });
 
+    test('_handleDiffBaseAgainstLeft when initially navigating to a comment',
+        () => {
+          element._change = {
+            ...createChange(),
+            revisions: createRevisions(10),
+          };
+          element._patchRange = {
+            patchNum: 3,
+            basePatchNum: 1,
+          };
+          sinon.stub(element, '_paramsChanged');
+          element.params = {commentLink: true, view: GerritView.DIFF};
+          element._focusLineNum = 10;
+          sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+          const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+          element._handleDiffBaseAgainstLeft(new CustomEvent(''));
+          assert(diffNavStub.called);
+          const args = diffNavStub.getCall(0).args;
+          assert.equal(args[2], 1);
+          assert.equal(args[3], 'PARENT');
+          assert.equal(args[4], 10);
+        });
+
     test('_handleDiffRightAgainstLatest', () => {
-      element._change = generateChange({revisionsCount: 10});
+      element._change = {
+        ...createChange(),
+        revisions: createRevisions(10),
+      };
       element._patchRange = {
         basePatchNum: 1,
         patchNum: 3,
@@ -471,7 +573,10 @@
     });
 
     test('_handleDiffBaseAgainstLatest', () => {
-      element._change = generateChange({revisionsCount: 10});
+      element._change = {
+        ...createChange(),
+        revisions: createRevisions(10),
+      };
       element._patchRange = {
         basePatchNum: 1,
         patchNum: 3,
@@ -488,8 +593,8 @@
     test('keyboard shortcuts with patch range', () => {
       element._changeNum = '42';
       element._patchRange = {
-        basePatchNum: '5',
-        patchNum: '10',
+        basePatchNum: 5,
+        patchNum: 10,
       };
       element._change = {
         _number: 42,
@@ -515,24 +620,24 @@
       MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
       assert.isTrue(element.changeViewState.showReplyDialog);
 
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, '10',
-          '5'), 'Should navigate to /c/42/5..10');
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, 10,
+          5), 'Should navigate to /c/42/5..10');
 
       MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, '10',
-          '5'), 'Should navigate to /c/42/5..10');
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, 10,
+          5), 'Should navigate to /c/42/5..10');
 
       MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
       assert.isTrue(element._loading);
       assert(diffNavStub.lastCall.calledWithExactly(element._change,
-          'wheatley.md', '10', '5'),
+          'wheatley.md', 10, 5),
       'Should navigate to /c/42/5..10/wheatley.md');
       element._path = 'wheatley.md';
 
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert.isTrue(element._loading);
       assert(diffNavStub.lastCall.calledWithExactly(element._change,
-          'glados.txt', '10', '5'),
+          'glados.txt', 10, 5),
       'Should navigate to /c/42/5..10/glados.txt');
       element._path = 'glados.txt';
 
@@ -541,15 +646,15 @@
       assert(diffNavStub.lastCall.calledWithExactly(
           element._change,
           'chell.go',
-          '10',
-          '5'),
+          10,
+          5),
       'Should navigate to /c/42/5..10/chell.go');
       element._path = 'chell.go';
 
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert.isTrue(element._loading);
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, '10',
-          '5'),
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, 10,
+          5),
       'Should navigate to /c/42/5..10');
 
       assert.isUndefined(element.changeViewState.showDownloadDialog);
@@ -561,7 +666,7 @@
       element._changeNum = '42';
       element._patchRange = {
         basePatchNum: PARENT,
-        patchNum: '1',
+        patchNum: 1,
       };
       element._change = {
         _number: 42,
@@ -587,22 +692,22 @@
       MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
       assert.isTrue(element.changeViewState.showReplyDialog);
 
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, '1',
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, 1,
           PARENT), 'Should navigate to /c/42/1');
 
       MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, '1',
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, 1,
           PARENT), 'Should navigate to /c/42/1');
 
       MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
       assert(diffNavStub.lastCall.calledWithExactly(element._change,
-          'wheatley.md', '1', PARENT),
+          'wheatley.md', 1, PARENT),
       'Should navigate to /c/42/1/wheatley.md');
       element._path = 'wheatley.md';
 
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert(diffNavStub.lastCall.calledWithExactly(element._change,
-          'glados.txt', '1', PARENT),
+          'glados.txt', 1, PARENT),
       'Should navigate to /c/42/1/glados.txt');
       element._path = 'glados.txt';
 
@@ -610,13 +715,15 @@
       assert(diffNavStub.lastCall.calledWithExactly(
           element._change,
           'chell.go',
-          '1',
+          1,
           PARENT), 'Should navigate to /c/42/1/chell.go');
       element._path = 'chell.go';
 
+      changeNavStub.reset();
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, '1',
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, 1,
           PARENT), 'Should navigate to /c/42/1');
+      assert.isTrue(changeNavStub.calledOnce);
     });
 
     test('edit should redirect to edit page', done => {
@@ -624,7 +731,7 @@
       element._path = 't.txt';
       element._patchRange = {
         basePatchNum: PARENT,
-        patchNum: '1',
+        patchNum: 1,
       };
       element._change = {
         _number: 42,
@@ -658,7 +765,7 @@
       element._path = 't.txt';
       element._patchRange = {
         basePatchNum: PARENT,
-        patchNum: '1',
+        patchNum: 1,
       };
       element._change = {
         _number: 42,
@@ -695,7 +802,7 @@
         element._path = 't.txt';
         element._patchRange = {
           basePatchNum: PARENT,
-          patchNum: '1',
+          patchNum: 1,
         };
         element._change = {
           _number: 42,
@@ -797,14 +904,14 @@
     test('_computeCommentString', done => {
       const path = '/test';
       element.$.commentAPI.loadAll().then(comments => {
-        const commentCountStub =
-            sinon.stub(comments, 'computeCommentCount');
+        const commentThreadCountStub =
+            sinon.stub(comments, 'computeCommentThreadCount');
         const unresolvedCountStub =
             sinon.stub(comments, 'computeUnresolvedNum');
-        commentCountStub.withArgs({patchNum: 1, path}).returns(0);
-        commentCountStub.withArgs({patchNum: 2, path}).returns(1);
-        commentCountStub.withArgs({patchNum: 3, path}).returns(2);
-        commentCountStub.withArgs({patchNum: 4, path}).returns(0);
+        commentThreadCountStub.withArgs({patchNum: 1, path}).returns(0);
+        commentThreadCountStub.withArgs({patchNum: 2, path}).returns(1);
+        commentThreadCountStub.withArgs({patchNum: 3, path}).returns(2);
+        commentThreadCountStub.withArgs({patchNum: 4, path}).returns(0);
         unresolvedCountStub.withArgs({patchNum: 1, path}).returns(1);
         unresolvedCountStub.withArgs({patchNum: 2, path}).returns(0);
         unresolvedCountStub.withArgs({patchNum: 3, path}).returns(2);
@@ -849,10 +956,10 @@
         element._changeNum = '42';
         element._patchRange = {
           basePatchNum: PARENT,
-          patchNum: '10',
+          patchNum: 10,
         };
-        // computeCommentCount is an empty function hence stubbing function
-        // that depends on it's return value
+        // computeCommentThreadCount is an empty function hence stubbing
+        // function that depends on it's return value
         sinon.stub(element, '_computeCommentString').returns('');
         element._change = {_number: 42};
         element._files = getFilesFromFileList(
@@ -898,7 +1005,7 @@
         element._changeNum = '42';
         element._patchRange = {
           basePatchNum: PARENT,
-          patchNum: '10',
+          patchNum: 10,
         };
         element._change = {
           _number: 42,
@@ -939,8 +1046,8 @@
       test('prev/up/next links with patch range', () => {
         element._changeNum = '42';
         element._patchRange = {
-          basePatchNum: '5',
-          patchNum: '10',
+          basePatchNum: 5,
+          patchNum: 10,
         };
         element._change = {
           _number: 42,
@@ -978,19 +1085,19 @@
 
       element._patchRange = {
         basePatchNum: 'PARENT',
-        patchNum: '3',
+        patchNum: 3,
       };
 
       const detail = {
         basePatchNum: 'PARENT',
-        patchNum: '1',
+        patchNum: 1,
       };
 
       element.$.rangeSelect.dispatchEvent(
           new CustomEvent('patch-range-change', {detail, bubbles: false}));
 
       assert(navigateStub.lastCall.calledWithExactly(element._change,
-          element._path, '1', 'PARENT'));
+          element._path, 1, 'PARENT'));
     });
 
     test('_prefs.manual_review is respected', () => {
@@ -1004,13 +1111,13 @@
       element.params = {
         view: GerritNav.View.DIFF,
         changeNum: '42',
-        patchNum: '2',
-        basePatchNum: '1',
+        patchNum: 2,
+        basePatchNum: 1,
         path: '/COMMIT_MSG',
       };
       element._patchRange = {
-        patchNum: '2',
-        basePatchNum: '1',
+        patchNum: 2,
+        basePatchNum: 1,
       };
       element._prefs = {manual_review: true};
       flush();
@@ -1034,13 +1141,13 @@
       element.params = {
         view: GerritNav.View.DIFF,
         changeNum: '42',
-        patchNum: '2',
-        basePatchNum: '1',
+        patchNum: 2,
+        basePatchNum: 1,
         path: '/COMMIT_MSG',
       };
       element._patchRange = {
-        patchNum: '2',
-        basePatchNum: '1',
+        patchNum: 2,
+        basePatchNum: 1,
       };
       element._prefs = {};
       flush();
@@ -1085,8 +1192,8 @@
       element.params = {
         view: GerritNav.View.DIFF,
         changeNum: '42',
-        patchNum: '2',
-        basePatchNum: '1',
+        patchNum: 2,
+        basePatchNum: 1,
         path: '/COMMIT_MSG',
         hash: 10,
       };
@@ -1187,8 +1294,8 @@
         element.params = {
           view: GerritNav.View.DIFF,
           changeNum: '42',
-          patchNum: '4',
-          basePatchNum: '2',
+          patchNum: 4,
+          basePatchNum: 2,
           path: '/COMMIT_MSG',
         };
         element._change = change;
@@ -1205,7 +1312,7 @@
         element.params = {
           view: GerritNav.View.DIFF,
           changeNum: '42',
-          patchNum: '5',
+          patchNum: 5,
           path: '/COMMIT_MSG',
         };
         element._change = change;
@@ -1223,42 +1330,42 @@
       assert.isNotOk(element.$.cursor.initialLineNumber);
 
       // Does nothing when params specify no cursor address:
-      element._initCursor({});
+      element._initCursor(false);
       assert.isNotOk(element.$.cursor.initialLineNumber);
 
       // Does nothing when params specify side but no number:
-      element._initCursor({leftSide: true});
+      element._initCursor(true);
       assert.isNotOk(element.$.cursor.initialLineNumber);
 
       // Revision hash: specifies lineNum but not side.
 
       element._focusLineNum = 234;
-      element._initCursor({});
+      element._initCursor(false);
       assert.equal(element.$.cursor.initialLineNumber, 234);
       assert.equal(element.$.cursor.side, 'right');
 
       // Base hash: specifies lineNum and side.
       element._focusLineNum = 345;
-      element._initCursor({leftSide: true});
+      element._initCursor(true);
       assert.equal(element.$.cursor.initialLineNumber, 345);
       assert.equal(element.$.cursor.side, 'left');
 
       // Specifies right side:
       element._focusLineNum = 123;
-      element._initCursor({leftSide: false});
+      element._initCursor(false);
       assert.equal(element.$.cursor.initialLineNumber, 123);
       assert.equal(element.$.cursor.side, 'right');
     });
 
     test('_getLineOfInterest', () => {
-      assert.isNull(element._getLineOfInterest({}));
+      assert.isUndefined(element._getLineOfInterest(false));
 
       element._focusLineNum = 12;
-      let result = element._getLineOfInterest({});
+      let result = element._getLineOfInterest(false);
       assert.equal(result.number, 12);
       assert.isNotOk(result.leftSide);
 
-      result = element._getLineOfInterest({leftSide: true});
+      result = element._getLineOfInterest(true);
       assert.equal(result.number, 12);
       assert.isOk(result.leftSide);
     });
@@ -1272,8 +1379,8 @@
       element._changeNum = 321;
       element._change = {_number: 321, project: 'foo/bar'};
       element._patchRange = {
-        basePatchNum: '3',
-        patchNum: '5',
+        basePatchNum: 3,
+        patchNum: 5,
       };
       const e = {};
       const detail = {number: 123, side: 'right'};
@@ -1282,6 +1389,29 @@
 
       assert.isTrue(replaceStateStub.called);
       assert.isTrue(getUrlStub.called);
+      assert.isFalse(getUrlStub.lastCall.args[6]);
+    });
+
+    test('line selected on left side', () => {
+      const getUrlStub = sinon.stub(GerritNav, 'getUrlForDiffById');
+      const replaceStateStub = sinon.stub(history, 'replaceState');
+      sinon.stub(element.$.cursor, 'getAddress')
+          .returns({number: 123, isLeftSide: true});
+
+      element._changeNum = 321;
+      element._change = {_number: 321, project: 'foo/bar'};
+      element._patchRange = {
+        basePatchNum: 3,
+        patchNum: 5,
+      };
+      const e = {};
+      const detail = {number: 123, side: 'left'};
+
+      element._onLineSelected(e, detail);
+
+      assert.isTrue(replaceStateStub.called);
+      assert.isTrue(getUrlStub.called);
+      assert.isTrue(getUrlStub.lastCall.args[6]);
     });
 
     test('_getDiffViewMode', () => {
@@ -1311,10 +1441,16 @@
     });
 
     suite('_initPatchRange', () => {
+      setup(async () => {
+        element.params = {
+          view: GerritView.DIFF,
+          changeNum: '42',
+          patchNum: 3,
+        };
+        await flush();
+      });
       test('empty', () => {
-        sinon.stub(element, '_getCommentsForPath');
         sinon.stub(element, '_getPaths').returns(new Map());
-        element.params = {};
         element._initPatchRange();
         assert.equal(Object.keys(element._commentMap).length, 0);
       });
@@ -1325,13 +1461,11 @@
           'path/to/file/one.cpp': [{patch_set: 3, message: 'lorem'}],
           'path-to/file/two.py': [{patch_set: 5, message: 'ipsum'}],
         });
-        sinon.stub(element, '_getCommentsForPath').returns({meta: {}});
         element._changeNum = '42';
         element._patchRange = {
-          basePatchNum: '3',
-          patchNum: '5',
+          basePatchNum: 3,
+          patchNum: 5,
         };
-        element.params = {};
         element._initPatchRange();
         assert.deepEqual(Object.keys(element._commentMap),
             ['path/to/file/one.cpp', 'path-to/file/two.py']);
@@ -1392,7 +1526,7 @@
           element._files = getFilesFromFileList([
             'path/one.jpg', 'path/two.m4v', 'path/three.wav',
           ]);
-          element._patchRange = {patchNum: '2', basePatchNum: '1'};
+          element._patchRange = {patchNum: 2, basePatchNum: 1};
         });
 
         suite('_moveToPreviousFileWithComment', () => {
@@ -1500,11 +1634,21 @@
           .then(reviewed => assert.isFalse(reviewed)));
 
       promises.push(element._getReviewedStatus(false, null, null, 'path')
+          .then(reviewed => assert.isFalse(reviewed)));
+
+      promises.push(element._getReviewedStatus(false, 3, 5, 'path')
           .then(reviewed => assert.isTrue(reviewed)));
 
       return Promise.all(promises);
     });
 
+    test('f open file dropdown', () => {
+      assert.isFalse(element.$.dropdown.$.dropdown.opened);
+      MockInteractions.pressAndReleaseKeyOn(element, 70, null, 'f');
+      flush();
+      assert.isTrue(element.$.dropdown.$.dropdown.opened);
+    });
+
     suite('blame', () => {
       test('toggle blame with button', () => {
         const toggleBlame = sinon.stub(
@@ -1533,7 +1677,7 @@
 
       test('reviewed checkbox', () => {
         sinon.stub(element, '_handlePatchChange');
-        element._patchRange = {patchNum: '1'};
+        element._patchRange = {patchNum: 1};
         // Reviewed checkbox should be shown.
         assert.isTrue(isVisible(element.$.reviewed));
         element.set('_patchRange.patchNum', SPECIAL_PATCH_SET_NUM.EDIT);
@@ -1590,6 +1734,10 @@
         patchNum: 1,
         basePatchNum: 'PARENT',
       };
+      element._change = {
+        ...createChange(),
+        revisions: createRevisions(1),
+      };
       flush();
       assert.isTrue(GerritNav.navigateToDiff.notCalled);
 
@@ -1731,14 +1879,15 @@
         getReviewedFiles() { return Promise.resolve([]); },
       });
       element = basicFixture.instantiate();
+      element._changeNum = '42';
       return element._loadComments();
     });
 
     test('_getFiles add files with comments without changes', () => {
       const patchChangeRecord = {
         base: {
-          basePatchNum: '5',
-          patchNum: '10',
+          basePatchNum: 5,
+          patchNum: 10,
         },
       };
       const changeComments = {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
index 9f5cdf3..eb11588 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
@@ -38,7 +38,7 @@
   end: number | null;
 }
 
-interface GrDiffGroupRange {
+export interface GrDiffGroupRange {
   left: Range;
   right: Range;
 }
@@ -89,7 +89,13 @@
       [before, hidden] = _splitCommonGroups(hidden, hiddenStart);
     }
     if (hiddenEnd) {
-      [hidden, after] = _splitCommonGroups(hidden, hiddenEnd - hiddenStart);
+      let beforeLength = 0;
+      if (before.length > 0) {
+        const beforeStart = before[0].lineRange.left.start || 0;
+        const beforeEnd = before[before.length - 1].lineRange.left.end || 0;
+        beforeLength = beforeEnd - beforeStart + 1;
+      }
+      [hidden, after] = _splitCommonGroups(hidden, hiddenEnd - beforeLength);
     }
   } else {
     [hidden, after] = [[], hidden];
@@ -106,6 +112,67 @@
 }
 
 /**
+ * Splits a group in two, defined by leftSplit and rightSplit. Primarily to be
+ * used in function _splitCommonGroups
+ * Groups with some lines before and some lines after the split will be split
+ * into two groups, which will be put into the first and second list.
+ *
+ * @param group The group to be split in two
+ * @param leftSplit The line number relative to the split on the left side
+ * @param rightSplit The line number relative to the split on the right side
+ * @return two new groups, one before the split and another after it
+ */
+function _splitGroupInTwo(
+  group: GrDiffGroup,
+  leftSplit: number,
+  rightSplit: number
+) {
+  let beforeSplit: GrDiffGroup | undefined;
+  let afterSplit: GrDiffGroup | undefined;
+  // split line is in the middle of a group, we need to break the group
+  // in lines before and after the split.
+  if (group.skip) {
+    // Currently we assume skip chunks "refuse" to be split. Expanding this
+    // group will in the future mean load more data - and therefore we want to
+    // fire an event when user wants to do it.
+    const closerToStartThanEnd =
+      leftSplit - (group.lineRange.left.start || 0) <
+      (group.lineRange.right.end || 0) - leftSplit;
+    if (closerToStartThanEnd) {
+      afterSplit = group;
+    } else {
+      beforeSplit = group;
+    }
+  } else {
+    const before = [];
+    const after = [];
+    for (const line of group.lines) {
+      if (
+        (line.beforeNumber && line.beforeNumber < leftSplit) ||
+        (line.afterNumber && line.afterNumber < rightSplit)
+      ) {
+        before.push(line);
+      } else {
+        after.push(line);
+      }
+    }
+    if (before.length) {
+      beforeSplit =
+        before.length === group.lines.length
+          ? group
+          : group.cloneWithLines(before);
+    }
+    if (after.length) {
+      afterSplit =
+        after.length === group.lines.length
+          ? group
+          : group.cloneWithLines(after);
+    }
+  }
+  return {beforeSplit, afterSplit};
+}
+
+/**
  * Splits a list of common groups into two lists of groups.
  *
  * Groups where all lines are before or all lines are after the split will be
@@ -129,47 +196,28 @@
   const beforeGroups = [];
   const afterGroups = [];
   for (const group of groups) {
-    if (
+    const isCompletelyBefore =
       (group.lineRange.left.end || 0) < leftSplit ||
-      (group.lineRange.right.end || 0) < rightSplit
-    ) {
-      beforeGroups.push(group);
-      continue;
-    }
-    if (
+      (group.lineRange.right.end || 0) < rightSplit;
+    const isCompletelyAfter =
       leftSplit <= (group.lineRange.left.start || 0) ||
-      rightSplit <= (group.lineRange.right.start || 0)
-    ) {
+      rightSplit <= (group.lineRange.right.start || 0);
+    if (isCompletelyBefore) {
+      beforeGroups.push(group);
+    } else if (isCompletelyAfter) {
       afterGroups.push(group);
-      continue;
-    }
-
-    const before = [];
-    const after = [];
-    for (const line of group.lines) {
-      if (
-        (line.beforeNumber && line.beforeNumber < leftSplit) ||
-        (line.afterNumber && line.afterNumber < rightSplit)
-      ) {
-        before.push(line);
-      } else {
-        after.push(line);
+    } else {
+      const {beforeSplit, afterSplit} = _splitGroupInTwo(
+        group,
+        leftSplit,
+        rightSplit
+      );
+      if (beforeSplit) {
+        beforeGroups.push(beforeSplit);
       }
-    }
-
-    if (before.length) {
-      beforeGroups.push(
-        before.length === group.lines.length
-          ? group
-          : group.cloneWithLines(before)
-      );
-    }
-    if (after.length) {
-      afterGroups.push(
-        after.length === group.lines.length
-          ? group
-          : group.cloneWithLines(after)
-      );
+      if (afterSplit) {
+        afterGroups.push(afterSplit);
+      }
     }
   }
   return [beforeGroups, afterGroups];
@@ -189,8 +237,6 @@
 
   dueToRebase = false;
 
-  dueToMove = false;
-
   /**
    * True means all changes in this line are whitespace changes that should
    * not be highlighted as changed as per the user settings.
@@ -213,12 +259,22 @@
 
   contextGroups: GrDiffGroup[] = [];
 
+  skip?: number;
+
   /** Both start and end line are inclusive. */
   lineRange: GrDiffGroupRange = {
     left: {start: null, end: null},
     right: {start: null, end: null},
   };
 
+  moveDetails?: {
+    changed: boolean;
+    range?: {
+      start: number;
+      end: number;
+    };
+  };
+
   /**
    * Creates a new group with the same properties but different lines.
    *
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js
index 13fc4d1..3423834 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js
@@ -177,6 +177,35 @@
       assert.deepEqual(collapsedGroups[3].lines, groups[2].lines.slice(1));
     });
 
+    suite('with skip chunks', () => {
+      setup(() => {
+        const skipGroup = new GrDiffGroup(GrDiffGroupType.BOTH);
+        skipGroup.skip = 60;
+        skipGroup.lineRange = {
+          left: {start: 8, end: 67},
+          right: {start: 10, end: 69},
+        };
+        groups = [
+          new GrDiffGroup(GrDiffGroupType.BOTH, [
+            new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
+            new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
+            new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
+          ]),
+          skipGroup,
+          new GrDiffGroup(GrDiffGroupType.BOTH, [
+            new GrDiffLine(GrDiffLineType.BOTH, 68, 70),
+            new GrDiffLine(GrDiffLineType.BOTH, 69, 71),
+            new GrDiffLine(GrDiffLineType.BOTH, 70, 72),
+          ]),
+        ];
+      });
+
+      test('refuses to split skip group when closer to before', () => {
+        const collapsedGroups = hideInContextControl(groups, 4, 10);
+        assert.deepEqual(groups, collapsedGroups);
+      });
+    });
+
     test('groups unchanged if the hidden range is empty', () => {
       assert.deepEqual(
           hideInContextControl(groups, 0, 0), groups);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
index 159c056..f64d940 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -34,14 +34,16 @@
 import {
   BlameInfo,
   CommentRange,
-  DiffInfo,
-  DiffPreferencesInfo,
-  DiffPreferencesInfoKey,
   EditPatchSetNum,
   ImageInfo,
   ParentPatchSetNum,
   PatchRange,
 } from '../../../types/common';
+import {
+  DiffInfo,
+  DiffPreferencesInfo,
+  DiffPreferencesInfoKey,
+} from '../../../types/diff';
 import {GrDiffHighlight} from '../gr-diff-highlight/gr-diff-highlight';
 import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
 import {
@@ -50,10 +52,12 @@
   PolymerDomWrapper,
 } from '../../../types/types';
 import {CommentRangeLayer} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
-import {DiffViewMode, Side} from '../../../constants/constants';
+import {DiffViewMode, Side, CommentSide} from '../../../constants/constants';
 import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
 import {FlattenedNodesObserver} from '@polymer/polymer/lib/utils/flattened-nodes-observer';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {AbortStop} from '../../shared/gr-cursor-manager/gr-cursor-manager';
+import {fireAlert} from '../../../utils/event-util';
 
 const NO_NEWLINE_BASE = 'No newline at end of base file.';
 const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
@@ -164,9 +168,6 @@
   @property({type: Object, observer: '_prefsObserver'})
   prefs?: DiffPreferencesInfo;
 
-  @property({type: String})
-  projectName?: string;
-
   @property({type: Boolean})
   displayLine = false;
 
@@ -194,8 +195,18 @@
   @property({type: Object})
   lineOfInterest?: LineOfInterest;
 
-  @property({type: Boolean, observer: '_loadingChanged'})
-  loading = false;
+  /**
+   * True when diff is changed, until the content is done rendering.
+   *
+   * This is readOnly, meaning one can listen for the loading-changed event, but
+   * not write to it from the outside. Code in this class should use the
+   * "private" _setLoading method.
+   */
+  @property({type: Boolean, notify: true, readOnly: true})
+  loading!: boolean;
+
+  // Polymer generated when setting readOnly above.
+  _setLoading!: (loading: boolean) => void;
 
   @property({type: Boolean})
   loggedIn = false;
@@ -242,6 +253,9 @@
   @property({type: Boolean})
   showNewlineWarningRight = false;
 
+  @property({type: Boolean})
+  useNewContextControls = false;
+
   @property({
     type: String,
     computed:
@@ -274,6 +288,7 @@
   /** @override */
   created() {
     super.created();
+    this._setLoading(true);
     this.addEventListener('create-range-comment', (e: Event) =>
       this._handleCreateRangeComment(e as CustomEvent)
     );
@@ -404,7 +419,6 @@
    * The key locations based on the comments and line of interests,
    * where lines should not be collapsed.
    *
-   * @return
    */
   _computeKeyLocations() {
     const keyLocations: KeyLocations = {left: {}, right: {}};
@@ -418,7 +432,7 @@
 
     for (const threadEl of threadEls) {
       const side = getSide(threadEl);
-      const lineNum = Number(threadEl.getAttribute('line-num')) || FILE;
+      const lineNum = threadEl.getAttribute('line-num') || FILE;
       const commentRange = threadEl.range || {};
       keyLocations[side][lineNum] = true;
       // Add start_line as well if exists,
@@ -458,12 +472,17 @@
     this.cancelDebouncer(RENDER_DIFF_TABLE_DEBOUNCE_NAME);
   }
 
-  getCursorStops() {
+  getCursorStops(): Array<HTMLElement | AbortStop> {
     if (this.hidden && this.noAutoRender) return [];
-    if (!this.root) return [];
+
+    if (this.loading) {
+      return [new AbortStop()];
+    }
 
     return Array.from(
-      this.root.querySelectorAll(':not(.contextControl) > .diff-row')
+      this.root?.querySelectorAll<HTMLElement>(
+        ':not(.contextControl) > .diff-row'
+      ) || []
     ).filter(tr => tr.querySelector('button'));
   }
 
@@ -552,22 +571,11 @@
 
     const lineNum = getLineNumber(el);
     if (lineNum === null) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: 'Invalid line number'},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, 'Invalid line number');
       return;
     }
 
-    // TODO(TS): existing logic always pass undefined lineNum
-    // for file level comment, the drafts API will reject the
-    // request if file level draft contains the `line: 'FILE'` field
-    // probably should do this inside of the _createComment, this
-    // is just to keep existing behavior.
-    this._createComment(el, lineNum === FILE ? undefined : lineNum);
+    this._createComment(el, lineNum);
   }
 
   createRangeComment() {
@@ -605,13 +613,7 @@
       return false;
     }
     if (!this.patchRange) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: 'Cannot create comment. Patch range undefined.'},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, 'Cannot create comment. Patch range undefined.');
       return false;
     }
     const patchNum = el.classList.contains(Side.LEFT)
@@ -624,25 +626,11 @@
       patchNumEquals(this.patchRange.patchNum, EditPatchSetNum);
 
     if (isEdit) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: 'You cannot comment on an edit.'},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, 'You cannot comment on an edit.');
       return false;
     }
     if (isEditBase) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'You cannot comment on the base patchset of an edit.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireAlert(this, 'You cannot comment on the base patchset of an edit.');
       return false;
     }
     return true;
@@ -665,6 +653,7 @@
       lineEl,
       contentEl
     );
+    const commentSide = isOnParent ? CommentSide.PARENT : CommentSide.REVISION;
     this.dispatchEvent(
       new CustomEvent('create-comment', {
         bubbles: true,
@@ -672,9 +661,11 @@
         detail: {
           lineNum,
           side,
+          commentSide,
           patchNum: patchForNewThreads,
-          isOnParent,
           range,
+          path: this.path,
+          isOnParent,
         },
       })
     );
@@ -788,12 +779,6 @@
     this.clearDiffContent();
   }
 
-  _loadingChanged(newValue?: boolean) {
-    if (newValue) {
-      this._cleanup();
-    }
-  }
-
   _lineWrappingObserver() {
     this._prefsChanged(this.prefs);
   }
@@ -832,8 +817,9 @@
   }
 
   _diffChanged(newValue?: DiffInfo) {
+    this._setLoading(true);
+    this._cleanup();
     if (newValue) {
-      this._cleanup();
       this._diffLength = this.getDiffLength(newValue);
       this._debounceRenderDiffTable();
     }
@@ -891,6 +877,7 @@
   }
 
   _handleRenderContent() {
+    this._setLoading(false);
     this._unobserveIncrementalNodes();
     this._incrementalNodeObserver = (dom(
       this
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
index 08f5cd3..ee5e1c0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
@@ -27,7 +27,10 @@
     :host {
       font-family: var(--monospace-font-family, ''), 'Roboto Mono';
       font-size: var(--font-size, var(--font-size-code, 12px));
-      line-height: var(--line-height-code, 1.334);
+      /* usually 16px = 12px + 4px */
+      line-height: calc(
+        var(--font-size, var(--font-size-code, 12px)) + var(--spacing-s, 4px)
+      );
     }
 
     .thread-group {
@@ -46,9 +49,38 @@
     }
     table {
       border-collapse: collapse;
-      border-right: 1px solid var(--border-color);
       table-layout: fixed;
     }
+
+    /*
+      Context controls break up the table visually, so we set the right border
+      on individual sections to leave a gap for the divider.
+      */
+    .section {
+      border-right: 1px solid var(--border-color);
+    }
+    .section.contextControl.newStyle {
+      /*
+       * Divider inside this section must not have border; we set borders on
+       * the padding rows below.
+       */
+      border-right-width: 0;
+    }
+    /*
+     * Padding rows behind new style context controls. The diff is styled to be
+     * cut into two halves by the negative space of the divider on which the
+     * context control buttons are anchored.
+     */
+    .contextBackground {
+      border-right: 1px solid var(--border-color);
+    }
+    .contextBackground.above {
+      border-bottom: 1px solid var(--border-color);
+    }
+    .contextBackground.below {
+      border-top: 1px solid var(--border-color);
+    }
+
     .lineNumButton {
       display: block;
       width: 100%;
@@ -113,7 +145,7 @@
       width: 100%;
     }
     .full-width .contentText {
-      white-space: pre-wrap;
+      white-space: break-spaces;
       word-wrap: break-word;
     }
     .lineNumButton,
@@ -190,6 +222,7 @@
     }
     .moveControls {
       text-align: right;
+      font-style: italic;
     }
 
     /* ignoredWhitespaceOnly */
@@ -206,12 +239,22 @@
       /* Newline, to ensure empty lines are one line-height tall. */
       content: '\\A';
     }
+
+    /* Context controls */
     .contextControl {
       background-color: var(--diff-context-control-background-color);
       border: 1px solid var(--diff-context-control-border-color);
       color: var(--diff-context-control-color);
+      --divider-height: var(--spacing-s);
+      --divider-border: 1px;
     }
-    .contextControl gr-button {
+    .contextControl.newStyle {
+      background-color: transparent;
+      border: none;
+      /* Change to --diff-context-control-color once only new style exists. */
+      --diff-context-control-color: var(--default-button-text-color);
+    }
+    .contextControl:not(.newStyle) gr-button {
       display: inline-block;
       text-decoration: none;
       vertical-align: top;
@@ -229,6 +272,97 @@
     .contextControl td:not(.lineNumButton) {
       text-align: center;
     }
+
+    /*
+     * Padding rows behind new style context controls. Styled as a continuation
+     * of the line gutters and code area.
+     */
+    .contextBackground > .contextLineNum {
+      background-color: var(--diff-blank-background-color);
+    }
+    .contextBackground > td:not(.contextLineNum) {
+      background-color: var(--view-background-color);
+    }
+    .contextBackground {
+      /* 
+       * One line of background behind the context expanders which they can 
+       * render on top of, plus some padding.
+       */
+      height: calc(var(--line-height-normal) + var(--spacing-s));
+    }
+
+    .contextDivider {
+      height: var(--divider-height);
+      /* Create a positioning context. */
+      transform: translateX(0px);
+    }
+    .contextDivider.collapsed {
+      /* Hide divider gap, but still show child elements (expansion buttons). */
+      height: 0;
+    }
+    .dividerCell {
+      width: 100%;
+      height: 100%;
+      display: flex;
+      justify-content: center;
+      position: absolute;
+      top: 0;
+      left: 0;
+    }
+    .contextControlButton {
+      background-color: var(--default-button-background-color);
+      font: var(--context-control-button-font, inherit);
+      /* All position is relative to container, so ignore sibling buttons. */
+      position: absolute;
+    }
+    .contextControlButton:first-child {
+      /* First button needs to claim width to display without text wrapping. */
+      position: relative;
+    }
+    .centeredButton {
+      /* Center over divider. */
+      top: 50%;
+      transform: translateY(-50%);
+      --gr-button: {
+        color: var(--diff-context-control-color);
+        border: solid var(--border-color);
+        border-width: 1px;
+        border-radius: var(--border-radius);
+        padding: var(--spacing-s) var(--spacing-l);
+      }
+    }
+    .aboveBelowButtons {
+      display: flex;
+      flex-direction: column;
+      margin-left: var(--spacing-m);
+      position: relative;
+    }
+    .aboveBelowButtons:first-child {
+      margin-left: 0;
+    }
+    .aboveButton {
+      /* Display over preceding content / background placeholder. */
+      transform: translateY(-100%);
+      --gr-button: {
+        color: var(--diff-context-control-color);
+        border: solid var(--border-color);
+        border-width: 1px 1px 0 1px;
+        border-radius: var(--border-radius) var(--border-radius) 0 0;
+        padding: var(--spacing-xxs) var(--spacing-l);
+      }
+    }
+    .belowButton {
+      /* Display over following content / background placeholder. */
+      top: calc(100% + var(--divider-border));
+      --gr-button: {
+        color: var(--diff-context-control-color);
+        border: solid var(--border-color);
+        border-width: 0 1px 1px 1px;
+        border-radius: 0 0 var(--border-radius) var(--border-radius);
+        padding: var(--spacing-xxs) var(--spacing-l);
+      }
+    }
+
     .displayLine .diff-row.target-row td {
       box-shadow: inset 0 -1px var(--border-color);
     }
@@ -428,7 +562,6 @@
           id="diffBuilder"
           comment-ranges="[[_commentRanges]]"
           coverage-ranges="[[coverageRanges]]"
-          project-name="[[projectName]]"
           diff="[[diff]]"
           path="[[path]]"
           change-num="[[changeNum]]"
@@ -438,6 +571,7 @@
           base-image="[[baseImage]]"
           layers="[[layers]]"
           revision-image="[[revisionImage]]"
+          use-new-context-controls="[[useNewContextControls]]"
         >
           <table
             id="diffTable"
@@ -447,7 +581,7 @@
 
           <template
             is="dom-if"
-            if="[[showNoChangeMessage(loading, prefs, _diffLength, diff)]]"
+            if="[[showNoChangeMessage(_loading, prefs, _diffLength, diff)]]"
           >
             <div class="whitespace-change-only-message">
               This file only contains whitespace changes. Modify the whitespace
@@ -458,7 +592,7 @@
       </gr-diff-highlight>
     </gr-diff-selection>
   </div>
-  <div class$="[[_computeNewlineWarningClass(_newlineWarning, loading)]]">
+  <div class$="[[_computeNewlineWarningClass(_newlineWarning, _loading)]]">
     [[_newlineWarning]]
   </div>
   <div id="loadingError" class$="[[_computeErrorClass(errorMessage)]]">
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
index 36b3b8f..3c53697 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
@@ -585,7 +585,7 @@
     });
 
     suite('getCursorStops', () => {
-      const setupDiff = function() {
+      function setupDiff() {
         element.diff = getMockDiffResponse();
         element.prefs = {
           context: 10,
@@ -605,8 +605,9 @@
         };
 
         element._renderDiffTable();
+        element._setLoading(false);
         flush();
-      };
+      }
 
       test('getCursorStops returns [] when hidden and noAutoRender', () => {
         element.noAutoRender = true;
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
index 2b997fd..2a9fe54 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
@@ -375,9 +375,11 @@
       return;
     }
 
-    const commentCount = changeComments.computeCommentCount({patchNum});
-    const commentString = GrCountStringFormatter.computePluralString(
-      commentCount,
+    const commentThreadCount = changeComments.computeCommentThreadCount({
+      patchNum,
+    });
+    const commentThreadString = GrCountStringFormatter.computePluralString(
+      commentThreadCount,
       'comment'
     );
 
@@ -387,14 +389,14 @@
       'unresolved'
     );
 
-    if (!commentString.length && !unresolvedString.length) {
+    if (!commentThreadString.length && !unresolvedString.length) {
       return '';
     }
 
     return (
-      ` (${commentString}` +
-      // Add a comma + space if both comments and unresolved
-      (commentString && unresolvedString ? ', ' : '') +
+      ` (${commentThreadString}` +
+      // Add a comma + space if both comment threads and unresolved
+      (commentThreadString && unresolvedString ? ', ' : '') +
       `${unresolvedString})`
     );
   }
@@ -437,7 +439,7 @@
         previous: detail.patchNum,
         current: e.detail.value,
         latest: latestPatchNum,
-        commentCount: this.changeComments?.computeCommentCount({
+        commentCount: this.changeComments?.computeCommentThreadCount({
           patchNum: e.detail.value as PatchSetNum,
         }),
       });
@@ -447,7 +449,7 @@
       this.reporting.reportInteraction('left-patchset-changed', {
         previous: detail.basePatchNum,
         current: e.detail.value,
-        commentCount: this.changeComments?.computeCommentCount({
+        commentCount: this.changeComments?.computeCommentThreadCount({
           patchNum: patchSetValue,
         }),
       });
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts
index 415ef4b..52465b3 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts
@@ -45,12 +45,10 @@
         --native-select-style: {
           max-width: 5.25em;
         }
-        --dropdown-content-stype: {
-          max-width: 300px;
-        }
       }
     }
   </style>
+  <h3 class="assistive-tech-only">Patchset Range Selection</h3>
   <span class="patchRange" aria-label="patch range starts with">
     <gr-dropdown-list
       id="basePatchDropdown"
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
index eb9f47d..15841d9 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
@@ -69,7 +69,7 @@
   test('enabled/disabled options', () => {
     const patchRange = {
       basePatchNum: 'PARENT',
-      patchNum: '3',
+      patchNum: 3,
     };
     const sortedRevisions = [
       {_number: 3},
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
index 334c8f4..f5e5e04 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
@@ -23,8 +23,8 @@
 import {FILE, GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
 import {CancelablePromise, util} from '../../../scripts/util';
 import {customElement, property} from '@polymer/decorators';
+import {DiffFileMetaInfo, DiffInfo} from '../../../types/diff';
 import {DiffLayer, DiffLayerListener, HighlightJS} from '../../../types/types';
-import {DiffFileMetaInfo, DiffInfo} from '../../../types/common';
 import {GrLibLoader} from '../../shared/gr-lib-loader/gr-lib-loader';
 import {Side} from '../../../constants/constants';
 
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
index 5967b03..79c4359 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
@@ -30,6 +30,7 @@
 import {customElement, property} from '@polymer/decorators';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {DocResult} from '../../../types/common';
+import {fireTitleChange} from '../../../utils/event-util';
 
 export interface GrDocumentationSearch {
   $: {
@@ -62,9 +63,7 @@
   /** @override */
   attached() {
     super.attached();
-    this.dispatchEvent(
-      new CustomEvent('title-change', {detail: {title: 'Documentation Search'}})
-    );
+    fireTitleChange(this, 'Documentation Search');
   }
 
   _paramsChanged(params: ListViewParams) {
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_html.ts b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_html.ts
index ba8af3c..77b4bc6 100644
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_html.ts
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_html.ts
@@ -23,7 +23,8 @@
       box-sizing: border-box;
       font-family: var(--monospace-font-family);
       font-size: var(--font-size-code);
-      line-height: var(--line-height-code);
+      /* usually 16px = 12px + 4px */
+      line-height: calc(var(--font-size-code) + var(--spacing-s));
       min-height: 60vh;
       resize: none;
       white-space: pre;
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
index 3d0dede..60944d0 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -34,7 +34,10 @@
 import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util';
 import {computeTruncatedPath} from '../../../utils/path-list-util';
 import {customElement, property} from '@polymer/decorators';
-import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  RestApiService,
+  ErrorCallback,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {
   ChangeInfo,
   PatchSetNum,
@@ -43,11 +46,15 @@
   NumericChangeId,
 } from '../../../types/common';
 import {GrStorage} from '../../shared/gr-storage/gr-storage';
+import {HttpMethod, NotifyType} from '../../../constants/constants';
+import {fireAlert, fireTitleChange} from '../../../utils/event-util';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const SAVING_MESSAGE = 'Saving changes...';
 const SAVED_MESSAGE = 'All changes saved';
 const SAVE_FAILED_MSG = 'Failed to save changes';
+const PUBLISHING_EDIT_MSG = 'Publishing edit...';
+const PUBLISH_FAILED_MSG = 'Failed to publish edit';
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 100;
 
@@ -163,22 +170,14 @@
     this._patchNum =
       value.patchNum || (SPECIAL_PATCH_SET_NUM.EDIT as PatchSetNum);
     this._lineNum =
-      typeof value.lineNum === 'string'
-        ? parseInt(value.lineNum)
-        : value.lineNum;
+      typeof value.lineNum === 'string' ? Number(value.lineNum) : value.lineNum;
 
     // NOTE: This may be called before attachment (e.g. while parentElement is
     // null). Fire title-change in an async so that, if attachment to the DOM
     // has been queued, the event can bubble up to the handler in gr-app.
     this.async(() => {
       const title = `Editing ${computeTruncatedPath(value.path)}`;
-      this.dispatchEvent(
-        new CustomEvent('title-change', {
-          detail: {title},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireTitleChange(this, title);
     });
 
     const promises = [];
@@ -251,13 +250,7 @@
           storedContent.message &&
           storedContent.message !== content
         ) {
-          this.dispatchEvent(
-            new CustomEvent('show-alert', {
-              detail: {message: RESTORED_MESSAGE},
-              bubbles: true,
-              composed: true,
-            })
-          );
+          fireAlert(this, RESTORED_MESSAGE);
 
           this._newContent = storedContent.message;
         } else {
@@ -291,22 +284,17 @@
         this._saving = false;
         this._showAlert(res.ok ? SAVED_MESSAGE : SAVE_FAILED_MSG);
         if (!res.ok) {
-          return;
+          return res;
         }
 
         this._content = this._newContent;
         this._successfulSave = true;
+        return res;
       });
   }
 
   _showAlert(message: string) {
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {message},
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fireAlert(this, message);
   }
 
   _computeSaveDisabled(
@@ -330,6 +318,40 @@
     this._viewEditInChangeView();
   }
 
+  _handleSaveTap() {
+    this._saveEdit().then(res => {
+      if (res.ok) this._viewEditInChangeView();
+    });
+  }
+
+  _handlePublishTap() {
+    if (!this._changeNum) throw new Error('missing changeNum');
+
+    const changeNum = this._changeNum;
+    this._saveEdit().then(() => {
+      const handleError: ErrorCallback = response => {
+        this._showAlert(PUBLISH_FAILED_MSG);
+        console.error(response);
+      };
+
+      this._showAlert(PUBLISHING_EDIT_MSG);
+
+      this.$.restAPI
+        .executeChangeAction(
+          changeNum,
+          HttpMethod.POST,
+          '/edit:publish',
+          undefined,
+          {notify: NotifyType.NONE},
+          handleError
+        )
+        .then(() => {
+          if (!this._change) throw new Error('missing change');
+          GerritNav.navigateToChange(this._change);
+        });
+    });
+  }
+
   _handleContentChange(e: CustomEvent<{value: string}>) {
     this.debounce(
       'store',
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.ts
index 1297c97..1e05232 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.ts
@@ -92,16 +92,26 @@
       </span>
       <span class="controlGroup rightControls">
         <gr-button id="close" link="" on-click="_handleCloseTap"
-          >Close</gr-button
+          >Cancel</gr-button
         >
         <gr-button
           id="save"
           disabled$="[[_saveDisabled]]"
           primary=""
           link=""
-          on-click="_saveEdit"
+          title="Save and Close the file"
+          on-click="_handleSaveTap"
           >Save</gr-button
         >
+        <gr-button
+          id="publish"
+          link=""
+          primary=""
+          title="Publish your edit. A new patchset will be created."
+          on-click="_handlePublishTap"
+          disabled$="[[_saveDisabled]]"
+          >Save & Publish</gr-button
+        >
       </span>
     </header>
   </div>
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.js b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.js
index f2dc7f2..b04277e 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.js
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.js
@@ -19,6 +19,7 @@
 import './gr-editor-view.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
+import {HttpMethod} from '../../../constants/constants.js';
 
 const basicFixture = fixtureFromElement('gr-editor-view');
 
@@ -187,10 +188,47 @@
         assert.isTrue(saveFileStub.called);
         assert.isFalse(element._saving);
         assert.equal(alertStub.lastCall.args[0], 'All changes saved');
-        assert.isFalse(navigateStub.called);
         assert.isTrue(element.$.save.hasAttribute('disabled'));
         assert.equal(element._content, element._newContent);
         assert.isTrue(element._successfulSave);
+        assert.isTrue(navigateStub.called);
+      });
+    });
+
+    test('file modification and publish', () => {
+      const saveSpy = sinon.spy(element, '_saveEdit');
+      const alertStub = sinon.stub(element, '_showAlert');
+      const changeActionsStub =
+        sinon.stub(element.$.restAPI, 'executeChangeAction');
+      saveFileStub.returns(Promise.resolve({ok: true}));
+      element._newContent = newText;
+      flush();
+
+      assert.isFalse(element._saving);
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+
+      MockInteractions.tap(element.$.publish);
+      assert.isTrue(saveSpy.called);
+      assert.equal(alertStub.getCall(0).args[0], 'Saving changes...');
+      assert.isTrue(element._saving);
+      assert.isTrue(element.$.save.hasAttribute('disabled'));
+
+      return saveSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(saveFileStub.called);
+        assert.isFalse(element._saving);
+
+        assert.equal(alertStub.getCall(1).args[0], 'All changes saved');
+        assert.equal(alertStub.getCall(2).args[0], 'Publishing edit...');
+
+        assert.isTrue(element.$.save.hasAttribute('disabled'));
+        assert.equal(element._content, element._newContent);
+        assert.isTrue(element._successfulSave);
+        assert.isFalse(navigateStub.called);
+
+        const args = changeActionsStub.lastCall.args;
+        assert.equal(args[0], '42');
+        assert.equal(args[1], HttpMethod.POST);
+        assert.equal(args[2], '/edit:publish');
       });
     });
 
diff --git a/polygerrit-ui/app/elements/gr-app-element.js b/polygerrit-ui/app/elements/gr-app-element.js
deleted file mode 100644
index e5d424c..0000000
--- a/polygerrit-ui/app/elements/gr-app-element.js
+++ /dev/null
@@ -1,638 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 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.
- */
-import '../styles/shared-styles.js';
-import '../styles/themes/app-theme.js';
-import {applyTheme as applyDarkTheme} from '../styles/themes/dark-theme.js';
-import './admin/gr-admin-view/gr-admin-view.js';
-import './documentation/gr-documentation-search/gr-documentation-search.js';
-import './change-list/gr-change-list-view/gr-change-list-view.js';
-import './change-list/gr-dashboard-view/gr-dashboard-view.js';
-import './change/gr-change-view/gr-change-view.js';
-import './core/gr-error-manager/gr-error-manager.js';
-import './core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js';
-import './core/gr-main-header/gr-main-header.js';
-import './core/gr-router/gr-router.js';
-import './core/gr-smart-search/gr-smart-search.js';
-import './diff/gr-diff-view/gr-diff-view.js';
-import './edit/gr-editor-view/gr-editor-view.js';
-import './plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import './plugins/gr-endpoint-param/gr-endpoint-param.js';
-import './plugins/gr-endpoint-slot/gr-endpoint-slot.js';
-import './plugins/gr-external-style/gr-external-style.js';
-import './plugins/gr-plugin-host/gr-plugin-host.js';
-import './settings/gr-cla-view/gr-cla-view.js';
-import './settings/gr-registration-dialog/gr-registration-dialog.js';
-import './settings/gr-settings-view/gr-settings-view.js';
-import './shared/gr-lib-loader/gr-lib-loader.js';
-import './shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-app-element_html.js';
-import {getBaseUrl} from '../utils/url-util.js';
-import {
-  KeyboardShortcutMixin,
-  Shortcut,
-  SPECIAL_SHORTCUT,
-} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {GerritNav} from './core/gr-navigation/gr-navigation.js';
-import {appContext} from '../services/app-context.js';
-import {flush} from '@polymer/polymer/lib/utils/flush';
-
-/**
- * @extends PolymerElement
- */
-class GrAppElement extends KeyboardShortcutMixin(
-    GestureEventListeners(
-        LegacyElementMixin(PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-app-element'; }
-  /**
-   * Fired when the URL location changes.
-   *
-   * @event location-change
-   */
-
-  static get properties() {
-    return {
-    /**
-     * @type {{ query: string, view: string, screen: string }}
-     */
-      params: Object,
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-
-      _account: {
-        type: Object,
-        observer: '_accountChanged',
-      },
-
-      /**
-       * The last time the g key was pressed in milliseconds (or a keydown event
-       * was handled if the key is held down).
-       *
-       * @type {number|null}
-       */
-      _lastGKeyPressTimestamp: {
-        type: Number,
-        value: null,
-      },
-
-      /**
-       * @type {{ plugin: Object }}
-       */
-      _serverConfig: Object,
-      _version: String,
-      _showChangeListView: Boolean,
-      _showDashboardView: Boolean,
-      _showChangeView: Boolean,
-      _showDiffView: Boolean,
-      _showSettingsView: Boolean,
-      _showAdminView: Boolean,
-      _showCLAView: Boolean,
-      _showEditorView: Boolean,
-      _showPluginScreen: Boolean,
-      _showDocumentationSearch: Boolean,
-      /** @type {?} */
-      _viewState: Object,
-      /** @type {?} */
-      _lastError: Object,
-      _lastSearchPage: String,
-      _path: String,
-      _pluginScreenName: {
-        type: String,
-        computed: '_computePluginScreenName(params)',
-      },
-      _settingsUrl: String,
-      _feedbackUrl: String,
-      // Used to allow searching on mobile
-      mobileSearch: {
-        type: Boolean,
-        value: false,
-      },
-
-      /**
-       * Other elements in app must open this URL when
-       * user login is required.
-       */
-      _loginUrl: {
-        type: String,
-        value: '/login',
-      },
-
-      loadRegistrationDialog: {
-        type: Boolean,
-        value: false,
-      },
-
-      loadKeyboardShortcutsDialog: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_viewChanged(params.view)',
-      '_paramsChanged(params.*)',
-    ];
-  }
-
-  keyboardShortcuts() {
-    return {
-      [Shortcut.OPEN_SHORTCUT_HELP_DIALOG]: '_showKeyboardShortcuts',
-      [Shortcut.GO_TO_USER_DASHBOARD]: '_goToUserDashboard',
-      [Shortcut.GO_TO_OPENED_CHANGES]: '_goToOpenedChanges',
-      [Shortcut.GO_TO_MERGED_CHANGES]: '_goToMergedChanges',
-      [Shortcut.GO_TO_ABANDONED_CHANGES]: '_goToAbandonedChanges',
-      [Shortcut.GO_TO_WATCHED_CHANGES]: '_goToWatchedChanges',
-    };
-  }
-
-  constructor() {
-    super();
-    this.reporting = appContext.reportingService;
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this._bindKeyboardShortcuts();
-    this.addEventListener('page-error',
-        e => this._handlePageError(e));
-    this.addEventListener('title-change',
-        e => this._handleTitleChange(e));
-    this.addEventListener('location-change',
-        e => this._handleLocationChange(e));
-    this.addEventListener('rpc-log',
-        e => this._handleRpcLog(e));
-    this.addEventListener('shortcut-triggered',
-        e => this._handleShortcutTriggered(e));
-    // Ideally individual views should handle this event and respond with a soft
-    // reload. This is a catch-all for all views that cannot or have not
-    // implemented that.
-    this.addEventListener('reload', e => window.location.reload());
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this._updateLoginUrl();
-    this.reporting.appStarted();
-    this.$.router.start();
-
-    this.$.restAPI.getAccount().then(account => {
-      this._account = account;
-      const role = account ? 'user' : 'guest';
-      this.reporting.reportLifeCycle(`Started as ${role}`);
-    });
-    this.$.restAPI.getConfig().then(config => {
-      this._serverConfig = config;
-
-      if (config && config.gerrit && config.gerrit.report_bug_url) {
-        this._feedbackUrl = config.gerrit.report_bug_url;
-      }
-    });
-    this.$.restAPI.getVersion().then(version => {
-      this._version = version;
-      this._logWelcome();
-    });
-
-    if (window.localStorage.getItem('dark-theme')) {
-      applyDarkTheme();
-    }
-
-    // Note: this is evaluated here to ensure that it only happens after the
-    // router has been initialized. @see Issue 7837
-    this._settingsUrl = GerritNav.getUrlForSettings();
-
-    this._viewState = {
-      changeView: {
-        changeNum: null,
-        patchRange: null,
-        selectedFileIndex: 0,
-        showReplyDialog: false,
-        showDownloadDialog: false,
-        diffMode: null,
-        numFilesShown: null,
-        scrollTop: 0,
-      },
-      changeListView: {
-        query: null,
-        offset: 0,
-        selectedChangeIndex: 0,
-      },
-      dashboardView: {
-        selectedChangeIndex: 0,
-      },
-    };
-  }
-
-  _bindKeyboardShortcuts() {
-    this.bindShortcut(Shortcut.SEND_REPLY,
-        SPECIAL_SHORTCUT.DOC_ONLY, 'ctrl+enter', 'meta+enter');
-    this.bindShortcut(Shortcut.EMOJI_DROPDOWN,
-        SPECIAL_SHORTCUT.DOC_ONLY, ':');
-
-    this.bindShortcut(
-        Shortcut.OPEN_SHORTCUT_HELP_DIALOG, '?');
-    this.bindShortcut(
-        Shortcut.GO_TO_USER_DASHBOARD, SPECIAL_SHORTCUT.GO_KEY, 'i');
-    this.bindShortcut(
-        Shortcut.GO_TO_OPENED_CHANGES, SPECIAL_SHORTCUT.GO_KEY, 'o');
-    this.bindShortcut(
-        Shortcut.GO_TO_MERGED_CHANGES, SPECIAL_SHORTCUT.GO_KEY, 'm');
-    this.bindShortcut(
-        Shortcut.GO_TO_ABANDONED_CHANGES, SPECIAL_SHORTCUT.GO_KEY, 'a');
-    this.bindShortcut(
-        Shortcut.GO_TO_WATCHED_CHANGES, SPECIAL_SHORTCUT.GO_KEY, 'w');
-
-    this.bindShortcut(
-        Shortcut.CURSOR_NEXT_CHANGE, 'j');
-    this.bindShortcut(
-        Shortcut.CURSOR_PREV_CHANGE, 'k');
-    this.bindShortcut(
-        Shortcut.OPEN_CHANGE, 'o');
-    this.bindShortcut(
-        Shortcut.NEXT_PAGE, 'n', ']');
-    this.bindShortcut(
-        Shortcut.PREV_PAGE, 'p', '[');
-    this.bindShortcut(
-        Shortcut.TOGGLE_CHANGE_REVIEWED, 'r:keyup');
-    this.bindShortcut(
-        Shortcut.TOGGLE_CHANGE_STAR, 's:keydown');
-    this.bindShortcut(
-        Shortcut.REFRESH_CHANGE_LIST, 'shift+r:keyup');
-    this.bindShortcut(
-        Shortcut.EDIT_TOPIC, 't');
-
-    this.bindShortcut(
-        Shortcut.OPEN_REPLY_DIALOG, 'a:keyup');
-    this.bindShortcut(
-        Shortcut.OPEN_DOWNLOAD_DIALOG, 'd:keyup');
-    this.bindShortcut(
-        Shortcut.EXPAND_ALL_MESSAGES, 'x');
-    this.bindShortcut(
-        Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
-    this.bindShortcut(
-        Shortcut.REFRESH_CHANGE, 'shift+r:keyup');
-    this.bindShortcut(
-        Shortcut.UP_TO_DASHBOARD, 'u');
-    this.bindShortcut(
-        Shortcut.UP_TO_CHANGE, 'u');
-    this.bindShortcut(
-        Shortcut.TOGGLE_DIFF_MODE, 'm:keyup');
-    this.bindShortcut(
-        Shortcut.DIFF_AGAINST_BASE, SPECIAL_SHORTCUT.V_KEY, 'down', 's');
-    // this keyboard shortcut is used in toast _displayDiffAgainstLatestToast
-    // in gr-diff-view. Any updates here should be reflected there
-    this.bindShortcut(
-        Shortcut.DIFF_AGAINST_LATEST, SPECIAL_SHORTCUT.V_KEY, 'up', 'w');
-    // this keyboard shortcut is used in toast _displayDiffBaseAgainstLeftToast
-    // in gr-diff-view. Any updates here should be reflected there
-    this.bindShortcut(
-        Shortcut.DIFF_BASE_AGAINST_LEFT,
-        SPECIAL_SHORTCUT.V_KEY, 'left', 'a');
-    this.bindShortcut(
-        Shortcut.DIFF_RIGHT_AGAINST_LATEST,
-        SPECIAL_SHORTCUT.V_KEY, 'right', 'd');
-    this.bindShortcut(
-        Shortcut.DIFF_BASE_AGAINST_LATEST, SPECIAL_SHORTCUT.V_KEY, 'b');
-
-    this.bindShortcut(
-        Shortcut.NEXT_LINE, 'j', 'down');
-    this.bindShortcut(
-        Shortcut.PREV_LINE, 'k', 'up');
-    if (this._isCursorManagerSupportMoveToVisibleLine()) {
-      this.bindShortcut(
-          Shortcut.VISIBLE_LINE, '.');
-    }
-    this.bindShortcut(
-        Shortcut.NEXT_CHUNK, 'n');
-    this.bindShortcut(
-        Shortcut.PREV_CHUNK, 'p');
-    this.bindShortcut(
-        Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
-    this.bindShortcut(
-        Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
-    this.bindShortcut(
-        Shortcut.PREV_COMMENT_THREAD, 'shift+p');
-    this.bindShortcut(
-        Shortcut.EXPAND_ALL_COMMENT_THREADS,
-        SPECIAL_SHORTCUT.DOC_ONLY, 'e');
-    this.bindShortcut(
-        Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
-        SPECIAL_SHORTCUT.DOC_ONLY, 'shift+e');
-    this.bindShortcut(
-        Shortcut.LEFT_PANE, 'shift+left');
-    this.bindShortcut(
-        Shortcut.RIGHT_PANE, 'shift+right');
-    this.bindShortcut(
-        Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
-    this.bindShortcut(
-        Shortcut.NEW_COMMENT, 'c');
-    this.bindShortcut(
-        Shortcut.SAVE_COMMENT,
-        'ctrl+enter', 'meta+enter', 'ctrl+s', 'meta+s');
-    this.bindShortcut(
-        Shortcut.OPEN_DIFF_PREFS, ',');
-    this.bindShortcut(
-        Shortcut.TOGGLE_DIFF_REVIEWED, 'r:keyup');
-
-    this.bindShortcut(
-        Shortcut.NEXT_FILE, ']');
-    this.bindShortcut(
-        Shortcut.PREV_FILE, '[');
-    this.bindShortcut(
-        Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
-    this.bindShortcut(
-        Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
-    this.bindShortcut(
-        Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
-    this.bindShortcut(
-        Shortcut.CURSOR_PREV_FILE, 'k', 'up');
-    this.bindShortcut(
-        Shortcut.OPEN_FILE, 'o', 'enter');
-    this.bindShortcut(
-        Shortcut.TOGGLE_FILE_REVIEWED, 'r:keyup');
-    this.bindShortcut(
-        Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
-    this.bindShortcut(
-        Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
-    this.bindShortcut(
-        Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
-    this.bindShortcut(
-        Shortcut.TOGGLE_BLAME, 'b:keyup');
-    this.bindShortcut(
-        Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, 'h');
-
-    this.bindShortcut(
-        Shortcut.OPEN_FIRST_FILE, ']');
-    this.bindShortcut(
-        Shortcut.OPEN_LAST_FILE, '[');
-
-    this.bindShortcut(
-        Shortcut.SEARCH, '/');
-  }
-
-  _isCursorManagerSupportMoveToVisibleLine() {
-    // This method is a copy-paste from the
-    // method _isIntersectionObserverSupported of gr-cursor-manager.js
-    // It is better share this method with gr-cursor-manager,
-    // but doing it require a lot if changes instead of 1-line copied code
-    return 'IntersectionObserver' in window;
-  }
-
-  _accountChanged(account) {
-    if (!account) { return; }
-
-    // Preferences are cached when a user is logged in; warm them.
-    this.$.restAPI.getPreferences();
-    this.$.restAPI.getDiffPreferences();
-    this.$.restAPI.getEditPreferences();
-    this.$.errorManager.knownAccountId =
-        this._account && this._account._account_id || null;
-  }
-
-  _viewChanged(view) {
-    this.$.errorView.classList.remove('show');
-    this.set('_showChangeListView', view === GerritNav.View.SEARCH);
-    this.set('_showDashboardView', view === GerritNav.View.DASHBOARD);
-    this.set('_showChangeView', view === GerritNav.View.CHANGE);
-    this.set('_showDiffView', view === GerritNav.View.DIFF);
-    this.set('_showSettingsView', view === GerritNav.View.SETTINGS);
-    this.set('_showAdminView', view === GerritNav.View.ADMIN ||
-        view === GerritNav.View.GROUP || view === GerritNav.View.REPO);
-    this.set('_showCLAView', view === GerritNav.View.AGREEMENTS);
-    this.set('_showEditorView', view === GerritNav.View.EDIT);
-    const isPluginScreen = view === GerritNav.View.PLUGIN_SCREEN;
-    this.set('_showPluginScreen', false);
-    // Navigation within plugin screens does not restamp gr-endpoint-decorator
-    // because _showPluginScreen value does not change. To force restamp,
-    // change _showPluginScreen value between true and false.
-    if (isPluginScreen) {
-      this.async(() => this.set('_showPluginScreen', true), 1);
-    }
-    this.set('_showDocumentationSearch',
-        view === GerritNav.View.DOCUMENTATION_SEARCH);
-    if (this.params.justRegistered) {
-      this.loadRegistrationDialog = true;
-      flush();
-      const registrationOverlay =
-        this.shadowRoot.querySelector('#registrationOverlay');
-      const registrationDialog =
-        this.shadowRoot.querySelector('#registrationDialog');
-      registrationOverlay.open();
-      registrationDialog.loadData().then(() => {
-        registrationOverlay.refit();
-      });
-    }
-  }
-
-  _handleShortcutTriggered(event) {
-    const {event: e, goKey, vKey} = event.detail;
-    // eg: {key: "k:keydown", ..., from: "gr-diff-view"}
-    let key = `${e.key}:${e.type}`;
-    if (goKey) key = 'g+' + key;
-    if (vKey) key = 'v+' + key;
-    if (e.shiftKey) key = 'shift+' + key;
-    if (e.ctrlKey) key = 'ctrl+' + key;
-    if (e.metaKey) key = 'meta+' + key;
-    if (e.altKey) key = 'alt+' + key;
-    this.reporting.reportInteraction('shortcut-triggered', {
-      key,
-      from: event.path && event.path[0]
-        && event.path[0].nodeName || 'unknown',
-    });
-  }
-
-  _handlePageError(e) {
-    const props = [
-      '_showChangeListView',
-      '_showDashboardView',
-      '_showChangeView',
-      '_showDiffView',
-      '_showSettingsView',
-      '_showAdminView',
-    ];
-    for (const showProp of props) {
-      this.set(showProp, false);
-    }
-
-    this.$.errorView.classList.add('show');
-    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(text => {
-        err.moreInfo = text;
-        this._lastError = err;
-      });
-    }
-  }
-
-  _handleLocationChange(e) {
-    this._updateLoginUrl();
-
-    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);
-  }
-
-  _updateLoginUrl() {
-    const baseUrl = getBaseUrl();
-    if (baseUrl) {
-      // Strip the canonical path from the path since needing canonical in
-      // the path is unneeded and breaks the url.
-      this._loginUrl = baseUrl + '/login/' + encodeURIComponent(
-          '/' + window.location.pathname.substring(baseUrl.length) +
-          window.location.search +
-          window.location.hash);
-    } else {
-      this._loginUrl = '/login/' + encodeURIComponent(
-          window.location.pathname +
-          window.location.search +
-          window.location.hash);
-    }
-  }
-
-  _paramsChanged(paramsRecord) {
-    const params = paramsRecord.base;
-    const viewsToCheck = [GerritNav.View.SEARCH, GerritNav.View.DASHBOARD];
-    if (viewsToCheck.includes(params.view)) {
-      this.set('_lastSearchPage', location.pathname);
-    }
-  }
-
-  _handleTitleChange(e) {
-    if (e.detail.title) {
-      document.title = e.detail.title + ' · Gerrit Code Review';
-    } else {
-      document.title = '';
-    }
-  }
-
-  handleShowKeyboardShortcuts() {
-    this.loadKeyboardShortcutsDialog = true;
-    flush();
-    this.shadowRoot.querySelector('#keyboardShortcuts').open();
-  }
-
-  _showKeyboardShortcuts(e) {
-    // same shortcut should close the dialog if pressed again
-    // when dialog is open
-    this.loadKeyboardShortcutsDialog = true;
-    flush();
-    const keyboardShortcuts =
-      this.shadowRoot.querySelector('#keyboardShortcuts');
-    if (!keyboardShortcuts) return;
-    if (keyboardShortcuts.opened) {
-      keyboardShortcuts.close();
-      return;
-    }
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    keyboardShortcuts.open();
-  }
-
-  _handleKeyboardShortcutDialogClose() {
-    this.shadowRoot.querySelector('#keyboardShortcuts').close();
-  }
-
-  _handleAccountDetailUpdate(e) {
-    this.$.mainHeader.reload();
-    if (this.params.view === GerritNav.View.SETTINGS) {
-      this.shadowRoot.querySelector('gr-settings-view').reloadAccountDetail();
-    }
-  }
-
-  _handleRegistrationDialogClose(e) {
-    this.params.justRegistered = false;
-    this.shadowRoot.querySelector('#registrationOverlay').close();
-  }
-
-  _goToOpenedChanges() {
-    GerritNav.navigateToStatusSearch('open');
-  }
-
-  _goToUserDashboard() {
-    GerritNav.navigateToUserDashboard();
-  }
-
-  _goToMergedChanges() {
-    GerritNav.navigateToStatusSearch('merged');
-  }
-
-  _goToAbandonedChanges() {
-    GerritNav.navigateToStatusSearch('abandoned');
-  }
-
-  _goToWatchedChanges() {
-    // The query is hardcoded, and doesn't respect custom menu entries
-    GerritNav.navigateToSearchQuery('is:watched is:open');
-  }
-
-  _computePluginScreenName({plugin, screen}) {
-    if (!plugin || !screen) return '';
-    return `${plugin}-screen-${screen}`;
-  }
-
-  _logWelcome() {
-    console.group('Runtime Info');
-    console.info('Gerrit UI (PolyGerrit)');
-    console.info(`Gerrit Server Version: ${this._version}`);
-    if (window.VERSION_INFO) {
-      console.info(`UI Version Info: ${window.VERSION_INFO}`);
-    }
-    if (this._feedbackUrl) {
-      console.info(`Please file bugs and feedback at: ${this._feedbackUrl}`);
-    }
-    console.groupEnd();
-  }
-
-  /**
-   * Intercept RPC log events emitted by REST API interfaces.
-   * Note: the REST API interface cannot use gr-reporting directly because
-   * that would create a cyclic dependency.
-   */
-  _handleRpcLog(e) {
-    this.reporting.reportRpcTiming(e.detail.anonymizedUrl,
-        e.detail.elapsed);
-  }
-
-  _mobileSearchToggle(e) {
-    this.mobileSearch = !this.mobileSearch;
-  }
-
-  getThemeEndpoint() {
-    // For now, we only have dark mode and light mode
-    return window.localStorage.getItem('dark-theme') ?
-      'app-theme-dark' :
-      'app-theme-light';
-  }
-}
-
-customElements.define(GrAppElement.is, GrAppElement);
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
new file mode 100644
index 0000000..2915acd
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -0,0 +1,712 @@
+/**
+ * @license
+ * Copyright (C) 2019 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.
+ */
+import '../styles/shared-styles';
+import '../styles/themes/app-theme';
+import {applyTheme as applyDarkTheme} from '../styles/themes/dark-theme';
+import './admin/gr-admin-view/gr-admin-view';
+import './documentation/gr-documentation-search/gr-documentation-search';
+import './change-list/gr-change-list-view/gr-change-list-view';
+import './change-list/gr-dashboard-view/gr-dashboard-view';
+import './change/gr-change-view/gr-change-view';
+import './core/gr-error-manager/gr-error-manager';
+import './core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog';
+import './core/gr-main-header/gr-main-header';
+import './core/gr-router/gr-router';
+import './core/gr-smart-search/gr-smart-search';
+import './diff/gr-diff-view/gr-diff-view';
+import './edit/gr-editor-view/gr-editor-view';
+import './plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import './plugins/gr-endpoint-param/gr-endpoint-param';
+import './plugins/gr-endpoint-slot/gr-endpoint-slot';
+import './plugins/gr-external-style/gr-external-style';
+import './plugins/gr-plugin-host/gr-plugin-host';
+import './settings/gr-cla-view/gr-cla-view';
+import './settings/gr-registration-dialog/gr-registration-dialog';
+import './settings/gr-settings-view/gr-settings-view';
+import './shared/gr-lib-loader/gr-lib-loader';
+import './shared/gr-rest-api-interface/gr-rest-api-interface';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-app-element_html';
+import {getBaseUrl} from '../utils/url-util';
+import {
+  KeyboardShortcutMixin,
+  Shortcut,
+  SPECIAL_SHORTCUT,
+} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {GerritNav, GerritView} from './core/gr-navigation/gr-navigation';
+import {appContext} from '../services/app-context';
+import {flush} from '@polymer/polymer/lib/utils/flush';
+import {customElement, observe, property} from '@polymer/decorators';
+import {RestApiService} from '../services/services/gr-rest-api/gr-rest-api';
+import {GrRouter} from './core/gr-router/gr-router';
+import {
+  AccountDetailInfo,
+  ElementPropertyDeepChange,
+  ServerInfo,
+} from '../types/common';
+import {GrErrorManager} from './core/gr-error-manager/gr-error-manager';
+import {GrOverlay} from './shared/gr-overlay/gr-overlay';
+import {GrRegistrationDialog} from './settings/gr-registration-dialog/gr-registration-dialog';
+import {
+  AppElementJustRegisteredParams,
+  AppElementParams,
+  isAppElementJustRegisteredParams,
+} from './gr-app-types';
+import {GrMainHeader} from './core/gr-main-header/gr-main-header';
+import {GrSettingsView} from './settings/gr-settings-view/gr-settings-view';
+import {
+  CustomKeyboardEvent,
+  LocationChangeEvent,
+  PageErrorEventDetail,
+  RpcLogEvent,
+  ShortcutTriggeredEvent,
+  TitleChangeEventDetail,
+} from '../types/events';
+import {ViewState} from '../types/types';
+import {EventType} from '../utils/event-util';
+
+interface ErrorInfo {
+  text: string;
+  emoji?: string;
+  moreInfo?: string;
+}
+
+export interface GrAppElement {
+  $: {
+    restAPI: RestApiService & Element;
+    router: GrRouter;
+    errorManager: GrErrorManager;
+    errorView: HTMLDivElement;
+    mainHeader: GrMainHeader;
+  };
+}
+
+// TODO(TS): implement AppElement interface from gr-app-types.ts
+@customElement('gr-app-element')
+export class GrAppElement extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the URL location changes.
+   *
+   * @event location-change
+   */
+
+  @property({type: Object})
+  params?: AppElementParams;
+
+  @property({type: Object})
+  keyEventTarget = document.body;
+
+  @property({type: Object, observer: '_accountChanged'})
+  _account?: AccountDetailInfo;
+
+  @property({type: Number})
+  _lastGKeyPressTimestamp: number | null = null;
+
+  @property({type: Object})
+  _serverConfig?: ServerInfo;
+
+  @property({type: String})
+  _version?: string;
+
+  @property({type: Boolean})
+  _showChangeListView?: boolean;
+
+  @property({type: Boolean})
+  _showDashboardView?: boolean;
+
+  @property({type: Boolean})
+  _showChangeView?: boolean;
+
+  @property({type: Boolean})
+  _showDiffView?: boolean;
+
+  @property({type: Boolean})
+  _showSettingsView?: boolean;
+
+  @property({type: Boolean})
+  _showAdminView?: boolean;
+
+  @property({type: Boolean})
+  _showCLAView?: boolean;
+
+  @property({type: Boolean})
+  _showEditorView?: boolean;
+
+  @property({type: Boolean})
+  _showPluginScreen?: boolean;
+
+  @property({type: Boolean})
+  _showDocumentationSearch?: boolean;
+
+  @property({type: Object})
+  _viewState?: ViewState;
+
+  @property({type: Object})
+  _lastError?: ErrorInfo;
+
+  @property({type: String})
+  _lastSearchPage?: string;
+
+  @property({type: String})
+  _path?: string;
+
+  @property({type: String, computed: '_computePluginScreenName(params)'})
+  _pluginScreenName?: string;
+
+  @property({type: String})
+  _settingsUrl?: string;
+
+  @property({type: String})
+  _feedbackUrl?: string;
+
+  @property({type: Boolean})
+  mobileSearch = false;
+
+  @property({type: String})
+  _loginUrl = '/login';
+
+  @property({type: Boolean})
+  loadRegistrationDialog = false;
+
+  @property({type: Boolean})
+  loadKeyboardShortcutsDialog = false;
+
+  private reporting = appContext.reportingService;
+
+  keyboardShortcuts() {
+    return {
+      [Shortcut.OPEN_SHORTCUT_HELP_DIALOG]: '_showKeyboardShortcuts',
+      [Shortcut.GO_TO_USER_DASHBOARD]: '_goToUserDashboard',
+      [Shortcut.GO_TO_OPENED_CHANGES]: '_goToOpenedChanges',
+      [Shortcut.GO_TO_MERGED_CHANGES]: '_goToMergedChanges',
+      [Shortcut.GO_TO_ABANDONED_CHANGES]: '_goToAbandonedChanges',
+      [Shortcut.GO_TO_WATCHED_CHANGES]: '_goToWatchedChanges',
+    };
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this._bindKeyboardShortcuts();
+    this.addEventListener(EventType.PAGE_ERROR, e => {
+      this._handlePageError(e);
+    });
+    this.addEventListener(EventType.TITLE_CHANGE, e => {
+      this._handleTitleChange(e);
+    });
+    this.addEventListener('location-change', e =>
+      this._handleLocationChange(e)
+    );
+    this.addEventListener('rpc-log', e => this._handleRpcLog(e));
+    this.addEventListener('shortcut-triggered', e =>
+      this._handleShortcutTriggered(e)
+    );
+    // Ideally individual views should handle this event and respond with a soft
+    // reload. This is a catch-all for all views that cannot or have not
+    // implemented that.
+    this.addEventListener('reload', () => window.location.reload());
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._updateLoginUrl();
+    this.reporting.appStarted();
+    this.$.router.start();
+
+    this.$.restAPI.getAccount().then(account => {
+      this._account = account;
+      const role = account ? 'user' : 'guest';
+      this.reporting.reportLifeCycle(`Started as ${role}`);
+    });
+    this.$.restAPI.getConfig().then(config => {
+      this._serverConfig = config;
+
+      if (config && config.gerrit && config.gerrit.report_bug_url) {
+        this._feedbackUrl = config.gerrit.report_bug_url;
+      }
+    });
+    this.$.restAPI.getVersion().then(version => {
+      this._version = version;
+      this._logWelcome();
+    });
+
+    if (window.localStorage.getItem('dark-theme')) {
+      applyDarkTheme();
+    }
+
+    // Note: this is evaluated here to ensure that it only happens after the
+    // router has been initialized. @see Issue 7837
+    this._settingsUrl = GerritNav.getUrlForSettings();
+
+    this._viewState = {
+      changeView: {
+        changeNum: null,
+        patchRange: null,
+        selectedFileIndex: 0,
+        showReplyDialog: false,
+        showDownloadDialog: false,
+        diffMode: null,
+        numFilesShown: null,
+        scrollTop: 0,
+      },
+      changeListView: {
+        query: null,
+        offset: 0,
+        selectedChangeIndex: 0,
+      },
+      dashboardView: {
+        selectedChangeIndex: 0,
+      },
+    };
+  }
+
+  _bindKeyboardShortcuts() {
+    this.bindShortcut(
+      Shortcut.SEND_REPLY,
+      SPECIAL_SHORTCUT.DOC_ONLY,
+      'ctrl+enter',
+      'meta+enter'
+    );
+    this.bindShortcut(Shortcut.EMOJI_DROPDOWN, SPECIAL_SHORTCUT.DOC_ONLY, ':');
+
+    this.bindShortcut(Shortcut.OPEN_SHORTCUT_HELP_DIALOG, '?');
+    this.bindShortcut(
+      Shortcut.GO_TO_USER_DASHBOARD,
+      SPECIAL_SHORTCUT.GO_KEY,
+      'i'
+    );
+    this.bindShortcut(
+      Shortcut.GO_TO_OPENED_CHANGES,
+      SPECIAL_SHORTCUT.GO_KEY,
+      'o'
+    );
+    this.bindShortcut(
+      Shortcut.GO_TO_MERGED_CHANGES,
+      SPECIAL_SHORTCUT.GO_KEY,
+      'm'
+    );
+    this.bindShortcut(
+      Shortcut.GO_TO_ABANDONED_CHANGES,
+      SPECIAL_SHORTCUT.GO_KEY,
+      'a'
+    );
+    this.bindShortcut(
+      Shortcut.GO_TO_WATCHED_CHANGES,
+      SPECIAL_SHORTCUT.GO_KEY,
+      'w'
+    );
+
+    this.bindShortcut(Shortcut.CURSOR_NEXT_CHANGE, 'j');
+    this.bindShortcut(Shortcut.CURSOR_PREV_CHANGE, 'k');
+    this.bindShortcut(Shortcut.OPEN_CHANGE, 'o');
+    this.bindShortcut(Shortcut.NEXT_PAGE, 'n', ']');
+    this.bindShortcut(Shortcut.PREV_PAGE, 'p', '[');
+    this.bindShortcut(Shortcut.TOGGLE_CHANGE_REVIEWED, 'r:keyup');
+    this.bindShortcut(Shortcut.TOGGLE_CHANGE_STAR, 's:keydown');
+    this.bindShortcut(Shortcut.REFRESH_CHANGE_LIST, 'shift+r:keyup');
+    this.bindShortcut(Shortcut.EDIT_TOPIC, 't');
+
+    this.bindShortcut(Shortcut.OPEN_REPLY_DIALOG, 'a:keyup');
+    this.bindShortcut(Shortcut.OPEN_DOWNLOAD_DIALOG, 'd:keyup');
+    this.bindShortcut(Shortcut.EXPAND_ALL_MESSAGES, 'x');
+    this.bindShortcut(Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
+    this.bindShortcut(Shortcut.REFRESH_CHANGE, 'shift+r:keyup');
+    this.bindShortcut(Shortcut.UP_TO_DASHBOARD, 'u');
+    this.bindShortcut(Shortcut.UP_TO_CHANGE, 'u');
+    this.bindShortcut(Shortcut.TOGGLE_DIFF_MODE, 'm:keyup');
+    this.bindShortcut(
+      Shortcut.DIFF_AGAINST_BASE,
+      SPECIAL_SHORTCUT.V_KEY,
+      'down',
+      's'
+    );
+    // this keyboard shortcut is used in toast _displayDiffAgainstLatestToast
+    // in gr-diff-view. Any updates here should be reflected there
+    this.bindShortcut(
+      Shortcut.DIFF_AGAINST_LATEST,
+      SPECIAL_SHORTCUT.V_KEY,
+      'up',
+      'w'
+    );
+    // this keyboard shortcut is used in toast _displayDiffBaseAgainstLeftToast
+    // in gr-diff-view. Any updates here should be reflected there
+    this.bindShortcut(
+      Shortcut.DIFF_BASE_AGAINST_LEFT,
+      SPECIAL_SHORTCUT.V_KEY,
+      'left',
+      'a'
+    );
+    this.bindShortcut(
+      Shortcut.DIFF_RIGHT_AGAINST_LATEST,
+      SPECIAL_SHORTCUT.V_KEY,
+      'right',
+      'd'
+    );
+    this.bindShortcut(
+      Shortcut.DIFF_BASE_AGAINST_LATEST,
+      SPECIAL_SHORTCUT.V_KEY,
+      'b'
+    );
+
+    this.bindShortcut(Shortcut.NEXT_LINE, 'j', 'down');
+    this.bindShortcut(Shortcut.PREV_LINE, 'k', 'up');
+    if (this._isCursorManagerSupportMoveToVisibleLine()) {
+      this.bindShortcut(Shortcut.VISIBLE_LINE, '.');
+    }
+    this.bindShortcut(Shortcut.NEXT_CHUNK, 'n');
+    this.bindShortcut(Shortcut.PREV_CHUNK, 'p');
+    this.bindShortcut(Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
+    this.bindShortcut(Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
+    this.bindShortcut(Shortcut.PREV_COMMENT_THREAD, 'shift+p');
+    this.bindShortcut(
+      Shortcut.EXPAND_ALL_COMMENT_THREADS,
+      SPECIAL_SHORTCUT.DOC_ONLY,
+      'e'
+    );
+    this.bindShortcut(
+      Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
+      SPECIAL_SHORTCUT.DOC_ONLY,
+      'shift+e'
+    );
+    this.bindShortcut(Shortcut.LEFT_PANE, 'shift+left');
+    this.bindShortcut(Shortcut.RIGHT_PANE, 'shift+right');
+    this.bindShortcut(Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
+    this.bindShortcut(Shortcut.NEW_COMMENT, 'c');
+    this.bindShortcut(
+      Shortcut.SAVE_COMMENT,
+      'ctrl+enter',
+      'meta+enter',
+      'ctrl+s',
+      'meta+s'
+    );
+    this.bindShortcut(Shortcut.OPEN_DIFF_PREFS, ',');
+    this.bindShortcut(Shortcut.TOGGLE_DIFF_REVIEWED, 'r:keyup');
+
+    this.bindShortcut(Shortcut.NEXT_FILE, ']');
+    this.bindShortcut(Shortcut.PREV_FILE, '[');
+    this.bindShortcut(Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
+    this.bindShortcut(Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
+    this.bindShortcut(Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
+    this.bindShortcut(Shortcut.CURSOR_PREV_FILE, 'k', 'up');
+    this.bindShortcut(Shortcut.OPEN_FILE, 'o', 'enter');
+    this.bindShortcut(Shortcut.TOGGLE_FILE_REVIEWED, 'r:keyup');
+    this.bindShortcut(Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
+    this.bindShortcut(Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
+    this.bindShortcut(Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
+    this.bindShortcut(Shortcut.TOGGLE_BLAME, 'b:keyup');
+    this.bindShortcut(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, 'h');
+    this.bindShortcut(Shortcut.OPEN_FILE_LIST, 'f');
+
+    this.bindShortcut(Shortcut.OPEN_FIRST_FILE, ']');
+    this.bindShortcut(Shortcut.OPEN_LAST_FILE, '[');
+
+    this.bindShortcut(Shortcut.SEARCH, '/');
+  }
+
+  _isCursorManagerSupportMoveToVisibleLine() {
+    // This method is a copy-paste from the
+    // method _isIntersectionObserverSupported of gr-cursor-manager.js
+    // It is better share this method with gr-cursor-manager,
+    // but doing it require a lot if changes instead of 1-line copied code
+    return 'IntersectionObserver' in window;
+  }
+
+  _accountChanged(account?: AccountDetailInfo) {
+    if (!account) return;
+
+    // Preferences are cached when a user is logged in; warm them.
+    this.$.restAPI.getPreferences();
+    this.$.restAPI.getDiffPreferences();
+    this.$.restAPI.getEditPreferences();
+    this.$.errorManager.knownAccountId =
+      (this._account && this._account._account_id) || null;
+  }
+
+  @observe('params.view')
+  _viewChanged(view?: GerritView) {
+    this.$.errorView.classList.remove('show');
+    this.set('_showChangeListView', view === GerritView.SEARCH);
+    this.set('_showDashboardView', view === GerritView.DASHBOARD);
+    this.set('_showChangeView', view === GerritView.CHANGE);
+    this.set('_showDiffView', view === GerritView.DIFF);
+    this.set('_showSettingsView', view === GerritView.SETTINGS);
+    // _showAdminView must be in sync with the gr-admin-view AdminViewParams type
+    this.set(
+      '_showAdminView',
+      view === GerritView.ADMIN ||
+        view === GerritView.GROUP ||
+        view === GerritView.REPO
+    );
+    this.set('_showCLAView', view === GerritView.AGREEMENTS);
+    this.set('_showEditorView', view === GerritView.EDIT);
+    const isPluginScreen = view === GerritView.PLUGIN_SCREEN;
+    this.set('_showPluginScreen', false);
+    // Navigation within plugin screens does not restamp gr-endpoint-decorator
+    // because _showPluginScreen value does not change. To force restamp,
+    // change _showPluginScreen value between true and false.
+    if (isPluginScreen) {
+      this.async(() => this.set('_showPluginScreen', true), 1);
+    }
+    this.set(
+      '_showDocumentationSearch',
+      view === GerritView.DOCUMENTATION_SEARCH
+    );
+    if (
+      this.params &&
+      isAppElementJustRegisteredParams(this.params) &&
+      this.params.justRegistered
+    ) {
+      this.loadRegistrationDialog = true;
+      flush();
+      const registrationOverlay = this.shadowRoot!.querySelector(
+        '#registrationOverlay'
+      ) as GrOverlay;
+      const registrationDialog = this.shadowRoot!.querySelector(
+        '#registrationDialog'
+      ) as GrRegistrationDialog;
+      registrationOverlay.open();
+      registrationDialog.loadData().then(() => {
+        registrationOverlay.refit();
+      });
+    }
+  }
+
+  _handleShortcutTriggered(event: ShortcutTriggeredEvent) {
+    const {event: e, goKey, vKey} = event.detail;
+    // eg: {key: "k:keydown", ..., from: "gr-diff-view"}
+    let key = `${((e as unknown) as KeyboardEvent).key}:${e.type}`;
+    if (goKey) key = 'g+' + key;
+    if (vKey) key = 'v+' + key;
+    if (e.shiftKey) key = 'shift+' + key;
+    if (e.ctrlKey) key = 'ctrl+' + key;
+    if (e.metaKey) key = 'meta+' + key;
+    if (e.altKey) key = 'alt+' + key;
+    this.reporting.reportInteraction('shortcut-triggered', {
+      key,
+      from:
+        (event.path && event.path[0] && (event.path[0] as Element).nodeName) ??
+        'unknown',
+    });
+  }
+
+  _handlePageError(e: CustomEvent<PageErrorEventDetail>) {
+    const props = [
+      '_showChangeListView',
+      '_showDashboardView',
+      '_showChangeView',
+      '_showDiffView',
+      '_showSettingsView',
+      '_showAdminView',
+    ];
+    for (const showProp of props) {
+      this.set(showProp, false);
+    }
+
+    this.$.errorView.classList.add('show');
+    const response = e.detail.response;
+    const err: ErrorInfo = {
+      text: [response.status, response.statusText].join(' '),
+    };
+    if (response.status === 404) {
+      err.emoji = '¯\\_(ツ)_/¯';
+      this._lastError = err;
+    } else {
+      err.emoji = 'o_O';
+      response.text().then(text => {
+        err.moreInfo = text;
+        this._lastError = err;
+      });
+    }
+  }
+
+  _handleLocationChange(e: LocationChangeEvent) {
+    this._updateLoginUrl();
+
+    const hash = e.detail.hash.substring(1);
+    let pathname = e.detail.pathname;
+    if (pathname.startsWith('/c/') && Number(hash) > 0) {
+      pathname += '@' + hash;
+    }
+    this.set('_path', pathname);
+  }
+
+  _updateLoginUrl() {
+    const baseUrl = getBaseUrl();
+    if (baseUrl) {
+      // Strip the canonical path from the path since needing canonical in
+      // the path is unneeded and breaks the url.
+      this._loginUrl =
+        baseUrl +
+        '/login/' +
+        encodeURIComponent(
+          '/' +
+            window.location.pathname.substring(baseUrl.length) +
+            window.location.search +
+            window.location.hash
+        );
+    } else {
+      this._loginUrl =
+        '/login/' +
+        encodeURIComponent(
+          window.location.pathname +
+            window.location.search +
+            window.location.hash
+        );
+    }
+  }
+
+  @observe('params.*')
+  _paramsChanged(
+    paramsRecord: ElementPropertyDeepChange<GrAppElement, 'params'>
+  ) {
+    const params = paramsRecord.base;
+    const viewsToCheck = [GerritView.SEARCH, GerritView.DASHBOARD];
+    if (params?.view && viewsToCheck.includes(params.view)) {
+      this.set('_lastSearchPage', location.pathname);
+    }
+  }
+
+  _handleTitleChange(e: CustomEvent<TitleChangeEventDetail>) {
+    if (e.detail.title) {
+      document.title = e.detail.title + ' · Gerrit Code Review';
+    } else {
+      document.title = '';
+    }
+  }
+
+  handleShowKeyboardShortcuts() {
+    this.loadKeyboardShortcutsDialog = true;
+    flush();
+    (this.shadowRoot!.querySelector('#keyboardShortcuts') as GrOverlay).open();
+  }
+
+  _showKeyboardShortcuts(e: CustomKeyboardEvent) {
+    // same shortcut should close the dialog if pressed again
+    // when dialog is open
+    this.loadKeyboardShortcutsDialog = true;
+    flush();
+    const keyboardShortcuts = this.shadowRoot!.querySelector(
+      '#keyboardShortcuts'
+    ) as GrOverlay;
+    if (!keyboardShortcuts) return;
+    if (keyboardShortcuts.opened) {
+      keyboardShortcuts.close();
+      return;
+    }
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+    keyboardShortcuts.open();
+  }
+
+  _handleKeyboardShortcutDialogClose() {
+    (this.shadowRoot!.querySelector('#keyboardShortcuts') as GrOverlay).close();
+  }
+
+  _handleAccountDetailUpdate() {
+    this.$.mainHeader.reload();
+    if (this.params?.view === GerritView.SETTINGS) {
+      (this.shadowRoot!.querySelector(
+        'gr-settings-view'
+      ) as GrSettingsView).reloadAccountDetail();
+    }
+  }
+
+  _handleRegistrationDialogClose() {
+    // The registration dialog is visible only if this.params is
+    // instanceof AppElementJustRegisteredParams
+    (this.params as AppElementJustRegisteredParams).justRegistered = false;
+    (this.shadowRoot!.querySelector(
+      '#registrationOverlay'
+    ) as GrOverlay).close();
+  }
+
+  _goToOpenedChanges() {
+    GerritNav.navigateToStatusSearch('open');
+  }
+
+  _goToUserDashboard() {
+    GerritNav.navigateToUserDashboard();
+  }
+
+  _goToMergedChanges() {
+    GerritNav.navigateToStatusSearch('merged');
+  }
+
+  _goToAbandonedChanges() {
+    GerritNav.navigateToStatusSearch('abandoned');
+  }
+
+  _goToWatchedChanges() {
+    // The query is hardcoded, and doesn't respect custom menu entries
+    GerritNav.navigateToSearchQuery('is:watched is:open');
+  }
+
+  _computePluginScreenName(params: AppElementParams) {
+    if (params.view !== GerritView.PLUGIN_SCREEN) return '';
+    if (!params.plugin || !params.screen) return '';
+    return `${params.plugin}-screen-${params.screen}`;
+  }
+
+  _logWelcome() {
+    console.group('Runtime Info');
+    console.info('Gerrit UI (PolyGerrit)');
+    console.info(`Gerrit Server Version: ${this._version}`);
+    if (window.VERSION_INFO) {
+      console.info(`UI Version Info: ${window.VERSION_INFO}`);
+    }
+    if (this._feedbackUrl) {
+      console.info(`Please file bugs and feedback at: ${this._feedbackUrl}`);
+    }
+    console.groupEnd();
+  }
+
+  /**
+   * Intercept RPC log events emitted by REST API interfaces.
+   * Note: the REST API interface cannot use gr-reporting directly because
+   * that would create a cyclic dependency.
+   */
+  _handleRpcLog(e: RpcLogEvent) {
+    this.reporting.reportRpcTiming(e.detail.anonymizedUrl, e.detail.elapsed);
+  }
+
+  _mobileSearchToggle() {
+    this.mobileSearch = !this.mobileSearch;
+  }
+
+  getThemeEndpoint() {
+    // For now, we only have dark mode and light mode
+    return window.localStorage.getItem('dark-theme')
+      ? 'app-theme-dark'
+      : 'app-theme-light';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-app-element': GrAppElement;
+  }
+}
diff --git a/polygerrit-ui/app/elements/gr-app-global-var-init.ts b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
index e609e6f..fac5f45 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.ts
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
@@ -96,7 +96,6 @@
 import {RevisionInfo} from './shared/revision-info/revision-info';
 import {CoverageType} from '../types/types';
 import {_setHiddenScroll, getHiddenScroll} from '../scripts/hiddenscroll';
-import {GerritGlobal} from './shared/gr-js-api-interface/gr-gerrit';
 
 export function initGlobalVariables() {
   window.GrDisplayNameUtils = {
@@ -159,7 +158,7 @@
     PLUGIN_LOADING_TIMEOUT_MS,
   };
 
-  window.Gerrit = (window.Gerrit || {}) as GerritGlobal;
+  window.Gerrit = window.Gerrit || {};
   window.Gerrit.Nav = GerritNav;
   window.Gerrit.getRootElement = getRootElement;
   window.Gerrit.Auth = appContext.authService;
diff --git a/polygerrit-ui/app/elements/gr-app-init.ts b/polygerrit-ui/app/elements/gr-app-init.ts
index 93fda87..6d79ce1 100644
--- a/polygerrit-ui/app/elements/gr-app-init.ts
+++ b/polygerrit-ui/app/elements/gr-app-init.ts
@@ -28,11 +28,10 @@
 
 if (!window.Polymer) {
   // Without as... it violates internal google rules.
-  (window.Polymer as UninitializedPolymer) = {
+  ((window.Polymer as unknown) as UninitializedPolymer) = {
     lazyRegister: true,
   };
 }
-window.Gerrit = window.Gerrit || {};
 
 initAppContext();
 initVisibilityReporter(appContext);
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
index ef382ad..b05117f 100644
--- a/polygerrit-ui/app/elements/gr-app-types.ts
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -20,7 +20,14 @@
   GroupDetailView,
   RepoDetailView,
 } from './core/gr-navigation/gr-navigation';
-import {GroupId, RepoName} from '../types/common';
+import {
+  DashboardId,
+  GroupId,
+  NumericChangeId,
+  PatchSetNum,
+  RepoName,
+  UrlEncodedCommentId,
+} from '../types/common';
 
 export interface AppElement extends HTMLElement {
   params: AppElementParams | GenerateUrlParameters;
@@ -32,9 +39,9 @@
 export interface AppElementDashboardParams {
   view: GerritView.DASHBOARD;
   project?: RepoName;
-  dashboard?: string;
+  dashboard: DashboardId;
   user?: string;
-  sections?: Array<{name: string; query: string}>;
+  sections: Array<{name: string; query: string}>;
   title?: string;
 }
 
@@ -67,8 +74,8 @@
 
 export interface AppElementPluginScreenParams {
   view: GerritView.PLUGIN_SCREEN;
-  plugin: string;
-  screen: string;
+  plugin?: string;
+  screen?: string;
 }
 
 export interface AppElementSearchParam {
@@ -86,18 +93,54 @@
   view: GerritView.AGREEMENTS;
 }
 
+export interface AppElementDiffViewParam {
+  view: GerritView.DIFF;
+  changeNum: NumericChangeId;
+  project?: RepoName;
+  commentId?: UrlEncodedCommentId;
+  path?: string;
+  patchNum?: PatchSetNum;
+  basePatchNum?: PatchSetNum;
+  lineNum: number;
+  leftSide?: boolean;
+  commentLink?: boolean;
+}
+export interface AppElementChangeViewParams {
+  view: GerritView.CHANGE;
+  changeNum: NumericChangeId;
+  project: RepoName;
+  edit?: boolean;
+  patchNum?: PatchSetNum;
+  basePatchNum?: PatchSetNum;
+  queryMap?: Map<string, string> | URLSearchParams;
+}
+
 export interface AppElementJustRegisteredParams {
-  justRegistered: true;
+  // We use params.view === ... as a type guard.
+  // The view?: never tells to the compiler that
+  // AppElementJustRegisteredParams can't have view property.
+  // Otherwise, the compiler reports an error when the code tries to use
+  // the property 'view' of AppElementParams.
+  view?: never;
+  justRegistered: boolean;
 }
 
 export type AppElementParams =
   | AppElementDashboardParams
   | AppElementGroupParams
   | AppElementAdminParams
+  | AppElementChangeViewParams
   | AppElementRepoParams
   | AppElementDocSearchParams
   | AppElementPluginScreenParams
   | AppElementSearchParam
   | AppElementSettingsParam
   | AppElementAgreementParam
+  | AppElementDiffViewParam
   | AppElementJustRegisteredParams;
+
+export function isAppElementJustRegisteredParams(
+  p: AppElementParams
+): p is AppElementJustRegisteredParams {
+  return (p as AppElementJustRegisteredParams).justRegistered !== undefined;
+}
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
deleted file mode 100644
index 2801296..0000000
--- a/polygerrit-ui/app/elements/gr-app.js
+++ /dev/null
@@ -1,69 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-import {safeTypesBridge} from '../utils/safe-types-util.js';
-
-// We need to use goog.declareModuleId internally in google for TS-imports-JS
-// case. To avoid errors when goog is not available, the empty implementation is
-// added.
-window.goog = window.goog || {declareModuleId(name) {}};
-import './gr-app-init.js';
-import './font-roboto-local-loader.js';
-// Sets up global Polymer variable, because plugins requires it.
-import '../scripts/bundled-polymer.js';
-
-/**
- * setCancelSyntheticClickEvents is set to true by
- * default which will cancel synthetic click events
- * on older touch device.
- * See https://github.com/Polymer/polymer/issues/5289
- */
-import {setPassiveTouchGestures, setCancelSyntheticClickEvents} from '@polymer/polymer/lib/utils/settings.js';
-setCancelSyntheticClickEvents(false);
-setPassiveTouchGestures(true);
-
-import 'polymer-resin/standalone/polymer-resin.js';
-import {initGlobalVariables} from './gr-app-global-var-init.js';
-import './gr-app-element.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-app_html.js';
-import {initGerritPluginApi} from './shared/gr-js-api-interface/gr-gerrit.js';
-import {appContext} from '../services/app-context.js';
-
-security.polymer_resin.install({
-  allowedIdentifierPrefixes: [''],
-  reportHandler: security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER,
-  safeTypesBridge,
-});
-
-/** @extends PolymerElement */
-class GrApp extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  // When you are converting gr-app.js to ts, implement interface AppElement
-  // from the gr-app-types.ts
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-app'; }
-}
-
-customElements.define(GrApp.is, GrApp);
-
-initGlobalVariables();
-initGerritPluginApi(appContext);
diff --git a/polygerrit-ui/app/elements/gr-app.ts b/polygerrit-ui/app/elements/gr-app.ts
new file mode 100644
index 0000000..f19931f
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app.ts
@@ -0,0 +1,63 @@
+/**
+ * @license
+ * 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.
+ */
+
+import {safeTypesBridge} from '../utils/safe-types-util';
+import './gr-app-init';
+import './font-roboto-local-loader';
+// Sets up global Polymer variable, because plugins requires it.
+import '../scripts/bundled-polymer';
+
+/**
+ * setCancelSyntheticClickEvents is set to true by
+ * default which will cancel synthetic click events
+ * on older touch device.
+ * See https://github.com/Polymer/polymer/issues/5289
+ */
+import {
+  setPassiveTouchGestures,
+  setCancelSyntheticClickEvents,
+} from '@polymer/polymer/lib/utils/settings';
+setCancelSyntheticClickEvents(false);
+setPassiveTouchGestures(true);
+
+import {initGlobalVariables} from './gr-app-global-var-init';
+import './gr-app-element';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-app_html';
+import {initGerritPluginApi} from './shared/gr-js-api-interface/gr-gerrit';
+import {customElement} from '@polymer/decorators';
+import {installPolymerResin} from '../scripts/polymer-resin-install';
+
+installPolymerResin(safeTypesBridge);
+
+@customElement('gr-app')
+class GrApp extends GestureEventListeners(LegacyElementMixin(PolymerElement)) {
+  static get template() {
+    return htmlTemplate;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-app': GrApp;
+  }
+}
+
+initGlobalVariables();
+initGerritPluginApi();
diff --git a/polygerrit-ui/app/elements/lit/gr-lit-element.ts b/polygerrit-ui/app/elements/lit/gr-lit-element.ts
new file mode 100644
index 0000000..9ebadd5
--- /dev/null
+++ b/polygerrit-ui/app/elements/lit/gr-lit-element.ts
@@ -0,0 +1,52 @@
+/**
+ * @license
+ * Copyright (C) 2020 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.
+ */
+import {LitElement} from 'lit-element';
+import {Observable, Subject} from 'rxjs';
+import {takeUntil} from 'rxjs/operators';
+
+/**
+ * Base class for Gerrit's lit-elements.
+ *
+ * Adds basic functionality that we want to have available in all Gerrit's
+ * components.
+ */
+export abstract class GrLitElement extends LitElement {
+  disconnected$ = new Subject();
+
+  /**
+   * Hooks up an element property with an observable. Apart from subscribing it
+   * makes sure that you are unsubscribed when the component is disconnected.
+   * And it requests a template check when a new value comes in.
+   *
+   * Should be called from connectedCallback() such that you will be
+   * re-subscribed when the component is re-connected.
+   *
+   * TODO: Maybe distinctUntilChanged should be applied to obs$?
+   */
+  subscribe<Key extends keyof this>(prop: Key, obs$: Observable<this[Key]>) {
+    obs$.pipe(takeUntil(this.disconnected$)).subscribe(value => {
+      const oldValue = this[prop];
+      this[prop] = value;
+      this.requestUpdate(prop, oldValue);
+    });
+  }
+
+  disconnectedCallback() {
+    this.disconnected$.next();
+    super.disconnectedCallback();
+  }
+}
diff --git a/polygerrit-ui/app/elements/lit/gr-lit-element_test.ts b/polygerrit-ui/app/elements/lit/gr-lit-element_test.ts
new file mode 100644
index 0000000..9b7b0e2
--- /dev/null
+++ b/polygerrit-ui/app/elements/lit/gr-lit-element_test.ts
@@ -0,0 +1,40 @@
+/**
+ * @license
+ * Copyright (C) 2020 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.
+ */
+
+import '../../test/common-test-setup-karma';
+import {html, customElement} from 'lit-element';
+import {GrLitElement} from './gr-lit-element';
+
+@customElement('test-gr-lit-element')
+export class TestGrLitElement extends GrLitElement {
+  render() {
+    return html`<span>test</span>`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'test-gr-lit-element': GrLitElement;
+  }
+}
+
+suite('gr-lit-element test', () => {
+  test('is defined', () => {
+    const el = document.createElement('test-gr-lit-element');
+    assert.instanceOf(el, TestGrLitElement);
+  });
+});
diff --git a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts
index d3452dc..322d32e 100644
--- a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts
@@ -30,8 +30,7 @@
     this._hook = this.plugin.hook('change-metadata-item');
   }
 
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  onLabelsChanged(callback: (value: any) => void) {
+  onLabelsChanged(callback: (value: unknown) => void) {
     if (!this._hook) {
       this._createHook();
     }
diff --git a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api-types.ts b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api-types.ts
new file mode 100644
index 0000000..063d89d
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api-types.ts
@@ -0,0 +1,370 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Settings
+ *
+ * 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.
+ */
+
+// IMPORTANT !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+// The entire API is currently in DRAFT state.
+// Changes to all type and interfaces are expected.
+// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+
+export interface GrChecksApiInterface {
+  /**
+   * Must only be called once. You cannot register twice. You cannot unregister.
+   */
+  register(provider: ChecksProvider, config?: ChecksApiConfig): void;
+
+  /**
+   * Forces Gerrit to call fetch() on the registered provider. Can be called
+   * when the provider has gotten an update and does not want wait for the next
+   * polling interval to pass.
+   */
+  announceUpdate(): void;
+}
+
+export interface ChecksApiConfig {
+  /**
+   * How often should the provider be called for new CheckData while the user
+   * navigates change related pages and the browser tab remains visible?
+   * Set to 0 to disable polling. Default is 60 seconds.
+   */
+  fetchPollingIntervalSeconds: number;
+}
+
+export interface ChecksProvider {
+  /**
+   * Gerrit calls this method when ...
+   * - ... the change or diff page is loaded.
+   * - ... the user switches back to a Gerrit tab with a change or diff page.
+   * - ... while the tab is visible in a regular polling interval, see
+   *       ChecksApiConfig.
+   */
+  fetch(change: number, patchset: number): Promise<FetchResponse>;
+}
+
+export interface FetchResponse {
+  responseCode: ResponseCode;
+
+  /** Only relevant when the responseCode is ERROR. */
+  errorMessage?: string;
+
+  /**
+   * Only relevant when the responseCode is NOT_LOGGED_IN.
+   * Gerrit displays a "Login" button and calls this callback when the user
+   * clicks on the button.
+   */
+  loginCallback?: () => void;
+
+  runs: CheckRun[];
+  results: CheckResult[];
+}
+
+export enum ResponseCode {
+  OK,
+  ERROR,
+  NOT_LOGGED_IN,
+}
+
+/**
+ * A CheckRun models an entity that has start/end timestamps and can be in
+ * either of the states RUNNABLE, RUNNING, COMPLETED. By itself it cannot model
+ * an error, neither can it be failed or successful by itself. A run can be
+ * associated with 0 to n results (see below). So until runs are completed the
+ * runs are more interesting for the user: What is going on at the moment? When
+ * runs are completed the users' interest shifts to results: What do I have to
+ * fix? The only actions that can be associated with runs are RUN and CANCEL.
+ */
+export interface CheckRun {
+  /**
+   * Gerrit requests check runs and results from the plugin by change number and
+   * patchset number. So these two properties can as well be left empty when
+   * returning results to the Gerrit UI and are thus optional.
+   */
+  change?: number;
+  /**
+   * Typically only runs for the latest patchset are requested and presented.
+   * Older runs and their results are only available on request, e.g. by
+   * switching to another patchset in a dropdown
+   *
+   * TBD: Check data providers may decide that runs and results are applicable
+   * to a newer patchset, even if they were produced for an older, e.g. because
+   * only the commit message was changed. Maybe that warrants the addition of
+   * another optional field, e.g. `original_patchset`.
+   */
+  patchset?: number;
+  /**
+   * The UI will focus on just the latest attempt per run. Former attempts are
+   * accessible, but initially collapsed/hidden. Lower number means older
+   * attempt. Every run has its own attempt numbering, so attempt 3 of run A is
+   * not directly related to attempt 3 of run B.
+   *
+   * RUNNABLE runs must use `undefined` as attempt.
+   * COMPLETED and RUNNING runs must use an attempt number >=0.
+   *
+   * TBD: Optionally providing aggregate information about former attempts will
+   * probably be a useful feature, but we are deferring the exact data modeling
+   * of that to later.
+   */
+  attempt?: number;
+
+  /**
+   * An optional opaque identifier not used by Gerrit directly, but might be
+   * used by plugin extensions and callbacks.
+   */
+  externalId?: string;
+
+  // The following 3 properties are independent of this run *instance*. They
+  // just describe what the check is about and will be identical for other
+  // attempts or patchsets or changes.
+
+  /**
+   * The unique name of the check. There can’t be two runs with the same
+   * change/patchset/attempt/checkName combination.
+   * Multiple attempts of the same run must have the same checkName.
+   * It should be expected that this string is cut off at ~30 chars in the UI.
+   * The full name will then be shown in a tooltip.
+   */
+  checkName: string;
+  /**
+   * Optional description of the check. Only shown as a tooltip or in a
+   * hovercard.
+   */
+  checkDescription?: string;
+  /**
+   * Optional http link to an external page with more detailed information about
+   * this run. Must begin with 'http'.
+   */
+  checkLink?: string;
+
+  /**
+   * RUNNABLE:  Not run (yet). Mostly useful for runs that the user can trigger
+   *            (see actions). Cannot contain results.
+   * RUNNING:   Subsumes "scheduled".
+   * COMPLETED: The attempt of the run has finished. Does not indicate at all
+   *            whether the run was successful or not. Outcomes can and should
+   *            be modeled using the CheckResult entity.
+   */
+  status: RunStatus;
+  /**
+   * Optional short description of the run status. This is a plain string
+   * without styling or formatting options. It will only be shown as a tooltip
+   * or in a hovercard.
+   *
+   * Examples:
+   * "40 tests running, 30 completed: 0 failing so far",
+   * "Scheduled 5 minutes ago, not running yet".
+   */
+  statusDescription?: string;
+  /**
+   * Optional http link to an external page with more detailed information about
+   * the run status. Must begin with 'http'.
+   */
+  statusLink?: string;
+
+  /**
+   * Optional reference to a Gerrit label (e.g. "Verified") that this result
+   * influences. Allows the user to understand and navigate the relationship
+   * between check runs/results and submit requirements,
+   * see also https://gerrit-review.googlesource.com/c/homepage/+/279176.
+   */
+  labelName?: string;
+
+  /**
+   * Optional callbacks to the plugin. Must be implemented individually by
+   * each plugin. The most important actions (which get special UI treatment)
+   * are:
+   * "Run" for RUNNABLE and COMPLETED runs.
+   * "Cancel" for RUNNING runs.
+   */
+  actions: Action[];
+
+  scheduledTimestamp?: Date;
+  startedTimestamp?: Date;
+  finishedTimestamp?: Date;
+
+  /**
+   * List of results produced by this run.
+   * RUNNABLE runs must not have results.
+   * RUNNING runs can contain (intermediate) results.
+   * Nesting the results in runs enforces that:
+   * - A run can have 0-n results.
+   * - A result is associated with exactly one run.
+   */
+  results: CheckResult[];
+}
+
+export interface Action {
+  name: string;
+  tooltip?: string;
+  /**
+   * Primary actions will get a more prominent treatment in the UI. For example
+   * primary actions might be rendered as buttons versus just menu entries in
+   * an overflow menu.
+   */
+  primary: boolean;
+  callback: ActionCallback;
+}
+
+export type ActionCallback = (
+  change: number,
+  patchset: number,
+  attempt: number,
+  externalId: string,
+  /** Identical to 'checkName' property of CheckRun. */
+  checkName: string,
+  /** Identical to 'name' property of Action entity. */
+  actionName: string
+) => Promise<ActionResult>;
+
+export interface ActionResult {
+  /** An empty errorMessage means success. */
+  errorMessage?: string;
+}
+
+export enum RunStatus {
+  RUNNABLE,
+  RUNNING,
+  COMPLETED,
+}
+
+export interface CheckResult {
+  /**
+   * An optional opaque identifier not used by Gerrit directly, but might be
+   * used by plugin extensions and callbacks.
+   */
+  externalId?: string;
+
+  /**
+   * INFO:    The user will typically not bother to look into this category,
+   *          only for looking up something that they are searching for. Can be
+   *          used for reporting secondary metrics and analysis, or a wider
+   *          range of artifacts produced by the checks system.
+   * WARNING: A warning is something that should be read before submitting the
+   *          change. The user should not ignore it, but it is also not blocking
+   *          submit. It has a similar level of importance as an unresolved
+   *          comment.
+   * ERROR:   An error indicates that the change must not or cannot be submitted
+   *          without fixing the problem. Errors will be visualized very
+   *          prominently to the user.
+   *
+   * The ‘tags’ field below can be used for further categorization, e.g. for
+   * distinguishing FAILED vs TIMED_OUT.
+   */
+  category: Category;
+
+  /**
+   * Short description of the check result.
+   *
+   * It should be expected that this string might be cut off at ~80 chars in the
+   * UI. The full description will then be shown in a tooltip.
+   * This is a plain string without styling or formatting options.
+   *
+   * Examples:
+   * MessageConverterTest failed with: 'kermit' expected, but got 'ernie'.
+   * Binary size of javascript bundle has increased by 27%.
+   */
+  summary: string;
+
+  /**
+   * Exhaustive optional message describing the check result.
+   * Will be initially collapsed. Might potentially be very long, e.g. a log of
+   * MB size. The UI is not limiting this. Data providing plugins are
+   * responsible for not killing the browser. :-)
+   *
+   * For now this is just a plain unformatted string. The only formatting
+   * applied is the one that Gerrit also applies to human comments. TBD: Both
+   * human comments and check result messages should get richer formatting
+   * options.
+   */
+  message?: string;
+
+  /**
+   * Tags allow a plugins to further categorize a result, e.g. making a list
+   * of results filterable by the end-user.
+   * The name is free-form, but there is a predefined set of TagColors to
+   * choose from with a recommendation of color for common tags, see below.
+   *
+   * Examples:
+   * PASS, FAIL, SCHEDULED, OBSOLETE, SKIPPED, TIMED_OUT, INFRA_ERROR, FLAKY
+   * WIN, MAC, LINUX
+   * BUILD, TEST, LINT
+   * INTEGRATION, E2E, SCREENSHOT
+   */
+  tags: Tag[];
+
+  /**
+   * Links provide an opportunity for the end-user to easily access details and
+   * artifacts. Links are displayed by an icon+tooltip only. They don’t have a
+   * name, making them clearly distinguishable from tags and actions.
+   *
+   * There is a fixed set of LinkIcons to choose from, see below.
+   *
+   * Examples:
+   * Link to test log.
+   * Link to result artifacts such as images and screenshots.
+   * Link to downloadable artifacts such as ZIP or APK files.
+   */
+  links: Link[];
+
+  /**
+   * Callbacks to the plugin. Must be implemented individually by each
+   * plugin. Actions are rendered as buttons. If there are more than two actions
+   * per result, then further actions are put into an overflow menu. Sort order
+   * is defined by the data provider.
+   *
+   * Examples:
+   * Acknowledge/Dismiss, Delete, Report a bug, Report as not useful,
+   * Make blocking, Downgrade severity.
+   */
+  actions: Action[];
+}
+
+export enum Category {
+  INFO,
+  WARNING,
+  ERROR,
+}
+
+export interface Tag {
+  name: string;
+  tooltip?: string;
+  color?: TagColor;
+}
+
+// TBD: Add more ...
+// TBD: Clarify standard colors for common tags.
+export enum TagColor {
+  GRAY,
+  GREEN,
+}
+
+export interface Link {
+  /** Must begin with 'http'. */
+  url: string;
+  tooltip?: string;
+  /**
+   * Primary links will get a more prominent treatment in the UI, e.g. being
+   * always visible in the results table or also showing up in the change page
+   * summary of checks.
+   */
+  primary: boolean;
+  icon: LinkIcon;
+}
+
+// TBD: Add more ...
+export enum LinkIcon {
+  EXTERNAL,
+  DOWNLOAD,
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
new file mode 100644
index 0000000..50b9222
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
@@ -0,0 +1,74 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Settings
+ *
+ * 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.
+ */
+import {PluginApi} from '../gr-plugin-types';
+import {
+  ChecksApiConfig,
+  ChecksProvider,
+  GrChecksApiInterface,
+  ResponseCode,
+} from './gr-checks-api-types';
+
+const DEFAULT_CONFIG: ChecksApiConfig = {
+  fetchPollingIntervalSeconds: 60,
+};
+
+enum State {
+  NOT_REGISTERED,
+  REGISTERED,
+  FETCHING,
+}
+
+/**
+ * Plugin API for checks.
+ *
+ * This object is created/returned to plugins that want to provide check data.
+ * Plugins normally just call register() once at startup and then wait for
+ * fetch() being called on the provider interface.
+ */
+export class GrChecksApi implements GrChecksApiInterface {
+  private provider?: ChecksProvider;
+
+  config?: ChecksApiConfig;
+
+  private state = State.NOT_REGISTERED;
+
+  constructor(readonly plugin: PluginApi) {}
+
+  announceUpdate() {
+    // TODO(brohlfs): Implement!
+  }
+
+  register(provider: ChecksProvider, config?: ChecksApiConfig): void {
+    if (this.state !== State.NOT_REGISTERED || this.provider)
+      throw new Error('Only one provider can be registered per plugin.');
+    this.state = State.REGISTERED;
+    this.provider = provider;
+    this.config = config ?? DEFAULT_CONFIG;
+  }
+
+  async fetch(change: number, patchset: number) {
+    if (this.state === State.NOT_REGISTERED || !this.provider)
+      throw new Error('Cannot fetch checks without a registered provider.');
+    if (this.state === State.FETCHING) return;
+    this.state = State.FETCHING;
+    const response = await this.provider.fetch(change, patchset);
+    this.state = State.REGISTERED;
+    if (response.responseCode === ResponseCode.OK) {
+      // TODO(brohlfs): Do something with the response.
+    }
+  }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts
new file mode 100644
index 0000000..45cbb47
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts
@@ -0,0 +1,50 @@
+/**
+ * @license
+ * Copyright (C) 2020 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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+import {GrChecksApi} from './gr-checks-api';
+import {PluginApi} from '../gr-plugin-types';
+
+const gerritPluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-settings-api tests', () => {
+  let checksApi: GrChecksApi | undefined;
+
+  setup(() => {
+    let pluginApi: PluginApi | undefined = undefined;
+    gerritPluginApi.install(
+      p => {
+        pluginApi = p;
+      },
+      '0.1',
+      'http://test.com/plugins/testplugin/static/test.js'
+    );
+    getPluginLoader().loadPlugins([]);
+    assert.isOk(pluginApi);
+    checksApi = pluginApi!.checks();
+  });
+
+  teardown(() => {
+    checksApi = undefined;
+  });
+
+  test('exists', () => {
+    assert.isOk(checksApi);
+  });
+});
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
index 10335b8..02fcdec 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
@@ -15,10 +15,6 @@
  * limitations under the License.
  */
 
-export interface EventWithPath extends Event {
-  path?: HTMLElement[];
-}
-
 export interface ListenOptions {
   event?: string;
   capture?: boolean;
@@ -71,11 +67,11 @@
   _listen(
     container: HTMLElement,
     callback: (event: Event) => boolean,
-    opt_options?: ListenOptions | null
+    options?: ListenOptions | null
   ) {
-    const capture = opt_options?.capture;
-    const event = opt_options?.event || 'click';
-    const handler = (e: EventWithPath) => {
+    const capture = options?.capture;
+    const event = options?.event || 'click';
+    const handler = (e: Event) => {
       if (!e.path) return;
       if (e.path.indexOf(this.element) !== -1) {
         let mayContinue = true;
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-types.ts b/polygerrit-ui/app/elements/plugins/gr-plugin-types.ts
index 9eeddcc..de3eba5 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-types.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-types.ts
@@ -19,6 +19,7 @@
 import {GrEventHelper} from './gr-event-helper/gr-event-helper';
 import {GrPopupInterface} from './gr-popup-interface/gr-popup-interface';
 import {ConfigInfo} from '../../types/common';
+import {GrChecksApi} from './gr-checks-api/gr-checks-api';
 
 interface GerritElementExtensions {
   content?: HTMLElement & {hidden?: boolean};
@@ -91,6 +92,12 @@
   getPluginName(): string;
   on(eventName: string, target: any): void;
   attributeHelper(element: Element): GrAttributeHelper;
+  checks(): GrChecksApi;
   restApi(): GrPluginRestApi;
   eventHelper(element: Node): GrEventHelper;
+  registerDynamicCustomComponent(
+    endpointName: string,
+    moduleName?: string,
+    options?: RegisterOptions
+  ): HookApi;
 }
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
index 65f517a..be8836b 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
@@ -21,7 +21,7 @@
 import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {createIronOverlayBackdropStyleEl} from '../../../test/test-utils';
+import {createIronOverlayBackdropStyleEl} from '../../../test/test-utils.js';
 
 class GrUserTestPopupElement extends PolymerElement {
   static get is() { return 'gr-user-test-popup'; }
diff --git a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.ts b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.ts
index 419c8db..5c57208 100644
--- a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.ts
@@ -22,13 +22,6 @@
  * 2. we have css variables which are more recommended way to custom styling
  */
 
-/**
- * // import { useShadow } from '@polymer/polymer/lib/utils/settings';
- * TODO(TS): polymer/lib/utils/settings.d.ts is not exporting useShadow
- * while the js is, to avoid the error, re-define it here
- */
-const useShadow = !window.ShadyDOM || !window.ShadyDOM.inUse;
-
 let styleObjectCount = 0;
 
 interface PgElement extends Element {
@@ -50,10 +43,9 @@
    * if it hasn't been added yet. A root node is an document or is the
    * associated shadowRoot. This class can be added to any element with the same
    * root node.
-   *
    */
   getClassName(element: Element) {
-    let rootNodeEl = useShadow ? element.getRootNode() : document.body;
+    let rootNodeEl = element.getRootNode();
     if (rootNodeEl === document) {
       rootNodeEl = document.head;
     }
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
index d970b0a..9c781c8 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
@@ -27,6 +27,7 @@
 import {customElement, property, observe} from '@polymer/decorators';
 import {AccountInfo, ServerInfo} from '../../../types/common';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {EditableAccountField} from '../../../constants/constants';
 
 export interface GrAccountInfo {
   $: {
@@ -211,12 +212,16 @@
 
     // Username may not be changed once it is set.
     return (
-      config.auth.editable_account_fields.includes('USER_NAME') && !username
+      config.auth.editable_account_fields.includes(
+        EditableAccountField.USER_NAME
+      ) && !username
     );
   }
 
   _computeNameMutable(config: ServerInfo) {
-    return config.auth.editable_account_fields.includes('FULL_NAME');
+    return config.auth.editable_account_fields.includes(
+      EditableAccountField.FULL_NAME
+    );
   }
 
   @observe('_account.status')
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
index 28cd672..891fdf6 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
@@ -32,6 +32,7 @@
   GroupInfo,
   ContributorAgreementInfo,
 } from '../../../types/common';
+import {fireAlert, fireTitleChange} from '../../../utils/event-util';
 
 export interface GrClaView {
   $: {
@@ -79,13 +80,7 @@
     super.attached();
     this.loadData();
 
-    this.dispatchEvent(
-      new CustomEvent('title-change', {
-        detail: {title: 'New Contributor Agreement'},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireTitleChange(this, 'New Contributor Agreement');
   }
 
   loadData() {
@@ -156,13 +151,7 @@
   }
 
   _createToast(message: string) {
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {message},
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fireAlert(this, message);
   }
 
   _computeShowAgreementsClass(showAgreements: boolean) {
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
index 6699f25a..fd10a16 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
@@ -84,7 +84,7 @@
     if (!(target instanceof Element)) return;
     const indexStr = target.getAttribute('data-index');
     if (indexStr === null) return;
-    const index = parseInt(indexStr, 10);
+    const index = Number(indexStr);
     const email = this._emails[index];
     this.push('_emailsToRemove', email);
     this.splice('_emails', index, 1);
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.js b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
similarity index 68%
rename from polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.js
rename to polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
index 805b8c8..18ff95c 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
@@ -17,13 +17,14 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-email-editor.js';
+import {GrEmailEditor} from './gr-email-editor';
 
 const basicFixture = fixtureFromElement('gr-email-editor');
 
 suite('gr-email-editor tests', () => {
-  let element;
+  let element: GrEmailEditor;
 
-  setup(done => {
+  setup(async () => {
     const emails = [
       {email: 'email@one.com'},
       {email: 'email@two.com', preferred: true},
@@ -31,36 +32,47 @@
     ];
 
     stub('gr-rest-api-interface', {
-      getAccountEmails() { return Promise.resolve(emails); },
+      getAccountEmails() {
+        return Promise.resolve(emails);
+      },
     });
 
     element = basicFixture.instantiate();
 
-    element.loadData().then(flush(done));
+    await element.loadData();
+    await flush();
   });
 
   test('renders', () => {
-    const rows = element.shadowRoot
-        .querySelector('table').querySelectorAll('tbody tr');
+    const rows = element
+      .shadowRoot!.querySelector('table')!
+      .querySelectorAll('tbody tr') as NodeListOf<HTMLTableRowElement>;
 
     assert.equal(rows.length, 3);
 
-    assert.isFalse(rows[0].querySelector('input[type=radio]').checked);
-    assert.isNotOk(rows[0].querySelector('gr-button').disabled);
+    assert.isFalse(
+      (rows[0].querySelector('input[type=radio]') as HTMLInputElement).checked
+    );
+    assert.isNotOk(rows[0].querySelector('gr-button')!.disabled);
 
-    assert.isTrue(rows[1].querySelector('input[type=radio]').checked);
-    assert.isOk(rows[1].querySelector('gr-button').disabled);
+    assert.isTrue(
+      (rows[1].querySelector('input[type=radio]') as HTMLInputElement).checked
+    );
+    assert.isOk(rows[1].querySelector('gr-button')!.disabled);
 
-    assert.isFalse(rows[2].querySelector('input[type=radio]').checked);
-    assert.isNotOk(rows[2].querySelector('gr-button').disabled);
+    assert.isFalse(
+      (rows[2].querySelector('input[type=radio]') as HTMLInputElement).checked
+    );
+    assert.isNotOk(rows[2].querySelector('gr-button')!.disabled);
 
     assert.isFalse(element.hasUnsavedChanges);
   });
 
   test('edit preferred', () => {
     const preferredChangedSpy = sinon.spy(element, '_handlePreferredChange');
-    const radios = element.shadowRoot
-        .querySelector('table').querySelectorAll('input[type=radio]');
+    const radios = element
+      .shadowRoot!.querySelector('table')!
+      .querySelectorAll('input[type=radio]') as NodeListOf<HTMLInputElement>;
 
     assert.isFalse(element.hasUnsavedChanges);
     assert.isNotOk(element._newPreferred);
@@ -82,8 +94,9 @@
   });
 
   test('delete email', () => {
-    const buttons = element.shadowRoot
-        .querySelector('table').querySelectorAll('gr-button');
+    const buttons = element
+      .shadowRoot!.querySelector('table')!
+      .querySelectorAll('gr-button');
 
     assert.isFalse(element.hasUnsavedChanges);
     assert.isNotOk(element._newPreferred);
@@ -101,12 +114,15 @@
   });
 
   test('save changes', done => {
-    const deleteEmailStub =
-        sinon.stub(element.$.restAPI, 'deleteAccountEmail');
-    const setPreferredStub = sinon.stub(element.$.restAPI,
-        'setPreferredAccountEmail');
-    const rows = element.shadowRoot
-        .querySelector('table').querySelectorAll('tbody tr');
+    const deleteEmailStub = sinon.stub(element.$.restAPI, 'deleteAccountEmail');
+    const setPreferredStub = sinon.stub(
+      element.$.restAPI,
+      'setPreferredAccountEmail'
+    );
+
+    const rows = element
+      .shadowRoot!.querySelector('table')!
+      .querySelectorAll('tbody tr');
 
     assert.isFalse(element.hasUnsavedChanges);
     assert.isNotOk(element._newPreferred);
@@ -114,8 +130,8 @@
     assert.equal(element._emails.length, 3);
 
     // Delete the first email and set the last as preferred.
-    rows[0].querySelector('gr-button').click();
-    rows[2].querySelector('input[type=radio]').click();
+    rows[0].querySelector('gr-button')!.click();
+    (rows[2].querySelector('input[type=radio]')! as HTMLInputElement).click();
 
     assert.isTrue(element.hasUnsavedChanges);
     assert.equal(element._newPreferred, 'email@three.com');
@@ -135,4 +151,3 @@
     });
   });
 });
-
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
index 92ae1c4..21e414b 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
@@ -97,7 +97,7 @@
 
   _showKey(e: Event) {
     const el = (dom(e) as EventApi).localTarget as Element;
-    const index = parseInt(el.getAttribute('data-index')!, 10);
+    const index = Number(el.getAttribute('data-index')!);
     this._keyToView = this._keys[index];
     this.$.viewKeyOverlay.open();
   }
@@ -108,7 +108,7 @@
 
   _handleDeleteKey(e: Event) {
     const el = (dom(e) as EventApi).localTarget as Element;
-    const index = parseInt(el.getAttribute('data-index')!, 10);
+    const index = Number(el.getAttribute('data-index')!);
     this.push('_keysToRemove', this._keys[index]);
     this.splice('_keys', index, 1);
     this.hasUnsavedChanges = true;
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
index 0e85e4a..0e73062 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
@@ -26,6 +26,7 @@
 import {customElement, property, observe} from '@polymer/decorators';
 import {ServerInfo, AccountDetailInfo} from '../../../types/common';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {EditableAccountField} from '../../../constants/constants';
 
 export interface GrRegistrationDialog {
   $: {
@@ -97,7 +98,9 @@
     }
 
     return (
-      config.auth.editable_account_fields.includes('USER_NAME') && !username
+      config.auth.editable_account_fields.includes(
+        EditableAccountField.USER_NAME
+      ) && !username
     );
   }
 
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
deleted file mode 100644
index 06d9183..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
+++ /dev/null
@@ -1,506 +0,0 @@
-/**
- * @license
- * 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.
- */
-import '@polymer/iron-input/iron-input.js';
-import '@polymer/paper-toggle-button/paper-toggle-button.js';
-import '../../../styles/gr-form-styles.js';
-import '../../../styles/gr-menu-page-styles.js';
-import '../../../styles/gr-page-nav-styles.js';
-import '../../../styles/shared-styles.js';
-import {applyTheme as applyDarkTheme, removeTheme as removeDarkTheme} from '../../../styles/themes/dark-theme.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../gr-change-table-editor/gr-change-table-editor.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-diff-preferences/gr-diff-preferences.js';
-import '../../shared/gr-page-nav/gr-page-nav.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-select/gr-select.js';
-import '../gr-account-info/gr-account-info.js';
-import '../gr-agreements-list/gr-agreements-list.js';
-import '../gr-edit-preferences/gr-edit-preferences.js';
-import '../gr-email-editor/gr-email-editor.js';
-import '../gr-gpg-editor/gr-gpg-editor.js';
-import '../gr-group-list/gr-group-list.js';
-import '../gr-http-password/gr-http-password.js';
-import '../gr-identities/gr-identities.js';
-import '../gr-menu-editor/gr-menu-editor.js';
-import '../gr-ssh-editor/gr-ssh-editor.js';
-import '../gr-watched-projects-editor/gr-watched-projects-editor.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-settings-view_html.js';
-import {getDocsBaseUrl} from '../../../utils/url-util.js';
-import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin.js';
-
-const PREFS_SECTION_FIELDS = [
-  'changes_per_page',
-  'date_format',
-  'time_format',
-  'email_strategy',
-  'diff_view',
-  'publish_comments_on_push',
-  'work_in_progress_by_default',
-  'default_base_for_merges',
-  'signed_off_by',
-  'email_format',
-  'size_bar_in_change_table',
-  'relative_date_in_change_table',
-];
-
-const GERRIT_DOCS_BASE_URL = 'https://gerrit-review.googlesource.com/' +
-    'Documentation';
-const GERRIT_DOCS_FILTER_PATH = '/user-notify.html';
-const ABSOLUTE_URL_PATTERN = /^https?:/;
-const TRAILING_SLASH_PATTERN = /\/$/;
-
-const HTTP_AUTH = [
-  'HTTP',
-  'HTTP_LDAP',
-];
-
-/**
- * @extends PolymerElement
- */
-class GrSettingsView extends ChangeTableMixin(GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-settings-view'; }
-  /**
-   * Fired when the title of the page should change.
-   *
-   * @event title-change
-   */
-
-  /**
-   * Fired with email confirmation text, or when the page reloads.
-   *
-   * @event show-alert
-   */
-
-  static get properties() {
-    return {
-      prefs: {
-        type: Object,
-        value() { return {}; },
-      },
-      params: {
-        type: Object,
-        value() { return {}; },
-      },
-      _accountInfoChanged: Boolean,
-      _changeTableColumnsNotDisplayed: Array,
-      /** @type {?} */
-      _localPrefs: {
-        type: Object,
-        value() { return {}; },
-      },
-      _localChangeTableColumns: {
-        type: Array,
-        value() { return []; },
-      },
-      _localMenu: {
-        type: Array,
-        value() { return []; },
-      },
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _changeTableChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _prefsChanged: {
-        type: Boolean,
-        value: false,
-      },
-      /** @type {?} */
-      _diffPrefsChanged: Boolean,
-      /** @type {?} */
-      _editPrefsChanged: Boolean,
-      _menuChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _watchedProjectsChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _keysChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _gpgKeysChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _newEmail: String,
-      _addingEmail: {
-        type: Boolean,
-        value: false,
-      },
-      _lastSentVerificationEmail: {
-        type: String,
-        value: null,
-      },
-      /** @type {?} */
-      _serverConfig: Object,
-      /** @type {?string} */
-      _docsBaseUrl: String,
-      _emailsChanged: Boolean,
-
-      /**
-       * For testing purposes.
-       */
-      _loadingPromise: Object,
-
-      _showNumber: Boolean,
-
-      _isDark: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_handlePrefsChanged(_localPrefs.*)',
-      '_handleMenuChanged(_localMenu.splices)',
-      '_handleChangeTableChanged(_localChangeTableColumns, _showNumber)',
-    ];
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    // Polymer 2: anchor tag won't work on shadow DOM
-    // we need to manually calling scrollIntoView when hash changed
-    this.listen(window, 'location-change', '_handleLocationChange');
-    this.dispatchEvent(new CustomEvent('title-change', {
-      detail: {title: 'Settings'},
-      composed: true, bubbles: true,
-    }));
-
-    this._isDark = !!window.localStorage.getItem('dark-theme');
-
-    const promises = [
-      this.$.accountInfo.loadData(),
-      this.$.watchedProjectsEditor.loadData(),
-      this.$.groupList.loadData(),
-      this.$.identities.loadData(),
-      this.$.editPrefs.loadData(),
-      this.$.diffPrefs.loadData(),
-    ];
-
-    promises.push(this.$.restAPI.getPreferences().then(prefs => {
-      this.prefs = prefs;
-      this._showNumber = !!prefs.legacycid_in_change_table;
-      this._copyPrefs('_localPrefs', 'prefs');
-      this._cloneMenu(prefs.my);
-      this._cloneChangeTableColumns();
-    }));
-
-    promises.push(this.$.restAPI.getConfig().then(config => {
-      this._serverConfig = config;
-      const configPromises = [];
-
-      if (this._serverConfig && this._serverConfig.sshd) {
-        configPromises.push(this.$.sshEditor.loadData());
-      }
-
-      if (this._serverConfig &&
-          this._serverConfig.receive &&
-          this._serverConfig.receive.enable_signed_push) {
-        configPromises.push(this.$.gpgEditor.loadData());
-      }
-
-      configPromises.push(
-          getDocsBaseUrl(config, this.$.restAPI)
-              .then(baseUrl => { this._docsBaseUrl = baseUrl; }));
-
-      return Promise.all(configPromises);
-    }));
-
-    if (this.params.emailToken) {
-      promises.push(this.$.restAPI.confirmEmail(this.params.emailToken).then(
-          message => {
-            if (message) {
-              this.dispatchEvent(new CustomEvent('show-alert', {
-                detail: {message},
-                composed: true, bubbles: true,
-              }));
-            }
-            this.$.emailEditor.loadData();
-          }));
-    } else {
-      promises.push(this.$.emailEditor.loadData());
-    }
-
-    this._loadingPromise = Promise.all(promises).then(() => {
-      this._loading = false;
-
-      // Handle anchor tag for initial load
-      this._handleLocationChange();
-    });
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-    this.unlisten(window, 'location-change', '_handleLocationChange');
-  }
-
-  _handleLocationChange() {
-    // Handle anchor tag after dom attached
-    const urlHash = window.location.hash;
-    if (urlHash) {
-      // Use shadowRoot for Polymer 2
-      const elem = (this.shadowRoot || document).querySelector(urlHash);
-      if (elem) {
-        elem.scrollIntoView();
-      }
-    }
-  }
-
-  reloadAccountDetail() {
-    Promise.all([
-      this.$.accountInfo.loadData(),
-      this.$.emailEditor.loadData(),
-    ]);
-  }
-
-  _isLoading() {
-    return this._loading || this._loading === undefined;
-  }
-
-  _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(prefs) {
-    const menu = [];
-    for (const item of prefs) {
-      menu.push({
-        name: item.name,
-        url: item.url,
-        target: item.target,
-      });
-    }
-    this._localMenu = menu;
-  }
-
-  _cloneChangeTableColumns() {
-    let columns = this.getVisibleColumns(this.prefs.change_table);
-
-    if (columns.length === 0) {
-      columns = this.columnNames;
-      this._changeTableColumnsNotDisplayed = [];
-    } else {
-      this._changeTableColumnsNotDisplayed = this.getComplementColumns(
-          this.prefs.change_table);
-    }
-    this._localChangeTableColumns = columns;
-  }
-
-  _formatChangeTableColumns(changeTableArray) {
-    return changeTableArray.map(item => {
-      return {column: item};
-    });
-  }
-
-  _handleChangeTableChanged() {
-    if (this._isLoading()) { return; }
-    this._changeTableChanged = true;
-  }
-
-  _handlePrefsChanged(prefs) {
-    if (this._isLoading()) { return; }
-    this._prefsChanged = true;
-  }
-
-  _handleRelativeDateInChangeTable() {
-    this.set('_localPrefs.relative_date_in_change_table',
-        this.$.relativeDateInChangeTable.checked);
-  }
-
-  _handleShowSizeBarsInFileListChanged() {
-    this.set('_localPrefs.size_bar_in_change_table',
-        this.$.showSizeBarsInFileList.checked);
-  }
-
-  _handlePublishCommentsOnPushChanged() {
-    this.set('_localPrefs.publish_comments_on_push',
-        this.$.publishCommentsOnPush.checked);
-  }
-
-  _handleWorkInProgressByDefault() {
-    this.set('_localPrefs.work_in_progress_by_default',
-        this.$.workInProgressByDefault.checked);
-  }
-
-  _handleInsertSignedOff() {
-    this.set('_localPrefs.signed_off_by', this.$.insertSignedOff.checked);
-  }
-
-  _handleMenuChanged() {
-    if (this._isLoading()) { return; }
-    this._menuChanged = true;
-  }
-
-  _handleSaveAccountInfo() {
-    this.$.accountInfo.save();
-  }
-
-  _handleSavePreferences() {
-    this._copyPrefs('prefs', '_localPrefs');
-
-    return this.$.restAPI.savePreferences(this.prefs).then(() => {
-      this._prefsChanged = false;
-    });
-  }
-
-  _handleSaveChangeTable() {
-    this.set('prefs.change_table', this._localChangeTableColumns);
-    this.set('prefs.legacycid_in_change_table', this._showNumber);
-    this._cloneChangeTableColumns();
-    return this.$.restAPI.savePreferences(this.prefs).then(() => {
-      this._changeTableChanged = false;
-    });
-  }
-
-  _handleSaveDiffPreferences() {
-    this.$.diffPrefs.save();
-  }
-
-  _handleSaveEditPreferences() {
-    this.$.editPrefs.save();
-  }
-
-  _handleSaveMenu() {
-    this.set('prefs.my', this._localMenu);
-    this._cloneMenu(this.prefs.my);
-    return this.$.restAPI.savePreferences(this.prefs).then(() => {
-      this._menuChanged = false;
-    });
-  }
-
-  _handleResetMenuButton() {
-    return this.$.restAPI.getDefaultPreferences().then(data => {
-      if (data && data.my) {
-        this._cloneMenu(data.my);
-      }
-    });
-  }
-
-  _handleSaveWatchedProjects() {
-    this.$.watchedProjectsEditor.save();
-  }
-
-  _computeHeaderClass(changed) {
-    return changed ? 'edited' : '';
-  }
-
-  _handleSaveEmails() {
-    this.$.emailEditor.save();
-  }
-
-  _handleNewEmailKeydown(e) {
-    if (e.keyCode === 13) { // Enter
-      e.stopPropagation();
-      this._handleAddEmailButton();
-    }
-  }
-
-  _isNewEmailValid(newEmail) {
-    return newEmail && newEmail.includes('@');
-  }
-
-  _computeAddEmailButtonEnabled(newEmail, addingEmail) {
-    return this._isNewEmailValid(newEmail) && !addingEmail;
-  }
-
-  _handleAddEmailButton() {
-    if (!this._isNewEmailValid(this._newEmail)) { return; }
-
-    this._addingEmail = true;
-    this.$.restAPI.addAccountEmail(this._newEmail).then(response => {
-      this._addingEmail = false;
-
-      // If it was unsuccessful.
-      if (response.status < 200 || response.status >= 300) { return; }
-
-      this._lastSentVerificationEmail = this._newEmail;
-      this._newEmail = '';
-    });
-  }
-
-  _getFilterDocsLink(docsBaseUrl) {
-    let base = docsBaseUrl;
-    if (!base || !ABSOLUTE_URL_PATTERN.test(base)) {
-      base = GERRIT_DOCS_BASE_URL;
-    }
-
-    // Remove any trailing slash, since it is in the GERRIT_DOCS_FILTER_PATH.
-    base = base.replace(TRAILING_SLASH_PATTERN, '');
-
-    return base + GERRIT_DOCS_FILTER_PATH;
-  }
-
-  _handleToggleDark() {
-    if (this._isDark) {
-      window.localStorage.removeItem('dark-theme');
-      removeDarkTheme();
-    } else {
-      window.localStorage.setItem('dark-theme', 'true');
-      applyDarkTheme();
-    }
-    this._isDark = !!window.localStorage.getItem('dark-theme');
-    this.dispatchEvent(new CustomEvent('show-alert', {
-      detail: {
-        message: `Theme changed to ${this._isDark ? 'dark' : 'light'}.`,
-      },
-      bubbles: true,
-      composed: true,
-    }));
-  }
-
-  _showHttpAuth(config) {
-    if (config && config.auth &&
-        config.auth.git_basic_auth_policy) {
-      return HTTP_AUTH.includes(
-          config.auth.git_basic_auth_policy.toUpperCase());
-    }
-
-    return false;
-  }
-
-  /**
-   * Work around a issue on iOS when clicking turns into double tap
-   */
-  _onTapDarkToggle(e) {
-    e.preventDefault();
-  }
-}
-
-customElements.define(GrSettingsView.is, GrSettingsView);
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
new file mode 100644
index 0000000..4293a83
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
@@ -0,0 +1,559 @@
+/**
+ * @license
+ * 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.
+ */
+import '@polymer/iron-input/iron-input';
+import '@polymer/paper-toggle-button/paper-toggle-button';
+import '../../../styles/gr-form-styles';
+import '../../../styles/gr-menu-page-styles';
+import '../../../styles/gr-page-nav-styles';
+import '../../../styles/shared-styles';
+import {
+  applyTheme as applyDarkTheme,
+  removeTheme as removeDarkTheme,
+} from '../../../styles/themes/dark-theme';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../gr-change-table-editor/gr-change-table-editor';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-diff-preferences/gr-diff-preferences';
+import '../../shared/gr-page-nav/gr-page-nav';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import '../gr-account-info/gr-account-info';
+import '../gr-agreements-list/gr-agreements-list';
+import '../gr-edit-preferences/gr-edit-preferences';
+import '../gr-email-editor/gr-email-editor';
+import '../gr-gpg-editor/gr-gpg-editor';
+import '../gr-group-list/gr-group-list';
+import '../gr-http-password/gr-http-password';
+import '../gr-identities/gr-identities';
+import '../gr-menu-editor/gr-menu-editor';
+import '../gr-ssh-editor/gr-ssh-editor';
+import '../gr-watched-projects-editor/gr-watched-projects-editor';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-settings-view_html';
+import {getDocsBaseUrl} from '../../../utils/url-util';
+import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin';
+import {customElement, property, observe} from '@polymer/decorators';
+import {AppElementParams} from '../../gr-app-types';
+import {GrAccountInfo} from '../gr-account-info/gr-account-info';
+import {GrWatchedProjectsEditor} from '../gr-watched-projects-editor/gr-watched-projects-editor';
+import {GrGroupList} from '../gr-group-list/gr-group-list';
+import {GrIdentities} from '../gr-identities/gr-identities';
+import {GrEditPreferences} from '../gr-edit-preferences/gr-edit-preferences';
+import {GrDiffPreferences} from '../../shared/gr-diff-preferences/gr-diff-preferences';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  PreferencesInput,
+  ServerInfo,
+  TopMenuItemInfo,
+} from '../../../types/common';
+import {GrSshEditor} from '../gr-ssh-editor/gr-ssh-editor';
+import {GrGpgEditor} from '../gr-gpg-editor/gr-gpg-editor';
+import {GerritView} from '../../core/gr-navigation/gr-navigation';
+import {GrEmailEditor} from '../gr-email-editor/gr-email-editor';
+import {CustomKeyboardEvent} from '../../../types/events';
+import {fireAlert, fireTitleChange} from '../../../utils/event-util';
+
+const PREFS_SECTION_FIELDS: Array<keyof PreferencesInput> = [
+  'changes_per_page',
+  'date_format',
+  'time_format',
+  'email_strategy',
+  'diff_view',
+  'publish_comments_on_push',
+  'work_in_progress_by_default',
+  'default_base_for_merges',
+  'signed_off_by',
+  'email_format',
+  'size_bar_in_change_table',
+  'relative_date_in_change_table',
+];
+
+const GERRIT_DOCS_BASE_URL =
+  'https://gerrit-review.googlesource.com/' + 'Documentation';
+const GERRIT_DOCS_FILTER_PATH = '/user-notify.html';
+const ABSOLUTE_URL_PATTERN = /^https?:/;
+const TRAILING_SLASH_PATTERN = /\/$/;
+
+const HTTP_AUTH = ['HTTP', 'HTTP_LDAP'];
+
+enum CopyPrefsDirection {
+  PrefsToLocalPrefs,
+  LocalPrefsToPrefs,
+}
+
+type LocalMenuItemInfo = Omit<TopMenuItemInfo, 'id'>;
+
+export interface GrSettingsView {
+  $: {
+    restAPI: RestApiService & Element;
+    accountInfo: GrAccountInfo;
+    watchedProjectsEditor: GrWatchedProjectsEditor;
+    groupList: GrGroupList;
+    identities: GrIdentities;
+    editPrefs: GrEditPreferences;
+    diffPrefs: GrDiffPreferences;
+    sshEditor: GrSshEditor;
+    gpgEditor: GrGpgEditor;
+    emailEditor: GrEmailEditor;
+    insertSignedOff: HTMLInputElement;
+    workInProgressByDefault: HTMLInputElement;
+    showSizeBarsInFileList: HTMLInputElement;
+    publishCommentsOnPush: HTMLInputElement;
+    relativeDateInChangeTable: HTMLInputElement;
+  };
+}
+
+@customElement('gr-settings-view')
+export class GrSettingsView extends ChangeTableMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the title of the page should change.
+   *
+   * @event title-change
+   */
+
+  /**
+   * Fired with email confirmation text, or when the page reloads.
+   *
+   * @event show-alert
+   */
+
+  @property({type: Object})
+  prefs: PreferencesInput = {};
+
+  @property({type: Object})
+  params?: AppElementParams;
+
+  @property({type: Boolean})
+  _accountInfoChanged?: boolean;
+
+  @property({type: Array})
+  _changeTableColumnsNotDisplayed?: string[];
+
+  @property({type: Object})
+  _localPrefs: PreferencesInput = {};
+
+  @property({type: Array})
+  _localChangeTableColumns: string[] = [];
+
+  @property({type: Array})
+  _localMenu: LocalMenuItemInfo[] = [];
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: Boolean})
+  _changeTableChanged = false;
+
+  @property({type: Boolean})
+  _prefsChanged = false;
+
+  @property({type: Boolean})
+  _diffPrefsChanged?: boolean;
+
+  @property({type: Boolean})
+  _editPrefsChanged?: boolean;
+
+  @property({type: Boolean})
+  _menuChanged = false;
+
+  @property({type: Boolean})
+  _watchedProjectsChanged = false;
+
+  @property({type: Boolean})
+  _keysChanged = false;
+
+  @property({type: Boolean})
+  _gpgKeysChanged = false;
+
+  @property({type: String})
+  _newEmail?: string;
+
+  @property({type: Boolean})
+  _addingEmail = false;
+
+  @property({type: String})
+  _lastSentVerificationEmail?: string | null = null;
+
+  @property({type: Object})
+  _serverConfig?: ServerInfo;
+
+  @property({type: String})
+  _docsBaseUrl?: string | null;
+
+  @property({type: Boolean})
+  _emailsChanged?: boolean;
+
+  @property({type: Boolean})
+  _showNumber?: boolean;
+
+  @property({type: Boolean})
+  _isDark = false;
+
+  public _testOnly_loadingPromise?: Promise<void>;
+
+  /** @override */
+  attached() {
+    super.attached();
+    // Polymer 2: anchor tag won't work on shadow DOM
+    // we need to manually calling scrollIntoView when hash changed
+    this.listen(window, 'location-change', '_handleLocationChange');
+    fireTitleChange(this, 'Settings');
+
+    this._isDark = !!window.localStorage.getItem('dark-theme');
+
+    const promises: Array<Promise<unknown>> = [
+      this.$.accountInfo.loadData(),
+      this.$.watchedProjectsEditor.loadData(),
+      this.$.groupList.loadData(),
+      this.$.identities.loadData(),
+      this.$.editPrefs.loadData(),
+      this.$.diffPrefs.loadData(),
+    ];
+
+    promises.push(
+      this.$.restAPI.getPreferences().then(prefs => {
+        if (!prefs) {
+          throw new Error('getPreferences returned undefined');
+        }
+        this.prefs = prefs;
+        this._showNumber = !!prefs.legacycid_in_change_table;
+        this._copyPrefs(CopyPrefsDirection.PrefsToLocalPrefs);
+        this._cloneMenu(prefs.my);
+        this._cloneChangeTableColumns(prefs.change_table);
+      })
+    );
+
+    promises.push(
+      this.$.restAPI.getConfig().then(config => {
+        this._serverConfig = config;
+        const configPromises: Array<Promise<void>> = [];
+
+        if (this._serverConfig && this._serverConfig.sshd) {
+          configPromises.push(this.$.sshEditor.loadData());
+        }
+
+        if (
+          this._serverConfig &&
+          this._serverConfig.receive &&
+          this._serverConfig.receive.enable_signed_push
+        ) {
+          configPromises.push(this.$.gpgEditor.loadData());
+        }
+
+        configPromises.push(
+          getDocsBaseUrl(config, this.$.restAPI).then(baseUrl => {
+            this._docsBaseUrl = baseUrl;
+          })
+        );
+
+        return Promise.all(configPromises);
+      })
+    );
+
+    if (
+      this.params &&
+      this.params.view === GerritView.SETTINGS &&
+      this.params.emailToken
+    ) {
+      promises.push(
+        this.$.restAPI.confirmEmail(this.params.emailToken).then(message => {
+          if (message) {
+            fireAlert(this, message);
+          }
+          this.$.emailEditor.loadData();
+        })
+      );
+    } else {
+      promises.push(this.$.emailEditor.loadData());
+    }
+
+    this._testOnly_loadingPromise = Promise.all(promises).then(() => {
+      this._loading = false;
+
+      // Handle anchor tag for initial load
+      this._handleLocationChange();
+    });
+  }
+
+  detached() {
+    super.detached();
+    this.unlisten(window, 'location-change', '_handleLocationChange');
+  }
+
+  _handleLocationChange() {
+    // Handle anchor tag after dom attached
+    const urlHash = window.location.hash;
+    if (urlHash) {
+      // Use shadowRoot for Polymer 2
+      const elem = (this.shadowRoot || document).querySelector(urlHash);
+      if (elem) {
+        elem.scrollIntoView();
+      }
+    }
+  }
+
+  reloadAccountDetail() {
+    Promise.all([this.$.accountInfo.loadData(), this.$.emailEditor.loadData()]);
+  }
+
+  _isLoading() {
+    return this._loading || this._loading === undefined;
+  }
+
+  _copyPrefs(direction: CopyPrefsDirection) {
+    let to;
+    let from;
+    if (direction === CopyPrefsDirection.LocalPrefsToPrefs) {
+      from = this._localPrefs;
+      to = 'prefs';
+    } else {
+      from = this.prefs;
+      to = '_localPrefs';
+    }
+    for (let i = 0; i < PREFS_SECTION_FIELDS.length; i++) {
+      this.set([to, PREFS_SECTION_FIELDS[i]], from[PREFS_SECTION_FIELDS[i]]);
+    }
+  }
+
+  _cloneMenu(prefs: TopMenuItemInfo[]) {
+    const menu = [];
+    for (const item of prefs) {
+      menu.push({
+        name: item.name,
+        url: item.url,
+        target: item.target,
+      });
+    }
+    this._localMenu = menu;
+  }
+
+  _cloneChangeTableColumns(changeTable: string[]) {
+    let columns = this.getVisibleColumns(changeTable);
+
+    if (columns.length === 0) {
+      columns = this.columnNames;
+      this._changeTableColumnsNotDisplayed = [];
+    } else {
+      this._changeTableColumnsNotDisplayed = this.getComplementColumns(
+        changeTable
+      );
+    }
+    this._localChangeTableColumns = columns;
+  }
+
+  @observe('_localChangeTableColumns', '_showNumber')
+  _handleChangeTableChanged() {
+    if (this._isLoading()) {
+      return;
+    }
+    this._changeTableChanged = true;
+  }
+
+  @observe('_localPrefs.*')
+  _handlePrefsChanged() {
+    if (this._isLoading()) {
+      return;
+    }
+    this._prefsChanged = true;
+  }
+
+  _handleRelativeDateInChangeTable() {
+    this.set(
+      '_localPrefs.relative_date_in_change_table',
+      this.$.relativeDateInChangeTable.checked
+    );
+  }
+
+  _handleShowSizeBarsInFileListChanged() {
+    this.set(
+      '_localPrefs.size_bar_in_change_table',
+      this.$.showSizeBarsInFileList.checked
+    );
+  }
+
+  _handlePublishCommentsOnPushChanged() {
+    this.set(
+      '_localPrefs.publish_comments_on_push',
+      this.$.publishCommentsOnPush.checked
+    );
+  }
+
+  _handleWorkInProgressByDefault() {
+    this.set(
+      '_localPrefs.work_in_progress_by_default',
+      this.$.workInProgressByDefault.checked
+    );
+  }
+
+  _handleInsertSignedOff() {
+    this.set('_localPrefs.signed_off_by', this.$.insertSignedOff.checked);
+  }
+
+  @observe('_localMenu.splices')
+  _handleMenuChanged() {
+    if (this._isLoading()) {
+      return;
+    }
+    this._menuChanged = true;
+  }
+
+  _handleSaveAccountInfo() {
+    this.$.accountInfo.save();
+  }
+
+  _handleSavePreferences() {
+    this._copyPrefs(CopyPrefsDirection.LocalPrefsToPrefs);
+
+    return this.$.restAPI.savePreferences(this.prefs).then(() => {
+      this._prefsChanged = false;
+    });
+  }
+
+  _handleSaveChangeTable() {
+    this.set('prefs.change_table', this._localChangeTableColumns);
+    this.set('prefs.legacycid_in_change_table', this._showNumber);
+    this._cloneChangeTableColumns(this._localChangeTableColumns);
+    return this.$.restAPI.savePreferences(this.prefs).then(() => {
+      this._changeTableChanged = false;
+    });
+  }
+
+  _handleSaveDiffPreferences() {
+    this.$.diffPrefs.save();
+  }
+
+  _handleSaveEditPreferences() {
+    this.$.editPrefs.save();
+  }
+
+  _handleSaveMenu() {
+    this.set('prefs.my', this._localMenu);
+    this._cloneMenu(this._localMenu);
+    return this.$.restAPI.savePreferences(this.prefs).then(() => {
+      this._menuChanged = false;
+    });
+  }
+
+  _handleResetMenuButton() {
+    return this.$.restAPI.getDefaultPreferences().then(data => {
+      if (data?.my) {
+        this._cloneMenu(data.my);
+      }
+    });
+  }
+
+  _handleSaveWatchedProjects() {
+    this.$.watchedProjectsEditor.save();
+  }
+
+  _computeHeaderClass(changed?: boolean) {
+    return changed ? 'edited' : '';
+  }
+
+  _handleSaveEmails() {
+    this.$.emailEditor.save();
+  }
+
+  _handleNewEmailKeydown(e: CustomKeyboardEvent) {
+    if (e.keyCode === 13) {
+      // Enter
+      e.stopPropagation();
+      this._handleAddEmailButton();
+    }
+  }
+
+  _isNewEmailValid(newEmail?: string): newEmail is string {
+    return !!newEmail && newEmail.includes('@');
+  }
+
+  _computeAddEmailButtonEnabled(newEmail?: string, addingEmail?: boolean) {
+    return this._isNewEmailValid(newEmail) && !addingEmail;
+  }
+
+  _handleAddEmailButton() {
+    if (!this._isNewEmailValid(this._newEmail)) return;
+
+    this._addingEmail = true;
+    this.$.restAPI.addAccountEmail(this._newEmail).then(response => {
+      this._addingEmail = false;
+
+      // If it was unsuccessful.
+      if (response.status < 200 || response.status >= 300) {
+        return;
+      }
+
+      this._lastSentVerificationEmail = this._newEmail;
+      this._newEmail = '';
+    });
+  }
+
+  _getFilterDocsLink(docsBaseUrl?: string) {
+    let base = docsBaseUrl;
+    if (!base || !ABSOLUTE_URL_PATTERN.test(base)) {
+      base = GERRIT_DOCS_BASE_URL;
+    }
+
+    // Remove any trailing slash, since it is in the GERRIT_DOCS_FILTER_PATH.
+    base = base.replace(TRAILING_SLASH_PATTERN, '');
+
+    return base + GERRIT_DOCS_FILTER_PATH;
+  }
+
+  _handleToggleDark() {
+    if (this._isDark) {
+      window.localStorage.removeItem('dark-theme');
+      removeDarkTheme();
+    } else {
+      window.localStorage.setItem('dark-theme', 'true');
+      applyDarkTheme();
+    }
+    this._isDark = !!window.localStorage.getItem('dark-theme');
+    fireAlert(this, `Theme changed to ${this._isDark ? 'dark' : 'light'}.`);
+  }
+
+  _showHttpAuth(config?: ServerInfo) {
+    if (config && config.auth && config.auth.git_basic_auth_policy) {
+      return HTTP_AUTH.includes(
+        config.auth.git_basic_auth_policy.toUpperCase()
+      );
+    }
+
+    return false;
+  }
+
+  /**
+   * Work around a issue on iOS when clicking turns into double tap
+   */
+  _onTapDarkToggle(e: Event) {
+    e.preventDefault();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-settings-view': GrSettingsView;
+  }
+}
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
index ea26a51..78f84b1 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
@@ -175,6 +175,9 @@
               <select>
                 <option value="CC_ON_OWN_COMMENTS">Every comment</option>
                 <option value="ENABLED">Only comments left by others</option>
+                <option value="ATTENTION_SET_ONLY"
+                  >Only when I am in the attention set</option
+                >
                 <option value="DISABLED">None</option>
               </select>
             </gr-select>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
index 1929f4e..0535e15 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
@@ -19,6 +19,7 @@
 import {getComputedStyleValue} from '../../../utils/dom-util.js';
 import './gr-settings-view.js';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GerritView} from '../../core/gr-navigation/gr-navigation.js';
 
 const basicFixture = fixtureFromElement('gr-settings-view');
 const blankFixture = fixtureFromElement('div');
@@ -95,7 +96,7 @@
     element = basicFixture.instantiate();
 
     // Allow the element to render.
-    element._loadingPromise.then(done);
+    element._testOnly_loadingPromise.then(done);
   });
 
   test('theme changing', () => {
@@ -485,7 +486,7 @@
           .callsFake(
               () => new Promise(
                   resolve => { resolveConfirm = resolve; }));
-      element.params = {emailToken: 'foo'};
+      element.params = {view: GerritView.SETTINGS, emailToken: 'foo'};
       element.attached();
     });
 
@@ -499,7 +500,7 @@
     });
 
     test('user emails are loaded after email confirmed', done => {
-      element._loadingPromise.then(() => {
+      element._testOnly_loadingPromise.then(() => {
         assert.isTrue(element.$.emailEditor.loadData.calledOnce);
         done();
       });
@@ -508,7 +509,7 @@
 
     test('show-alert is fired when email is confirmed', done => {
       sinon.spy(element, 'dispatchEvent');
-      element._loadingPromise.then(() => {
+      element._testOnly_loadingPromise.then(() => {
         assert.equal(
             element.dispatchEvent.lastCall.args[0].type, 'show-alert');
         assert.deepEqual(
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
index 15f2d4f..507caef 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
@@ -93,7 +93,7 @@
 
   _showKey(e: Event) {
     const el = (dom(e) as EventApi).localTarget as GrButton;
-    const index = parseInt(el.getAttribute('data-index')!, 10);
+    const index = Number(el.getAttribute('data-index')!);
     this._keyToView = this._keys[index];
     this.$.viewKeyOverlay.open();
   }
@@ -104,7 +104,7 @@
 
   _handleDeleteKey(e: Event) {
     const el = (dom(e) as EventApi).localTarget as GrButton;
-    const index = parseInt(el.getAttribute('data-index')!, 10);
+    const index = Number(el.getAttribute('data-index')!);
     this.push('_keysToRemove', this._keys[index]);
     this.splice('_keys', index, 1);
     this.hasUnsavedChanges = true;
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
index e1adae5..15f9c6b 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
@@ -138,7 +138,7 @@
     const el = (dom(e) as EventApi).localTarget as HTMLInputElement;
     const dataIndex = el.getAttribute('data-index');
     if (dataIndex === null || !this._projects) return;
-    const index = parseInt(dataIndex, 10);
+    const index = Number(dataIndex);
     const project = this._projects[index];
     this.splice('_projects', index, 1);
     this.push('_projectsToRemove', project);
@@ -221,7 +221,7 @@
     const dataIndex = el.getAttribute('data-index');
     const key = el.getAttribute('data-key');
     if (dataIndex === null || key === null) return;
-    const index = parseInt(dataIndex, 10);
+    const index = Number(dataIndex);
     const checked = el.checked;
     this.set(['_projects', index, key], !!checked);
     this.hasUnsavedChanges = true;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index c1fee2dc..be23cb3 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -183,6 +183,18 @@
     );
   }
 
+  _computeHasAttentionClass(
+    config: ServerInfo | undefined,
+    highlight: boolean,
+    account: AccountInfo,
+    change: ChangeInfo,
+    force: boolean
+  ) {
+    return this._hasAttention(config, highlight, account, change, force)
+      ? 'hasAttention'
+      : '';
+  }
+
   _computeName(
     account?: AccountInfo,
     config?: ServerInfo,
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
index d2b58a6..1d8b13e 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
@@ -85,6 +85,9 @@
       position: relative;
       top: 2px;
     }
+    .hasAttention .name {
+      font-weight: var(--font-weight-bold);
+    }
   </style>
   <span>
     <template is="dom-if" if="[[!hideHovercard]]">
@@ -113,7 +116,10 @@
       </gr-button>
     </template>
   </span>
-  <span id="hovercardTarget">
+  <span
+    id="hovercardTarget"
+    class$="[[_computeHasAttentionClass(_config, highlightAttention, account, change, forceAttention)]]"
+  >
     <template is="dom-if" if="[[!hideAvatar]]">
       <gr-avatar account="[[account]]" image-size="32"></gr-avatar>
     </template>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
index c5e71fc..5cc1240 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
@@ -38,6 +38,7 @@
 import {GrAccountChip} from '../gr-account-chip/gr-account-chip';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {PaperInputElementExt} from '../../../types/types';
+import {fireAlert} from '../../../utils/event-util';
 
 const VALID_EMAIL_ALERT = 'Please input a valid email.';
 
@@ -256,13 +257,7 @@
         // 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(item);
-        this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message: VALID_EMAIL_ALERT},
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fireAlert(this, VALID_EMAIL_ALERT);
         return false;
       } else {
         const account = {email: item, _pendingAdd: true};
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
index d62ce73..451bdfa 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
@@ -25,6 +25,7 @@
 import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {IronFitMixin} from '../../../mixins/iron-fit-mixin/iron-fit-mixin';
 import {customElement, property, observe} from '@polymer/decorators';
+import {IronFitBehavior} from '@polymer/iron-fit-behavior/iron-fit-behavior';
 
 // TODO(TS): Update once GrCursorManager is upated
 export interface GrAutocompleteDropdown {
@@ -55,7 +56,8 @@
 export class GrAutocompleteDropdown extends IronFitMixin(
   KeyboardShortcutMixin(
     GestureEventListeners(LegacyElementMixin(PolymerElement))
-  )
+  ),
+  IronFitBehavior as IronFitBehavior
 ) {
   static get template() {
     return htmlTemplate;
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
index 033b617..668ea1b 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -24,15 +24,12 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-autocomplete_html';
-import {
-  KeyboardShortcutMixin,
-  CustomKeyboardEvent,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {property, customElement, observe} from '@polymer/decorators';
 import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
-import {EventWithPath} from '../../plugins/gr-event-helper/gr-event-helper';
 import {PaperInputElementExt} from '../../../types/types';
+import {CustomKeyboardEvent} from '../../../types/events';
 
 const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
 const DEBOUNCE_WAIT_MS = 200;
@@ -452,7 +449,7 @@
     }
   }
 
-  _handleBodyClick(e: EventWithPath) {
+  _handleBodyClick(e: Event) {
     const eventPath = e.path;
     if (!eventPath) return;
     for (let i = 0; i < eventPath.length; i++) {
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
index 4d75138..7a6ce2c 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -22,13 +22,11 @@
 import {customElement, property, computed, observe} from '@polymer/decorators';
 import {htmlTemplate} from './gr-button_html';
 import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin';
-import {
-  KeyboardShortcutMixin,
-  CustomKeyboardEvent,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {PolymerEvent, getEventPath} from '../../../utils/dom-util';
 import {appContext} from '../../../services/app-context';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {CustomKeyboardEvent} from '../../../types/events';
 
 declare global {
   interface HTMLElementTagNameMap {
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
index 0aa077e..1ecaf7f 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
@@ -30,6 +30,11 @@
   }
 }
 
+export interface ChangeStarToggleStarDetail {
+  change: ChangeInfo;
+  starred: boolean;
+}
+
 @customElement('gr-change-star')
 export class GrChangeStar extends KeyboardShortcutMixin(
   GestureEventListeners(LegacyElementMixin(PolymerElement))
@@ -69,11 +74,15 @@
     }
     const newVal = !this.change.starred;
     this.set('change.starred', newVal);
+    const detail: ChangeStarToggleStarDetail = {
+      change: this.change,
+      starred: newVal,
+    };
     this.dispatchEvent(
       new CustomEvent('toggle-star', {
         bubbles: true,
         composed: true,
-        detail: {change: this.change, starred: newVal},
+        detail,
       })
     );
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index fe6a0fd..4bad587 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -23,10 +23,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-comment-thread_html';
-import {
-  CustomKeyboardEvent,
-  KeyboardShortcutMixin,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {
   isDraft,
   isRobot,
@@ -34,7 +31,7 @@
   UIComment,
   UIDraft,
   UIRobot,
-} from '../../diff/gr-comment-api/gr-comment-api';
+} from '../../../utils/comment-util';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {appContext} from '../../../services/app-context';
 import {CommentSide, Side, SpecialFilePath} from '../../../constants/constants';
@@ -52,6 +49,8 @@
 import {GrComment} from '../gr-comment/gr-comment';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {GrStorage, StorageLocation} from '../gr-storage/gr-storage';
+import {CustomKeyboardEvent} from '../../../types/events';
+import {LineNumber, FILE} from '../../diff/gr-diff/gr-diff-line';
 
 const UNRESOLVED_EXPAND_COUNT = 5;
 const NEWLINE_PATTERN = /\n/g;
@@ -90,7 +89,7 @@
    * diff widget like gr-diff to show the thread in the right location:
    *
    * line-num:
-   *     1-based line number or undefined if it refers to the entire file.
+   *     1-based line number or 'FILE' if it refers to the entire file.
    *
    * comment-side:
    *     "left" or "right". These indicate which of the two diffed versions
@@ -143,13 +142,13 @@
     notify: true,
     computed: '_computeRootId(comments.*)',
   })
-  rootId?: string;
+  rootId?: UrlEncodedCommentId;
 
   @property({type: Boolean})
   showFilePath = false;
 
-  @property({type: Number, reflectToAttribute: true})
-  lineNum?: number;
+  @property({type: Object, reflectToAttribute: true})
+  lineNum?: LineNumber;
 
   @property({type: Boolean, notify: true, reflectToAttribute: true})
   unresolved?: boolean;
@@ -202,7 +201,7 @@
     this._setInitialExpandedState();
   }
 
-  addOrEditDraft(lineNum?: number, rangeParam?: CommentRange) {
+  addOrEditDraft(lineNum?: LineNumber, rangeParam?: CommentRange) {
     const lastComment = this.comments[this.comments.length - 1] || {};
     if (isDraft(lastComment)) {
       const commentEl = this._commentElWithDraftID(
@@ -225,7 +224,7 @@
     }
   }
 
-  addDraft(lineNum?: number, range?: CommentRange, unresolved?: boolean) {
+  addDraft(lineNum?: LineNumber, range?: CommentRange, unresolved?: boolean) {
     const draft = this._newDraft(lineNum, range);
     draft.__editing = true;
     draft.unresolved = unresolved === false ? unresolved : true;
@@ -274,7 +273,7 @@
         path,
         patchNum,
         undefined,
-        this.lineNum
+        this.lineNum === FILE ? undefined : this.lineNum
       );
     }
     const id = this.comments[0].id;
@@ -295,14 +294,14 @@
   }
 
   _computeDisplayLine() {
-    if (this.lineNum) return `#${this.lineNum}`;
-    // If range is set, then lineNum equals the end line of the range.
-    if (!this.lineNum && !this.range) {
+    if (this.lineNum === FILE) {
       if (this.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
         return '';
       }
-      return 'FILE';
+      return FILE;
     }
+    if (this.lineNum) return `#${this.lineNum}`;
+    // If range is set, then lineNum equals the end line of the range.
     if (this.range) return `#${this.range.end_line}`;
     return '';
   }
@@ -492,7 +491,7 @@
     return d;
   }
 
-  _newDraft(lineNum?: number, range?: CommentRange) {
+  _newDraft(lineNum?: LineNumber, range?: CommentRange) {
     const d: UIDraft = {
       __draft: true,
       __draftID: Math.random().toString(36),
@@ -518,7 +517,7 @@
       d.side = this._getSide(this.isOnParent);
       d.__commentSide = this.commentSide;
 
-      if (lineNum) {
+      if (lineNum && lineNum !== FILE) {
         d.line = lineNum;
       }
       if (range) {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
index 52264ec..1833b73 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
@@ -19,7 +19,7 @@
 import './gr-comment-thread.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {SpecialFilePath} from '../../../constants/constants.js';
-import {sortComments} from '../../diff/gr-comment-api/gr-comment-api.js';
+import {sortComments} from '../../../utils/comment-util.js';
 
 const basicFixture = fixtureFromElement('gr-comment-thread');
 
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index d68da94..bba6bf1 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -58,7 +58,9 @@
   UIComment,
   UIDraft,
   UIRobot,
-} from '../../diff/gr-comment-api/gr-comment-api';
+} from '../../../utils/comment-util';
+import {OpenFixPreviewEventDetail} from '../../../types/events';
+import {fireAlert} from '../../../utils/event-util';
 
 const STORAGE_DEBOUNCE_INTERVAL = 400;
 const TOAST_DEBOUNCE_INTERVAL = 200;
@@ -103,11 +105,6 @@
   };
 }
 
-export interface CommentEventDetail {
-  patchNum?: PatchSetNum;
-  comment?: UIComment;
-}
-
 @customElement('gr-comment')
 export class GrComment extends KeyboardShortcutMixin(
   GestureEventListeners(LegacyElementMixin(PolymerElement))
@@ -506,7 +503,7 @@
     );
   }
 
-  _getEventPayload(): CommentEventDetail {
+  _getEventPayload(): OpenFixPreviewEventDetail {
     return {comment: this.comment, patchNum: this.patchNum};
   }
 
@@ -854,13 +851,7 @@
         // Note: the event is fired on the body rather than this element because
         // this element may not be attached by the time this executes, in which
         // case the event would not bubble.
-        document.body.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message},
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fireAlert(document.body, message);
       },
       TOAST_DEBOUNCE_INTERVAL
     );
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
index f557295..c3125c1 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
@@ -249,12 +249,17 @@
   <div id="container" class="container">
     <div class="header" id="header" on-click="_handleToggleCollapsed">
       <div class="headerLeft">
-        <gr-account-label
-          account="[[_getAuthor(comment, _selfAccount)]]"
-          class$="[[_computeAccountLabelClass(draft)]]"
-          hide-status=""
-        >
-        </gr-account-label>
+        <template is="dom-if" if="[[comment.robot_id]]">
+          <span class="robotName"> [[comment.robot_id]] </span>
+        </template>
+        <template is="dom-if" if="[[!comment.robot_id]]">
+          <gr-account-label
+            account="[[_getAuthor(comment, _selfAccount)]]"
+            class$="[[_computeAccountLabelClass(draft)]]"
+            hide-status=""
+          >
+          </gr-account-label>
+        </template>
         <gr-tooltip-content
           class="draftTooltip"
           has-tooltip=""
@@ -280,6 +285,7 @@
       </div>
       <gr-button
         id="deleteBtn"
+        title="Delete Comment"
         link=""
         class$="action delete [[_computeDeleteButtonClass(_isAdmin, draft)]]"
         hidden$="[[isRobotComment]]"
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
index 10925af..e8d74f0 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
@@ -695,9 +695,8 @@
         assert.isTrue(runDetailsLink.href.indexOf(element.comment.url) !== -1);
 
         const robotServiceName = element.shadowRoot
-            .querySelector('gr-account-label')
-            .shadowRoot.querySelector('span.name');
-        assert.equal(robotServiceName.textContent.trim(), 'Display name Robot');
+            .querySelector('.robotName');
+        assert.equal(robotServiceName.textContent.trim(), 'happy_robot_id');
 
         const authorName = element.shadowRoot
             .querySelector('.robotId');
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
index d9795c9..2fbbd7c 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
@@ -48,6 +48,19 @@
   ABORTED,
 }
 
+/** A sentinel that can be inserted to disallow moving across. */
+export class AbortStop {}
+
+export type Stop = HTMLElement | AbortStop;
+
+/**
+ * Type guard and checker to check if a stop can be targetted.
+ * Abort stops cannot be targetted.
+ */
+export function isTargetable(stop: Stop): stop is HTMLElement {
+  return !(stop instanceof AbortStop);
+}
+
 @customElement('gr-cursor-manager')
 export class GrCursorManager extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -95,7 +108,12 @@
   focusOnMove = false;
 
   @property({type: Array})
-  stops: HTMLElement[] = [];
+  stops: Stop[] = [];
+
+  /** Only non-AbortStop stops. */
+  get targetableStops(): HTMLElement[] {
+    return this.stops.filter(isTargetable);
+  }
 
   /** @override */
   detached() {
@@ -106,9 +124,6 @@
   /**
    * Move the cursor forward. Clipped to the ends of the stop list.
    *
-   * @param options.abort Will abort moving the cursor when encountering a
-   *    stop for which this condition is met. Will abort even if the stop
-   *    would have been filtered
    * @param options.filter Will keep going and skip any stops for which this
    *    condition is not met.
    * @param options.getTargetHeight Optional function to calculate the
@@ -122,7 +137,6 @@
   next(
     options: {
       filter?: (stop: HTMLElement) => boolean;
-      abort?: (stop: HTMLElement) => boolean;
       getTargetHeight?: (target: HTMLElement) => number;
       clipToTop?: boolean;
     } = {}
@@ -133,7 +147,6 @@
   previous(
     options: {
       filter?: (stop: HTMLElement) => boolean;
-      abort?: (stop: HTMLElement) => boolean;
     } = {}
   ): CursorMoveResult {
     return this._moveCursor(-1, options);
@@ -152,7 +165,9 @@
     if (!this.stops || !this._isIntersectionObserverSupported()) {
       return;
     }
-    const filteredStops = condition ? this.stops.filter(condition) : this.stops;
+    const filteredStops = condition
+      ? this.targetableStops.filter(condition)
+      : this.targetableStops;
     const dims = this._getWindowDims();
     const windowCenter = Math.round(dims.innerHeight / 2);
 
@@ -212,12 +227,17 @@
   }
 
   /**
-   * Set the cursor to an arbitrary element.
+   * Set the cursor to an arbitrary stop - if the given element is not one of
+   * the stops, unset the cursor.
    *
    * @param noScroll prevent any potential scrolling in response
    * setting the cursor.
    */
   setCursor(element: HTMLElement, noScroll?: boolean) {
+    if (!this.targetableStops.includes(element)) {
+      this.unsetCursor();
+      return;
+    }
     let behavior;
     if (noScroll) {
       behavior = this.scrollMode;
@@ -241,30 +261,33 @@
     this._targetHeight = null;
   }
 
-  isAtStart() {
-    return this.index === 0;
+  /** Returns true if there are no stops, or we are on the first stop. */
+  isAtStart(): boolean {
+    return this.stops.length === 0 || this.index === 0;
   }
 
-  isAtEnd() {
-    // Unset cursor should not be considered "at end", even when there are no
-    // cursor stops.
-    return this.index !== -1 && this.index === this.stops.length - 1;
+  /** Returns true if there are no stops, or we are on the last stop. */
+  isAtEnd(): boolean {
+    return this.stops.length === 0 || this.index === this.stops.length - 1;
   }
 
   moveToStart() {
     if (this.stops.length) {
-      this.setCursor(this.stops[0]);
+      this.setCursorAtIndex(0);
     }
   }
 
   moveToEnd() {
     if (this.stops.length) {
-      this.setCursor(this.stops[this.stops.length - 1]);
+      this.setCursorAtIndex(this.stops.length - 1);
     }
   }
 
   setCursorAtIndex(index: number, noScroll?: boolean) {
-    this.setCursor(this.stops[index], noScroll);
+    const stop = this.stops[index];
+    if (isTargetable(stop)) {
+      this.setCursor(stop, noScroll);
+    }
   }
 
   /**
@@ -289,12 +312,10 @@
     delta: number,
     {
       filter,
-      abort,
       getTargetHeight,
       clipToTop,
     }: {
       filter?: (stop: HTMLElement) => boolean;
-      abort?: (stop: HTMLElement) => boolean;
       getTargetHeight?: (target: HTMLElement) => number;
       clipToTop?: boolean;
     } = {}
@@ -312,7 +333,7 @@
     }
 
     let clipped = false;
-
+    let newStop: Stop;
     do {
       newIndex += delta;
       if (
@@ -320,19 +341,23 @@
         (delta < 0 && newIndex < 0)
       ) {
         newIndex = delta < 0 || clipToTop ? 0 : this.stops.length - 1;
+        newStop = this.stops[newIndex];
         clipped = true;
         break;
       }
-      if (abort && abort(this.stops[newIndex])) {
-        newIndex = this.index;
-        return CursorMoveResult.ABORTED;
-      }
-    } while (filter && !filter(this.stops[newIndex]));
+      // Sadly needed so that type narrowing understands that this.stops[newIndex] is
+      // targetable after I have checked that.
+      newStop = this.stops[newIndex];
+    } while (isTargetable(newStop) && filter && !filter(newStop));
+
+    if (!isTargetable(newStop)) {
+      return CursorMoveResult.ABORTED;
+    }
 
     this._unDecorateTarget();
 
     this.index = newIndex;
-    this.target = this.stops[newIndex];
+    this.target = newStop;
 
     if (getTargetHeight) {
       this._targetHeight = getTargetHeight(this.target);
@@ -368,7 +393,7 @@
       return;
     }
 
-    const newIndex = Array.prototype.indexOf.call(this.stops, this.target);
+    const newIndex = this.stops.indexOf(this.target);
     if (newIndex === -1) {
       this.unsetCursor();
     } else {
@@ -394,9 +419,6 @@
     return top;
   }
 
-  /**
-   * @return
-   */
   _targetIsVisible(top: number) {
     const dims = this._getWindowDims();
     return (
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
index 8848301..6f74d6b 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
@@ -18,7 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-cursor-manager.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {CursorMoveResult} from './gr-cursor-manager.js';
+import {AbortStop, CursorMoveResult} from './gr-cursor-manager.js';
 
 const basicTestFixutre = fixtureFromTemplate(html`
     <gr-cursor-manager cursor-target-class="targeted"></gr-cursor-manager>
@@ -48,7 +48,7 @@
     assert.isNotOk(element.target);
 
     // Initialize the cursor with its stops.
-    element.stops = list.querySelectorAll('li');
+    element.stops = [...list.querySelectorAll('li')];
 
     // It should have the stops but it should not be targeting any of them.
     assert.isNotNull(element.stops);
@@ -104,7 +104,7 @@
     const newLi = document.createElement('li');
     newLi.textContent = 'Z';
     list.insertBefore(newLi, list.children[0]);
-    element.stops = list.querySelectorAll('li');
+    element.stops = [...list.querySelectorAll('li')];
 
     assert.equal(element.index, 1);
 
@@ -117,8 +117,18 @@
     assert.equal(element.index, -1);
   });
 
+  test('isAtStart() returns true when there are no stops', () => {
+    element.stops = [];
+    assert.isTrue(element.isAtStart());
+  });
+
+  test('isAtEnd() returns true when there are no stops', () => {
+    element.stops = [];
+    assert.isTrue(element.isAtEnd());
+  });
+
   test('next() goes to first element when no cursor is set', () => {
-    element.stops = list.querySelectorAll('li');
+    element.stops = [...list.querySelectorAll('li')];
     const result = element.next();
 
     assert.equal(result, CursorMoveResult.MOVED);
@@ -137,35 +147,10 @@
     assert.equal(element.index, -1);
     assert.isNotOk(element.target);
     assert.isFalse(list.children[1].classList.contains('targeted'));
-    assert.isFalse(element.isAtStart());
-    assert.isFalse(element.isAtEnd());
-  });
-
-  test('next() with abort', () => {
-    element.stops = list.querySelectorAll('li');
-    element.setCursor(list.children[0]);
-
-    const result = element.next({abort: row => row.textContent === 'B'});
-
-    assert.equal(result, CursorMoveResult.ABORTED);
-    assert.equal(element.index, 0);
-  });
-
-  test('next() aborts even when stop would be filtered', () => {
-    element.stops = list.querySelectorAll('li');
-    element.setCursor(list.children[0]);
-
-    const result = element.next({
-      abort: row => row.textContent === 'B',
-      filter: row => row.textContent === 'C',
-    });
-
-    assert.equal(result, CursorMoveResult.ABORTED);
-    assert.equal(element.index, 0);
   });
 
   test('previous() goes to last element when no cursor is set', () => {
-    element.stops = list.querySelectorAll('li');
+    element.stops = [...list.querySelectorAll('li')];
     const result = element.previous();
 
     assert.equal(result, CursorMoveResult.MOVED);
@@ -185,13 +170,11 @@
     assert.equal(element.index, -1);
     assert.isNotOk(element.target);
     assert.isFalse(list.children[1].classList.contains('targeted'));
-    assert.isFalse(element.isAtStart());
-    assert.isFalse(element.isAtEnd());
   });
 
   test('_moveCursor', () => {
     // Initialize the cursor with its stops.
-    element.stops = list.querySelectorAll('li');
+    element.stops = [...list.querySelectorAll('li')];
     // Select the first stop.
     element.setCursor(list.children[0]);
     const getTargetHeight = sinon.stub();
@@ -215,7 +198,7 @@
   test('setCursorAtIndex with noScroll', () => {
     sinon.stub(element, '_targetIsVisible').callsFake(() => false);
     const scrollStub = sinon.stub(window, 'scrollTo');
-    element.stops = list.querySelectorAll('li');
+    element.stops = [...list.querySelectorAll('li')];
     element.scrollMode = 'keep-visible';
 
     element.setCursorAtIndex(1, true);
@@ -229,7 +212,7 @@
     const isLetterB = function(row) {
       return row.textContent === 'B';
     };
-    element.stops = list.querySelectorAll('li');
+    element.stops = [...list.querySelectorAll('li')];
     // Start cursor at the first stop.
     element.setCursor(list.children[0]);
 
@@ -256,7 +239,7 @@
   });
 
   test('focusOnMove prop', () => {
-    const listEls = list.querySelectorAll('li');
+    const listEls = [...list.querySelectorAll('li')];
     for (let i = 0; i < listEls.length; i++) {
       sinon.spy(listEls[i], 'focus');
     }
@@ -275,7 +258,7 @@
   suite('_scrollToTarget', () => {
     let scrollStub;
     setup(() => {
-      element.stops = list.querySelectorAll('li');
+      element.stops = [...list.querySelectorAll('li')];
       element.scrollMode = 'keep-visible';
 
       // There is a target which has a targetNext
@@ -339,4 +322,50 @@
           905);
     });
   });
+
+  suite('AbortStops', () => {
+    test('next() does not skip AbortStops', () => {
+      element.stops = [
+        document.createElement('li'),
+        new AbortStop(),
+        document.createElement('li'),
+      ];
+      element.setCursorAtIndex(0);
+
+      const result = element.next();
+
+      assert.equal(result, CursorMoveResult.ABORTED);
+      assert.equal(element.index, 0);
+    });
+
+    test('setCursorAtIndex() does not target AbortStops', () => {
+      element.stops = [
+        document.createElement('li'),
+        new AbortStop(),
+        document.createElement('li'),
+      ];
+      element.setCursorAtIndex(1);
+      assert.equal(element.index, -1);
+    });
+
+    test('moveToStart() does not target AbortStop', () => {
+      element.stops = [
+        new AbortStop(),
+        document.createElement('li'),
+        document.createElement('li'),
+      ];
+      element.moveToStart();
+      assert.equal(element.index, -1);
+    });
+
+    test('moveToEnd() does not target AbortStop', () => {
+      element.stops = [
+        document.createElement('li'),
+        document.createElement('li'),
+        new AbortStop(),
+      ];
+      element.moveToEnd();
+      assert.equal(element.index, -1);
+    });
+  });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
index 798f58b..c64dc2a 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
@@ -122,7 +122,13 @@
   _timeFormat?: string;
 
   @property({type: Boolean})
-  _relative?: boolean;
+  _relative = false;
+
+  @property({type: Boolean})
+  forceRelative = false;
+
+  @property({type: Boolean})
+  relativeOptionNoAgo = false;
 
   constructor() {
     super();
@@ -143,7 +149,7 @@
       if (!loggedIn) {
         this._timeFormat = TimeFormats.TIME_24;
         this._dateFormat = DateFormats.STD;
-        this._relative = false;
+        this._relative = this.forceRelative;
         return;
       }
       return Promise.all([this._loadTimeFormat(), this._loadRelative()]);
@@ -198,7 +204,8 @@
   _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);
+      this._relative =
+        this.forceRelative || !!(prefs && prefs.relative_date_in_change_table);
     });
   }
 
@@ -225,7 +232,7 @@
       return '';
     }
     if (relative) {
-      return fromNow(date);
+      return fromNow(date, this.relativeOptionNoAgo);
     }
     const now = new Date();
     let format = dateFormat.full;
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
index 02d039a..e781820c 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
@@ -25,7 +25,7 @@
 import {htmlTemplate} from './gr-diff-preferences_html';
 import {customElement, property} from '@polymer/decorators';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {DiffPreferencesInfo} from '../../../types/common';
+import {DiffPreferencesInfo} from '../../../types/diff';
 import {GrSelect} from '../gr-select/gr-select';
 
 export interface GrDiffPreferences {
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
index 84e6d98..2ea72ca 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
@@ -115,13 +115,13 @@
    * Handle a click on the button to open the dropdown.
    */
   _showDropdownTapHandler() {
-    this._open();
+    this.open();
   }
 
   /**
    * Open the dropdown.
    */
-  _open() {
+  open() {
     this.$.dropdown.open();
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts
index 30e0143..26a6b3f 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts
@@ -35,9 +35,7 @@
       background-color: var(--dropdown-background-color);
       box-shadow: var(--elevation-level-2);
       max-height: 70vh;
-      margin-top: var(--spacing-xxl);
       min-width: 266px;
-      @apply --dropdown-content-style;
     }
     paper-listbox {
       --paper-listbox: {
@@ -136,6 +134,9 @@
   <iron-dropdown
     id="dropdown"
     vertical-align="top"
+    horizontal-align="left"
+    dynamic-align
+    no-overlap
     allow-outside-scroll="true"
     on-click="_handleDropdownClick"
   >
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js
index 5dcf8da..e3d7ed70 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js
@@ -47,7 +47,7 @@
   });
 
   test('tap on trigger opens menu', () => {
-    sinon.stub(element, '_open')
+    sinon.stub(element, 'open')
         .callsFake(() => { element.$.dropdown.open(); });
     assert.isFalse(element.$.dropdown.opened);
     MockInteractions.tap(element.$.trigger);
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
index 1fadce5..d64b1c0 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
@@ -47,11 +47,11 @@
   };
 }
 
-interface DropdownLink {
+export interface DropdownLink {
   url?: string;
   name?: string;
   external?: boolean;
-  target?: string;
+  target?: string | null;
   download?: boolean;
   id?: string;
   tooltip?: string;
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index 90aaa9f..9c9363b 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -24,6 +24,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement, property} from '@polymer/decorators';
 import {htmlTemplate} from './gr-editable-content_html';
+import {fireAlert} from '../../../utils/event-util';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
@@ -149,13 +150,7 @@
       );
       if (storedContent?.message) {
         content = storedContent.message;
-        this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message: RESTORED_MESSAGE},
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fireAlert(this, RESTORED_MESSAGE);
       }
     }
     if (!content) {
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
index edadfba..9e1a5bf 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
@@ -21,15 +21,13 @@
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {
-  CustomKeyboardEvent,
-  KeyboardShortcutMixin,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {customElement, property} from '@polymer/decorators';
 import {htmlTemplate} from './gr-editable-label_html';
 import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {PaperInputElementExt} from '../../../types/types';
+import {CustomKeyboardEvent} from '../../../types/events';
 
 const AWAIT_MAX_ITERS = 10;
 const AWAIT_STEP = 5;
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
index bc1dfe0..1688a0d 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
@@ -59,7 +59,8 @@
     gr-linked-text.pre {
       font-family: var(--monospace-font-family);
       font-size: var(--font-size-code);
-      line-height: var(--line-height-code);
+      /* usually 16px = 12px + 4px */
+      line-height: calc(var(--font-size-code) + var(--spacing-s));
     }
   </style>
   <div id="container"></div>
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
index b3a53c5..da2881e 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
@@ -26,13 +26,26 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-hovercard-account_html';
 import {appContext} from '../../../services/app-context';
-import {isServiceUser} from '../../../utils/account-util';
+import {accountKey} from '../../../utils/account-util';
 import {getDisplayName} from '../../../utils/display-name-util';
 import {customElement, property} from '@polymer/decorators';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
-import {AccountInfo, ChangeInfo, ServerInfo} from '../../../types/common';
+import {
+  AccountInfo,
+  ChangeInfo,
+  ServerInfo,
+  ReviewInput,
+} from '../../../types/common';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
-import {hasOwnProperty} from '../../../utils/common-util';
+import {
+  canHaveAttention,
+  getLastUpdate,
+  getReason,
+  hasAttention,
+  isAttentionSetEnabled,
+} from '../../../utils/attention-set-util';
+import {ReviewerState} from '../../../constants/constants';
+import {isRemovableReviewer} from '../../../utils/change-util';
 
 export interface GrHovercardAccount {
   $: {
@@ -101,70 +114,132 @@
     return account._account_id === selfAccount._account_id ? 'Your' : 'Their';
   }
 
-  get isAttentionSetEnabled() {
+  get isAttentionEnabled() {
     return (
-      !!this._config &&
-      !!this._config.change &&
-      !!this._config.change.enable_attention_set &&
+      isAttentionSetEnabled(this._config) &&
       !!this.highlightAttention &&
       !!this.change &&
-      !!this.account &&
-      !isServiceUser(this.account)
+      canHaveAttention(this.account)
     );
   }
 
-  get hasAttention() {
-    if (
-      !this.isAttentionSetEnabled ||
-      !this.change?.attention_set ||
-      !this.account._account_id
-    )
-      return false;
-    return hasOwnProperty(this.change.attention_set, this.account._account_id);
+  get hasUserAttention() {
+    return hasAttention(this._config, this.account, this.change);
   }
 
   _computeReason(change?: ChangeInfo) {
-    if (!change || !change.attention_set || !this.account._account_id) {
-      return '';
-    }
-    const entry = change.attention_set[this.account._account_id];
-    if (!entry || !entry.reason) return '';
-    return entry.reason;
+    return getReason(this.account, change);
   }
 
   _computeLastUpdate(change?: ChangeInfo) {
-    if (!change || !change.attention_set || !this.account._account_id) {
-      return '';
+    return getLastUpdate(this.account, change);
+  }
+
+  _showReviewerOrCCActions(account?: AccountInfo, change?: ChangeInfo) {
+    return !!this._selfAccount && isRemovableReviewer(change, account);
+  }
+
+  _getReviewerState(account: AccountInfo, change: ChangeInfo) {
+    if (
+      change.reviewers[ReviewerState.REVIEWER]?.some(
+        (reviewer: AccountInfo) => {
+          return reviewer._account_id === account._account_id;
+        }
+      )
+    ) {
+      return ReviewerState.REVIEWER;
     }
-    const entry = change.attention_set[this.account._account_id];
-    if (!entry || !entry.last_update) return '';
-    return entry.last_update;
+    return ReviewerState.CC;
+  }
+
+  _computeReviewerOrCCText(account?: AccountInfo, change?: ChangeInfo) {
+    if (!change || !account) return '';
+    return this._getReviewerState(account, change) === ReviewerState.REVIEWER
+      ? 'Reviewer'
+      : 'CC';
+  }
+
+  _computeChangeReviewerOrCCText(account?: AccountInfo, change?: ChangeInfo) {
+    if (!change || !account) return '';
+    return this._getReviewerState(account, change) === ReviewerState.REVIEWER
+      ? 'Move Reviewer to CC'
+      : 'Move CC to Reviewer';
+  }
+
+  _handleChangeReviewerOrCCStatus() {
+    if (!this.change) throw new Error('expected change object to be present');
+    // accountKey() throws an error if _account_id & email is not found, which
+    // we want to check before showing reloading toast
+    const _accountKey = accountKey(this.account);
+    this.dispatchEventThroughTarget('show-alert', {
+      message: 'Reloading page...',
+    });
+    const reviewInput: Partial<ReviewInput> = {};
+    reviewInput.reviewers = [
+      {
+        reviewer: _accountKey,
+        state:
+          this._getReviewerState(this.account, this.change) === ReviewerState.CC
+            ? ReviewerState.REVIEWER
+            : ReviewerState.CC,
+      },
+    ];
+
+    this.$.restAPI
+      .saveChangeReview(this.change._number, 'current', reviewInput)
+      .then(response => {
+        if (!response || !response.ok) {
+          throw new Error(
+            'something went wrong when toggling' +
+              this._getReviewerState(this.account, this.change!)
+          );
+        }
+        this.dispatchEventThroughTarget('reload', {clearPatchset: true});
+      });
+  }
+
+  _handleRemoveReviewerOrCC() {
+    if (!this.change || !(this.account?._account_id || this.account?.email))
+      throw new Error('Missing change or account.');
+    this.dispatchEventThroughTarget('show-alert', {
+      message: 'Reloading page...',
+    });
+    this.$.restAPI
+      .removeChangeReviewer(
+        this.change._number,
+        (this.account?._account_id || this.account?.email)!
+      )
+      .then((response: Response | undefined) => {
+        if (!response || !response.ok) {
+          throw new Error('something went wrong when removing user');
+        }
+        this.dispatchEventThroughTarget('reload', {clearPatchset: true});
+        return response;
+      });
   }
 
   _computeShowLabelNeedsAttention() {
-    return this.isAttentionSetEnabled && this.hasAttention;
+    return this.isAttentionEnabled && this.hasUserAttention;
   }
 
   _computeShowActionAddToAttentionSet() {
-    return this.isAttentionSetEnabled && !this.hasAttention;
+    return (
+      this._selfAccount && this.isAttentionEnabled && !this.hasUserAttention
+    );
   }
 
   _computeShowActionRemoveFromAttentionSet() {
-    return this.isAttentionSetEnabled && this.hasAttention;
+    return (
+      this._selfAccount && this.isAttentionEnabled && this.hasUserAttention
+    );
   }
 
   _handleClickAddToAttentionSet() {
     if (!this.change || !this.account._account_id) return;
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {
-          message: 'Saving attention set update ...',
-          dismissOnNavigation: true,
-        },
-        composed: true,
-        bubbles: true,
-      })
-    );
+    this.dispatchEventThroughTarget('show-alert', {
+      message: 'Saving attention set update ...',
+      dismissOnNavigation: true,
+    });
 
     // We are deliberately updating the UI before making the API call. It is a
     // risk that we are taking to achieve a better UX for 99.9% of the cases.
@@ -191,16 +266,10 @@
 
   _handleClickRemoveFromAttentionSet() {
     if (!this.change || !this.account._account_id) return;
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {
-          message: 'Saving attention set update ...',
-          dismissOnNavigation: true,
-        },
-        composed: true,
-        bubbles: true,
-      })
-    );
+    this.dispatchEventThroughTarget('show-alert', {
+      message: 'Saving attention set update ...',
+      dismissOnNavigation: true,
+    });
 
     // We are deliberately updating the UI before making the API call. It is a
     // risk that we are taking to achieve a better UX for 99.9% of the cases.
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
index 6556a94..1d437fb 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
@@ -172,6 +172,28 @@
           </gr-button>
         </div>
       </template>
+      <template is="dom-if" if="[[_showReviewerOrCCActions(account, change)]]">
+        <div class="action">
+          <gr-button
+            class="removeReviewerOrCC"
+            link=""
+            no-uppercase=""
+            on-click="_handleRemoveReviewerOrCC"
+          >
+            Remove [[_computeReviewerOrCCText(account, change)]]
+          </gr-button>
+        </div>
+        <div class="action">
+          <gr-button
+            class="changeReviewerOrCC"
+            link=""
+            no-uppercase=""
+            on-click="_handleChangeReviewerOrCCStatus"
+          >
+            [[_computeChangeReviewerOrCCText(account, change)]]
+          </gr-button>
+        </div>
+      </template>
     </template>
   </div>
   <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
index f272507..b09f0ce 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-hovercard-account.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {ReviewerState} from '../../../constants/constants.js';
 
 const basicFixture = fixtureFromTemplate(html`
 <gr-hovercard-account class="hovered"></gr-hovercard-account>
@@ -39,6 +40,7 @@
         new Promise(resolve => { '2'; })
     );
 
+    element._selfAccount = {...ACCOUNT};
     element.account = {...ACCOUNT};
     element._config = {
       change: {enable_attention_set: true},
@@ -59,17 +61,6 @@
         'Kermit The Frog');
   });
 
-  test('_computeReason', () => {
-    const change = {
-      attention_set: {
-        31415926535: {
-          reason: 'a good reason',
-        },
-      },
-    };
-    assert.equal(element._computeReason(change), 'a good reason');
-  });
-
   test('_computeLastUpdate', () => {
     const last_update = '2019-07-17 19:39:02.000000000';
     const change = {
@@ -112,6 +103,104 @@
         element.voteableText);
   });
 
+  test('remove reviewer', async () => {
+    element.change = {
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [ACCOUNT],
+      },
+    };
+    sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
+        Promise.resolve({ok: true}));
+    const reloadListener = sinon.spy();
+    element._target.addEventListener('reload', reloadListener);
+    flush();
+    const button = element.shadowRoot.querySelector('.removeReviewerOrCC');
+    assert.isOk(button);
+    assert.equal(button.innerText, 'Remove Reviewer');
+    MockInteractions.tap(button);
+    await flush();
+    assert.isTrue(reloadListener.called);
+  });
+
+  test('move reviewer to cc', async () => {
+    element.change = {
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [ACCOUNT],
+      },
+    };
+    const saveReviewStub = sinon.stub(element.$.restAPI,
+        'saveChangeReview').returns(
+        Promise.resolve({ok: true}));
+    sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
+        Promise.resolve({ok: true}));
+    const reloadListener = sinon.spy();
+    element._target.addEventListener('reload', reloadListener);
+
+    flush();
+    const button = element.shadowRoot.querySelector('.changeReviewerOrCC');
+
+    assert.isOk(button);
+    assert.equal(button.innerText, 'Move Reviewer to CC');
+    MockInteractions.tap(button);
+    await flush();
+
+    assert.isTrue(saveReviewStub.called);
+    assert.isTrue(reloadListener.called);
+  });
+
+  test('move reviewer to cc', async () => {
+    element.change = {
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [],
+      },
+    };
+    const saveReviewStub = sinon.stub(element.$.restAPI,
+        'saveChangeReview').returns(
+        Promise.resolve({ok: true}));
+    sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
+        Promise.resolve({ok: true}));
+    const reloadListener = sinon.spy();
+    element._target.addEventListener('reload', reloadListener);
+    flush();
+
+    const button = element.shadowRoot.querySelector('.changeReviewerOrCC');
+    assert.isOk(button);
+    assert.equal(button.innerText, 'Move CC to Reviewer');
+
+    MockInteractions.tap(button);
+    await flush();
+
+    assert.isTrue(saveReviewStub.called);
+    assert.isTrue(reloadListener.called);
+  });
+
+  test('remove cc', async () => {
+    element.change = {
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [],
+      },
+    };
+    sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
+        Promise.resolve({ok: true}));
+    const reloadListener = sinon.spy();
+    element._target.addEventListener('reload', reloadListener);
+
+    flush();
+    const button = element.shadowRoot.querySelector('.removeReviewerOrCC');
+
+    assert.equal(button.innerText, 'Remove CC');
+    assert.isOk(button);
+    MockInteractions.tap(button);
+
+    await flush();
+
+    assert.isTrue(reloadListener.called);
+  });
+
   test('add to attention set', async () => {
     let apiResolve;
     const apiPromise = new Promise(r => {
@@ -125,7 +214,7 @@
     const showAlertListener = sinon.spy();
     const hideAlertListener = sinon.spy();
     const updatedListener = sinon.spy();
-    element.addEventListener('show-alert', showAlertListener);
+    element._target.addEventListener('show-alert', showAlertListener);
     element._target.addEventListener('hide-alert', hideAlertListener);
     element._target.addEventListener('attention-set-updated', updatedListener);
 
@@ -159,7 +248,7 @@
     const showAlertListener = sinon.spy();
     const hideAlertListener = sinon.spy();
     const updatedListener = sinon.spy();
-    element.addEventListener('show-alert', showAlertListener);
+    element._target.addEventListener('show-alert', showAlertListener);
     element._target.addEventListener('hide-alert', hideAlertListener);
     element._target.addEventListener('attention-set-updated', updatedListener);
 
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
index 90dac88..78b6cda 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
@@ -29,6 +29,15 @@
   removeScrollLock,
 } from '@polymer/iron-overlay-behavior/iron-scroll-manager';
 
+interface ShowAlertEventDetail {
+  message: string;
+  dismissOnNavigation?: boolean;
+}
+
+interface ReloadEventDetail {
+  clearPatchset?: boolean;
+}
+
 const HOVER_CLASS = 'hovered';
 const HIDE_CLASS = 'hide';
 
@@ -193,6 +202,19 @@
        * Hovercard elements are created outside of <gr-app>, so if you want to fire
        * events, then you probably want to do that through the target element.
        */
+
+      dispatchEventThroughTarget(eventName: string): void;
+
+      dispatchEventThroughTarget(
+        eventName: 'show-alert',
+        detail: ShowAlertEventDetail
+      ): void;
+
+      dispatchEventThroughTarget(
+        eventName: 'reload',
+        detail: ReloadEventDetail
+      ): void;
+
       dispatchEventThroughTarget(eventName: string, detail?: unknown) {
         if (!detail) detail = {};
         if (this._target)
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
index 2d91d3d..e069f8b 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
@@ -23,7 +23,7 @@
 } from '../../../types/types';
 import {Side} from '../../../constants/constants';
 import {PluginApi} from '../../plugins/gr-plugin-types';
-import {NumericChangeId} from '../../../types/common';
+import {ChangeInfo, NumericChangeId} from '../../../types/common';
 
 type AddLayerFunc = (ctx: GrAnnotationActionsContext) => void;
 
@@ -38,7 +38,8 @@
   changeNum: NumericChangeId,
   path: string,
   basePatchNum?: number,
-  patchNum?: number
+  patchNum?: number,
+  change?: ChangeInfo
 ) => Promise<Array<CoverageRange>>;
 
 export class GrAnnotationActionsInterface {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
index 3a86700..7e8c1f8 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
@@ -15,50 +15,109 @@
  * limitations under the License.
  */
 import {
-  GrChangeActions,
   ActionType,
   ActionPriority,
 } from '../../../services/services/gr-rest-api/gr-rest-api';
 import {JsApiService} from './gr-js-api-types';
-import {TargetElement} from '../../plugins/gr-plugin-types';
+import {PluginApi, TargetElement} from '../../plugins/gr-plugin-types';
+import {ActionInfo, RequireProperties} from '../../../types/common';
 
-interface Plugin {
-  getPluginName(): string;
+export enum ChangeActions {
+  ABANDON = 'abandon',
+  DELETE = '/',
+  DELETE_EDIT = 'deleteEdit',
+  EDIT = 'edit',
+  FOLLOW_UP = 'followup',
+  IGNORE = 'ignore',
+  MOVE = 'move',
+  PRIVATE = 'private',
+  PRIVATE_DELETE = 'private.delete',
+  PUBLISH_EDIT = 'publishEdit',
+  REBASE = 'rebase',
+  REBASE_EDIT = 'rebaseEdit',
+  READY = 'ready',
+  RESTORE = 'restore',
+  REVERT = 'revert',
+  REVERT_SUBMISSION = 'revert_submission',
+  REVIEWED = 'reviewed',
+  STOP_EDIT = 'stopEdit',
+  SUBMIT = 'submit',
+  UNIGNORE = 'unignore',
+  UNREVIEWED = 'unreviewed',
+  WIP = 'wip',
+}
+
+export enum RevisionActions {
+  CHERRYPICK = 'cherrypick',
+  REBASE = 'rebase',
+  SUBMIT = 'submit',
+  DOWNLOAD = 'download',
+}
+
+export type PrimaryActionKey = ChangeActions | RevisionActions;
+
+export interface UIActionInfo extends RequireProperties<ActionInfo, 'label'> {
+  __key: string;
+  __url?: string;
+  __primary?: boolean;
+  __type: ActionType;
+  icon?: string;
+}
+
+// This interface is required to avoid circular dependencies between files;
+export interface GrChangeActionsElement extends Element {
+  RevisionActions?: Record<string, string>;
+  ChangeActions: Record<string, string>;
+  ActionType: Record<string, string>;
+  primaryActionKeys: string[];
+  push(propName: 'primaryActionKeys', value: string): void;
+  hideQuickApproveAction(): void;
+  setActionOverflow(type: ActionType, key: string, overflow: boolean): void;
+  setActionPriority(
+    type: ActionType,
+    key: string,
+    overflow: ActionPriority
+  ): void;
+  setActionHidden(type: ActionType, key: string, hidden: boolean): void;
+  addActionButton(type: ActionType, label: string): string;
+  removeActionButton(key: string): void;
+  setActionButtonProp<T extends keyof UIActionInfo>(
+    key: string,
+    prop: T,
+    value: UIActionInfo[T]
+  ): void;
+  getActionDetails(actionName: string): ActionInfo | undefined;
 }
 
 export class GrChangeActionsInterface {
-  private _el?: GrChangeActions;
-  // TODO(TS): define correct types when gr-change-actions is converted to ts
+  private _el?: GrChangeActionsElement;
 
-  RevisionActions?: Record<string, string>;
+  RevisionActions = RevisionActions;
 
-  ChangeActions?: Record<string, string>;
+  ChangeActions = ChangeActions;
 
-  ActionType?: Record<string, string>;
+  ActionType = ActionType;
 
-  constructor(public plugin: Plugin, el?: GrChangeActions) {
+  constructor(public plugin: PluginApi, el?: GrChangeActionsElement) {
     this.setEl(el);
   }
 
   /**
    * Set gr-change-actions element to a GrChangeActionsInterface instance.
    */
-  private setEl(el?: GrChangeActions) {
+  private setEl(el?: GrChangeActionsElement) {
     if (!el) {
       console.warn('changeActions() is not ready');
       return;
     }
     this._el = el;
-    this.RevisionActions = el.RevisionActions;
-    this.ChangeActions = el.ChangeActions;
-    this.ActionType = el.ActionType;
   }
 
   /**
    * Ensure GrChangeActionsInterface instance has access to gr-change-actions
    * element and retrieve if the interface was created before element.
    */
-  private ensureEl(): GrChangeActions {
+  private ensureEl(): GrChangeActionsElement {
     if (!this._el) {
       const sharedApiElement = (document.createElement(
         'gr-js-api-interface'
@@ -66,13 +125,13 @@
       this.setEl(
         (sharedApiElement.getElement(
           TargetElement.CHANGE_ACTIONS
-        ) as unknown) as GrChangeActions
+        ) as unknown) as GrChangeActionsElement
       );
     }
     return this._el!;
   }
 
-  addPrimaryActionKey(key: string) {
+  addPrimaryActionKey(key: PrimaryActionKey) {
     const el = this.ensureEl();
     if (el.primaryActionKeys.includes(key)) {
       return;
@@ -130,7 +189,7 @@
     this.ensureEl().setActionButtonProp(key, 'title', text);
   }
 
-  setEnabled(key: string, enabled: string) {
+  setEnabled(key: string, enabled: boolean) {
     this.ensureEl().setActionButtonProp(key, 'enabled', enabled);
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
index 9e9a0ea..37ac354 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
@@ -91,16 +91,16 @@
  */
 function flushPreinstalls() {
   const Gerrit = window.Gerrit;
-  if (Gerrit.flushPreinstalls) {
+  if (Gerrit?.flushPreinstalls) {
     Gerrit.flushPreinstalls();
   }
 }
 export const _testOnly_flushPreinstalls = flushPreinstalls;
 
 export function initGerritPluginApi() {
-  window.Gerrit = (window.Gerrit || {}) as GerritGlobal;
+  window.Gerrit = window.Gerrit || {};
   flushPreinstalls();
-  initGerritPluginsMethods(window.Gerrit);
+  initGerritPluginsMethods(window.Gerrit as GerritGlobal);
   // Preloaded plugins should be installed after Gerrit.install() is set,
   // since plugin preloader substitutes Gerrit.install() temporarily.
   // (Gerrit.install() is set in initGerritPluginsMethods)
@@ -108,8 +108,9 @@
 }
 
 export function _testOnly_initGerritPluginApi(): GerritGlobal {
+  window.Gerrit = window.Gerrit || {};
   initGerritPluginApi();
-  return window.Gerrit;
+  return window.Gerrit as GerritGlobal;
 }
 
 export function deprecatedDelete(
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
index 4fc6f9f..736fac9 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
@@ -20,9 +20,13 @@
 import {getPluginLoader} from './gr-plugin-loader';
 import {patchNumEquals} from '../../../utils/patch-set-util';
 import {customElement} from '@polymer/decorators';
-import {ChangeInfo, RevisionInfo} from '../../../types/common';
+import {
+  ChangeInfo,
+  LabelNameToValuesMap,
+  RevisionInfo,
+} from '../../../types/common';
 import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
-import {GrAdminApi} from '../../plugins/gr-admin-api/gr-admin-api';
+import {GrAdminApi, MenuLink} from '../../plugins/gr-admin-api/gr-admin-api';
 import {
   JsApiService,
   EventCallback,
@@ -31,6 +35,7 @@
 } from './gr-js-api-types';
 import {EventType, TargetElement} from '../../plugins/gr-plugin-types';
 import {DiffLayer, HighlightJS} from '../../../types/types';
+import {ParsedChangeInfo} from '../gr-rest-api-interface/gr-reviewer-updates-parser';
 
 const elements: {[key: string]: HTMLElement} = {};
 const eventCallbacks: {[key: string]: EventCallback[]} = {};
@@ -87,7 +92,7 @@
     eventCallbacks[eventName].push(callback);
   }
 
-  canSubmitChange(change: ChangeInfo, revision: RevisionInfo) {
+  canSubmitChange(change: ChangeInfo, revision?: RevisionInfo | null) {
     const submitCallbacks = this._getEventCallbacks(EventType.SUBMIT_CHANGE);
     const cancelSubmit = submitCallbacks.some(callback => {
       try {
@@ -173,7 +178,7 @@
     }
   }
 
-  handleCommitMessage(change: ChangeInfo, msg: string) {
+  handleCommitMessage(change: ChangeInfo | ParsedChangeInfo, msg: string) {
     for (const cb of this._getEventCallbacks(EventType.COMMIT_MSG_EDIT)) {
       try {
         cb(change, msg);
@@ -276,22 +281,22 @@
    * provider, the first one is returned. If no plugin offers a coverage provider,
    * will resolve to null.
    */
-  getCoverageAnnotationApi(): Promise<
-    GrAnnotationActionsInterface | undefined
-  > {
+  getCoverageAnnotationApis(): Promise<GrAnnotationActionsInterface[]> {
     return getPluginLoader()
       .awaitPluginsLoaded()
-      .then(
-        () =>
-          this._getEventCallbacks(EventType.ANNOTATE_DIFF).find(cb => {
-            const annotationApi = (cb as unknown) as GrAnnotationActionsInterface;
-            return annotationApi.getCoverageProvider();
-          }) as GrAnnotationActionsInterface | undefined
-      );
+      .then(() => {
+        const providers: GrAnnotationActionsInterface[] = [];
+        this._getEventCallbacks(EventType.ANNOTATE_DIFF).forEach(cb => {
+          const annotationApi = (cb as unknown) as GrAnnotationActionsInterface;
+          const provider = annotationApi.getCoverageProvider();
+          if (provider) providers.push(annotationApi);
+        });
+        return providers;
+      });
   }
 
-  getAdminMenuLinks() {
-    const links = [];
+  getAdminMenuLinks(): MenuLink[] {
+    const links: MenuLink[] = [];
     for (const cb of this._getEventCallbacks(EventType.ADMIN_MENU_LINKS)) {
       const adminApi = (cb as unknown) as GrAdminApi;
       links.push(...adminApi.getMenuLinks());
@@ -299,8 +304,8 @@
     return links;
   }
 
-  getLabelValuesPostRevert(change: ChangeInfo) {
-    let labels = {};
+  getLabelValuesPostRevert(change?: ChangeInfo): LabelNameToValuesMap {
+    let labels: LabelNameToValuesMap = {};
     for (const cb of this._getEventCallbacks(EventType.POST_REVERT)) {
       try {
         labels = cb(change);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
index 505e62e..6456370 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
@@ -18,6 +18,7 @@
 import {EventType, TargetElement} from '../../plugins/gr-plugin-types';
 import {DiffLayer} from '../../../types/types';
 import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
+import {MenuLink} from '../../plugins/gr-admin-api/gr-admin-api';
 
 export interface ShowChangeDetail {
   change: ChangeInfo;
@@ -49,6 +50,7 @@
   addElement(key: TargetElement, el: HTMLElement): void;
   getDiffLayers(path: string, changeNum: number): DiffLayer[];
   disposeDiffLayers(path: string): void;
-  getCoverageAnnotationApi(): Promise<GrAnnotationActionsInterface | undefined>;
+  getCoverageAnnotationApis(): Promise<GrAnnotationActionsInterface[]>;
+  getAdminMenuLinks(): MenuLink[];
   // TODO(TS): Add more methods when needed for the TS conversion.
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
index b3f4987..ffdf710 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
@@ -15,13 +15,9 @@
  * limitations under the License.
  */
 
-import {
-  RevisionInfo,
-  ChangeInfo,
-  RequestPayload,
-  ActionInfo,
-} from '../../../types/common';
+import {RevisionInfo, ChangeInfo, RequestPayload} from '../../../types/common';
 import {PluginApi} from '../../plugins/gr-plugin-types';
+import {UIActionInfo} from './gr-change-actions-js-api';
 
 interface GrPopupInterface {
   close(): void;
@@ -36,7 +32,7 @@
 
   constructor(
     public readonly plugin: PluginApi,
-    public readonly action: ActionInfo,
+    public readonly action: UIActionInfo,
     public readonly change: ChangeInfo,
     public readonly revision: RevisionInfo
   ) {}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
index 1334763..47b7be3 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
@@ -299,7 +299,7 @@
     const url = this._urlFor(pathOrUrl);
     const name = getPluginNameFromUrl(url);
     const Gerrit = window.Gerrit as GerritGlobal;
-    if (name && Gerrit._preloadedPlugins) {
+    if (name && Gerrit?._preloadedPlugins) {
       return hasOwnProperty(Gerrit._preloadedPlugins, name);
     } else {
       return false;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
index 043293f..a0a8c4d 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
@@ -34,7 +34,7 @@
 import {getPluginEndpoints} from './gr-plugin-endpoints';
 
 import {PRELOADED_PROTOCOL, getPluginNameFromUrl, send} from './gr-api-utils';
-import {GrReporintJsApi} from './gr-reporting-js-api';
+import {GrReportingJsApi} from './gr-reporting-js-api';
 import {
   EventType,
   HookApi,
@@ -45,7 +45,8 @@
 import {RequestPayload} from '../../../types/common';
 import {HttpMethod} from '../../../constants/constants';
 import {JsApiService} from './gr-js-api-types';
-import {GrChangeActions} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrChangeActions} from '../../change/gr-change-actions/gr-change-actions';
+import {GrChecksApi} from '../../plugins/gr-checks-api/gr-checks-api';
 
 /**
  * Plugin-provided custom components can affect content in extension
@@ -250,8 +251,12 @@
     return new GrChangeReplyInterface(this, this.sharedApiElement);
   }
 
+  checks(): GrChecksApi {
+    return new GrChecksApi(this);
+  }
+
   reporting() {
-    return new GrReporintJsApi(this);
+    return new GrReportingJsApi(this);
   }
 
   theme() {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
index 0bf6676..87b320c4 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
@@ -17,16 +17,12 @@
 
 import {appContext} from '../../../services/app-context';
 import {EventDetails} from '../../../services/gr-reporting/gr-reporting';
-
-// TODO(TS): remove once Plugin api converted to ts
-interface PluginApi {
-  getPluginName(): string;
-}
+import {PluginApi} from '../../plugins/gr-plugin-types';
 
 /**
  * Defines all methods that will be exported to plugin from reporting service.
  */
-export class GrReporintJsApi {
+export class GrReportingJsApi {
   private readonly reporting = appContext.reportingService;
 
   constructor(private readonly plugin: PluginApi) {}
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
index b6e664e..1dac371 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
@@ -33,13 +33,14 @@
   ChangeInfo,
   AccountInfo,
   LabelInfo,
-  DetailedLabelInfo,
-  QuickLabelInfo,
   ApprovalInfo,
   AccountId,
+  isQuickLabelInfo,
+  isDetailedLabelInfo,
 } from '../../../types/common';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {GrButton} from '../gr-button/gr-button';
+import {getVotingRangeOrDefault} from '../../../utils/label-util';
 
 export interface GrLabelInfo {
   $: {
@@ -66,11 +67,6 @@
   value: string;
 }
 
-// type guard to check if label is QuickLabelInfo
-function isQuickLabelInfo(label: LabelInfo): label is QuickLabelInfo {
-  return !(label as DetailedLabelInfo).values;
-}
-
 @customElement('gr-label-info')
 export class GrLabelInfo extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -101,13 +97,16 @@
    * This method also listens on change.labels.*,
    * to trigger computation when a label is removed from the change.
    */
-  _mapLabelInfo(labelInfo: LabelInfo, account: AccountInfo) {
+  _mapLabelInfo(labelInfo?: LabelInfo, account?: AccountInfo) {
     const result: FormattedLabel[] = [];
-    if (!labelInfo || !account) {
+    if (!labelInfo) {
       return result;
     }
-    if (isQuickLabelInfo(labelInfo)) {
-      if (labelInfo.rejected || labelInfo.approved) {
+    if (!isDetailedLabelInfo(labelInfo)) {
+      if (
+        isQuickLabelInfo(labelInfo) &&
+        (labelInfo.rejected || labelInfo.approved)
+      ) {
         const ok = labelInfo.approved || !labelInfo.rejected;
         return [
           {
@@ -125,23 +124,24 @@
     const votes = (labelInfo.all || []).sort(
       (a, b) => (a.value || 0) - (b.value || 0)
     );
-    const values = Object.keys(labelInfo.values || {});
+    const votingRange = getVotingRangeOrDefault(labelInfo);
     for (const label of votes) {
-      if (label.value && label.value !== labelInfo.default_value) {
+      if (
+        label.value &&
+        (!isQuickLabelInfo(labelInfo) ||
+          label.value !== labelInfo.default_value)
+      ) {
         let labelClassName;
         let labelValPrefix = '';
         if (label.value > 0) {
           labelValPrefix = '+';
-          if (
-            parseInt(`${label.value}`, 10) ===
-            parseInt(values[values.length - 1], 10)
-          ) {
+          if (label.value === votingRange.max) {
             labelClassName = LabelClassName.MAX;
           } else {
             labelClassName = LabelClassName.POSITIVE;
           }
         } else if (label.value < 0) {
-          if (parseInt(`${label.value}`, 10) === parseInt(values[0], 10)) {
+          if (label.value === votingRange.min) {
             labelClassName = LabelClassName.MIN;
           } else {
             labelClassName = LabelClassName.NEGATIVE;
@@ -152,7 +152,7 @@
           className: labelClassName,
           account: label,
         };
-        if (label._account_id === account._account_id) {
+        if (label._account_id === account?._account_id) {
           // Put self-votes at the top.
           result.unshift(formattedLabel);
         } else {
@@ -203,9 +203,8 @@
     }
 
     target.disabled = true;
-    const accountID = parseInt(
-      `${target.getAttribute('data-account-id')}`,
-      10
+    const accountID = Number(
+      `${target.getAttribute('data-account-id')}`
     ) as AccountId;
     this._xhrPromise = this.$.restAPI
       .deleteVote(this.change._number, accountID, this.label)
@@ -228,8 +227,8 @@
   _computeValueTooltip(labelInfo: LabelInfo, score: string) {
     if (
       !labelInfo ||
-      isQuickLabelInfo(labelInfo) ||
-      !labelInfo.values?.[score]
+      !isDetailedLabelInfo(labelInfo) ||
+      !labelInfo.values[score]
     ) {
       return '';
     }
@@ -240,19 +239,25 @@
    * This method also listens change.labels.* in
    * order to trigger computation when a label is removed from the change.
    */
-  _computeShowPlaceholder(labelInfo: LabelInfo) {
+  _computeShowPlaceholder(labelInfo?: LabelInfo) {
+    if (!labelInfo) {
+      return '';
+    }
     if (
-      labelInfo &&
+      !isDetailedLabelInfo(labelInfo) &&
       isQuickLabelInfo(labelInfo) &&
       (labelInfo.rejected || labelInfo.approved)
     ) {
       return 'hidden';
     }
 
-    // TODO(TS): might replace with hasOwnProperty instead
-    if (labelInfo && (labelInfo as DetailedLabelInfo).all) {
-      for (const label of (labelInfo as DetailedLabelInfo).all || []) {
-        if (label.value && label.value !== labelInfo.default_value) {
+    if (isDetailedLabelInfo(labelInfo) && labelInfo.all) {
+      for (const label of labelInfo.all) {
+        if (
+          label.value &&
+          (!isQuickLabelInfo(labelInfo) ||
+            label.value !== labelInfo.default_value)
+        ) {
           return 'hidden';
         }
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
index be7878b..3a2cc39 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
@@ -204,25 +204,32 @@
   });
 
   test('placeholder', () => {
+    const values = {
+      '0': 'No score',
+      '+1': 'good',
+      '+2': 'excellent',
+      '-1': 'bad',
+      '-2': 'terrible',
+    };
     element.labelInfo = {};
     assert.isFalse(isHidden(element.shadowRoot
         .querySelector('.placeholder')));
-    element.labelInfo = {all: []};
+    element.labelInfo = {all: [], values};
     assert.isFalse(isHidden(element.shadowRoot
         .querySelector('.placeholder')));
-    element.labelInfo = {all: [{value: 1}]};
+    element.labelInfo = {all: [{value: 1}], values};
     assert.isTrue(isHidden(element.shadowRoot
         .querySelector('.placeholder')));
     element.labelInfo = {rejected: []};
     assert.isTrue(isHidden(element.shadowRoot
         .querySelector('.placeholder')));
-    element.labelInfo = {values: [], rejected: [], all: [{value: 1}]};
+    element.labelInfo = {values: [], rejected: [], all: [{value: 1}, values]};
     assert.isTrue(isHidden(element.shadowRoot
         .querySelector('.placeholder')));
     element.labelInfo = {approved: []};
     assert.isTrue(isHidden(element.shadowRoot
         .querySelector('.placeholder')));
-    element.labelInfo = {values: [], approved: [], all: [{value: 1}]};
+    element.labelInfo = {values: [], approved: [], all: [{value: 1}, values]};
     assert.isTrue(isHidden(element.shadowRoot
         .querySelector('.placeholder')));
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
index 342e937..3403e87 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
@@ -24,7 +24,7 @@
 import {htmlTemplate} from './gr-list-view_html';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {page} from '../../../utils/page-wrapper-utils';
-import {property, observe, customElement} from '@polymer/decorators';
+import {property, customElement} from '@polymer/decorators';
 
 const REQUEST_DEBOUNCE_INTERVAL_MS = 200;
 
@@ -51,7 +51,7 @@
   @property({type: Number})
   itemsPerPage = 25;
 
-  @property({type: String})
+  @property({type: String, observer: '_filterChanged'})
   filter?: string;
 
   @property({type: Number})
@@ -69,8 +69,8 @@
     this.cancelDebouncer('reload');
   }
 
-  @observe('filter')
-  _filterChanged(newFilter: string, oldFilter: string) {
+  _filterChanged(newFilter?: string, oldFilter?: string) {
+    // newFilter can be empty string and then !newFilter === true
     if (!newFilter && !oldFilter) {
       return;
     }
@@ -78,7 +78,7 @@
     this._debounceReload(newFilter);
   }
 
-  _debounceReload(filter: string) {
+  _debounceReload(filter?: string) {
     this.debounce(
       'reload',
       () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
index 4ac868d..cb25b81 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
@@ -21,6 +21,8 @@
 import {htmlTemplate} from './gr-overlay_html';
 import {IronOverlayMixin} from '../../../mixins/iron-overlay-mixin/iron-overlay-mixin';
 import {customElement, property} from '@polymer/decorators';
+import {IronOverlayBehavior} from '@polymer/iron-overlay-behavior/iron-overlay-behavior';
+import {findActiveElement} from '../../../utils/dom-util';
 
 const AWAIT_MAX_ITERS = 10;
 const AWAIT_STEP = 5;
@@ -34,7 +36,8 @@
 
 @customElement('gr-overlay')
 export class GrOverlay extends IronOverlayMixin(
-  GestureEventListeners(LegacyElementMixin(PolymerElement))
+  GestureEventListeners(LegacyElementMixin(PolymerElement)),
+  IronOverlayBehavior as IronOverlayBehavior
 ) {
   static get template() {
     return htmlTemplate;
@@ -57,7 +60,9 @@
 
   private _boundHandleClose: () => void = () => super.close();
 
-  private focusableNodes: Node[] | undefined;
+  private focusableNodes?: Node[];
+
+  private returnFocusTo?: HTMLElement;
 
   get _focusableNodes() {
     if (this.focusableNodes) {
@@ -87,6 +92,7 @@
   }
 
   open() {
+    this.returnFocusTo = findActiveElement(document, true) ?? undefined;
     window.addEventListener('popstate', this._boundHandleClose);
     return new Promise((resolve, reject) => {
       super.open.apply(this);
@@ -119,6 +125,10 @@
       );
       this._fullScreenOpen = false;
     }
+    if (this.returnFocusTo) {
+      this.returnFocusTo.focus();
+      this.returnFocusTo = undefined;
+    }
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
index 5b9f360..ddcb0e2 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
@@ -72,9 +72,7 @@
   DashboardId,
   DashboardInfo,
   DeleteDraftCommentsInput,
-  DiffInfo,
   DiffPreferenceInput,
-  DiffPreferencesInfo,
   EditPatchSetNum,
   EditPreferencesInfo,
   EncodedGroupId,
@@ -134,8 +132,19 @@
   FilePathToDiffInfoMap,
   ChangeViewChangeInfo,
   BlameInfo,
+  ActionNameToActionInfoMap,
+  RevisionId,
+  GroupName,
+  Hashtag,
+  TopMenuEntryInfo,
+  MergeableInfo,
 } from '../../../types/common';
 import {
+  DiffInfo,
+  DiffPreferencesInfo,
+  IgnoreWhitespaceType,
+} from '../../../types/diff';
+import {
   CancelConditionCallback,
   ErrorCallback,
   RestApiService,
@@ -146,9 +155,9 @@
   CommentSide,
   DiffViewMode,
   HttpMethod,
-  IgnoreWhitespaceType,
   ReviewerState,
 } from '../../../constants/constants';
+import {firePageError} from '../../../utils/event-util';
 
 const JSON_PREFIX = ")]}'";
 const MAX_PROJECT_RESULTS = 25;
@@ -177,7 +186,7 @@
   reportEndpointAsIs?: boolean;
   endpoint: string;
   anonymizedEndpoint?: string;
-  patchNum?: PatchSetNum;
+  revision?: RevisionId;
   changeNum: NumericChangeId;
   errFn?: ErrorCallback;
   params?: FetchParams;
@@ -194,7 +203,7 @@
   endpoint: string;
   anonymizedEndpoint?: string;
   changeNum: NumericChangeId;
-  method: HttpMethod;
+  method: HttpMethod | undefined;
   errFn?: ErrorCallback;
   headers?: Record<string, string>;
   contentType?: string;
@@ -245,7 +254,6 @@
 
 interface GetDiffParams {
   [paramName: string]: string | undefined | null | number | boolean;
-  context?: number | 'ALL';
   intraline?: boolean | null;
   whitespace?: IgnoreWhitespaceType;
   parent?: number;
@@ -345,9 +353,7 @@
     );
   }
 
-  private _fetchSharedCacheURL(
-    req: FetchJSONRequest
-  ): Promise<ParsedJSON | undefined> {
+  _fetchSharedCacheURL(req: FetchJSONRequest): Promise<ParsedJSON | undefined> {
     // Cache is shared across instances
     return this._restApiHelper.fetchCacheURL(req);
   }
@@ -515,7 +521,7 @@
   }
 
   getGroupConfig(
-    group: GroupId,
+    group: GroupId | GroupName,
     errFn?: ErrorCallback
   ): Promise<GroupInfo | undefined> {
     return this._restApiHelper.fetchJSON({
@@ -649,7 +655,7 @@
     });
   }
 
-  getIsGroupOwner(groupName: GroupId): Promise<boolean> {
+  getIsGroupOwner(groupName: GroupName): Promise<boolean> {
     const encodeName = encodeURIComponent(groupName);
     const req = {
       url: `/groups/?owned&g=${encodeName}`,
@@ -661,7 +667,7 @@
   }
 
   getGroupMembers(
-    groupName: GroupId,
+    groupName: GroupId | GroupName,
     errFn?: ErrorCallback
   ): Promise<AccountInfo[] | undefined> {
     const encodeName = encodeURIComponent(groupName);
@@ -672,14 +678,16 @@
     }) as Promise<AccountInfo[] | undefined>;
   }
 
-  getIncludedGroup(groupName: GroupId): Promise<GroupInfo[] | undefined> {
+  getIncludedGroup(
+    groupName: GroupId | GroupName
+  ): Promise<GroupInfo[] | undefined> {
     return this._restApiHelper.fetchJSON({
       url: `/groups/${encodeURIComponent(groupName)}/groups/`,
       anonymizedUrl: '/groups/*/groups',
     }) as Promise<GroupInfo[] | undefined>;
   }
 
-  saveGroupName(groupId: GroupId, name: string): Promise<Response> {
+  saveGroupName(groupId: GroupId | GroupName, name: string): Promise<Response> {
     const encodeId = encodeURIComponent(groupId);
     return this._restApiHelper.send({
       method: HttpMethod.PUT,
@@ -689,7 +697,10 @@
     });
   }
 
-  saveGroupOwner(groupId: GroupId, ownerId: string): Promise<Response> {
+  saveGroupOwner(
+    groupId: GroupId | GroupName,
+    ownerId: string
+  ): Promise<Response> {
     const encodeId = encodeURIComponent(groupId);
     return this._restApiHelper.send({
       method: HttpMethod.PUT,
@@ -700,7 +711,7 @@
   }
 
   saveGroupDescription(
-    groupId: GroupId,
+    groupId: GroupId | GroupName,
     description: string
   ): Promise<Response> {
     const encodeId = encodeURIComponent(groupId);
@@ -713,7 +724,7 @@
   }
 
   saveGroupOptions(
-    groupId: GroupId,
+    groupId: GroupId | GroupName,
     options: GroupOptionsInput
   ): Promise<Response> {
     const encodeId = encodeURIComponent(groupId);
@@ -737,7 +748,7 @@
   }
 
   saveGroupMember(
-    groupName: GroupId,
+    groupName: GroupId | GroupName,
     groupMember: AccountId
   ): Promise<AccountInfo> {
     const encodeName = encodeURIComponent(groupName);
@@ -751,7 +762,7 @@
   }
 
   saveIncludedGroup(
-    groupName: GroupId,
+    groupName: GroupId | GroupName,
     includedGroup: GroupId,
     errFn?: ErrorCallback
   ): Promise<GroupInfo | undefined> {
@@ -774,7 +785,7 @@
   }
 
   deleteGroupMember(
-    groupName: GroupId,
+    groupName: GroupId | GroupName,
     groupMember: AccountId
   ): Promise<Response> {
     const encodeName = encodeURIComponent(groupName);
@@ -788,7 +799,7 @@
 
   deleteIncludedGroup(
     groupName: GroupId,
-    includedGroup: GroupId
+    includedGroup: GroupId | GroupName
   ): Promise<Response> {
     const encodeName = encodeURIComponent(groupName);
     const encodeIncludedGroup = encodeURIComponent(includedGroup);
@@ -819,7 +830,7 @@
         context: 10,
         cursor_blink_rate: 0,
         font_size: 12,
-        ignore_whitespace: IgnoreWhitespaceType.IGNORE_NONE,
+        ignore_whitespace: 'IGNORE_NONE',
         intraline_difference: true,
         line_length: 100,
         line_wrapping: false,
@@ -1192,11 +1203,11 @@
       );
   }
 
-  getDefaultPreferences() {
+  getDefaultPreferences(): Promise<PreferencesInfo | undefined> {
     return this._fetchSharedCacheURL({
       url: '/config/server/preferences',
       reportUrlAsIs: true,
-    });
+    }) as Promise<PreferencesInfo | undefined>;
   }
 
   getPreferences(): Promise<PreferencesInfo | undefined> {
@@ -1374,10 +1385,12 @@
 
   getChangeActionURL(
     changeNum: NumericChangeId,
-    patchNum: PatchSetNum | undefined,
+    revisionId: RevisionId | undefined,
     endpoint: string
   ): Promise<string> {
-    return this._changeBaseURL(changeNum, patchNum).then(url => url + endpoint);
+    return this._changeBaseURL(changeNum, revisionId).then(
+      url => url + endpoint
+    );
   }
 
   getChangeDetail(
@@ -1537,9 +1550,9 @@
     return this._getChangeURLAndFetch({
       changeNum,
       endpoint: '/commit?links',
-      patchNum,
+      revision: patchNum,
       reportEndpointAsIs: true,
-    });
+    }) as Promise<CommitInfo | undefined>;
   }
 
   getChangeFiles(
@@ -1555,7 +1568,7 @@
     return this._getChangeURLAndFetch({
       changeNum,
       endpoint: '/files',
-      patchNum: patchRange.patchNum,
+      revision: patchRange.patchNum,
       params,
       reportEndpointAsIs: true,
     }) as Promise<FileNameToFileInfoMap | undefined>;
@@ -1587,12 +1600,15 @@
     return this._getChangeURLAndFetch({
       changeNum,
       endpoint: `/files?q=${encodeURIComponent(query)}`,
-      patchNum,
+      revision: patchNum,
       anonymizedEndpoint: '/files?q=*',
     }) as Promise<string[] | undefined>;
   }
 
-  getChangeOrEditFiles(changeNum: NumericChangeId, patchRange: PatchRange) {
+  getChangeOrEditFiles(
+    changeNum: NumericChangeId,
+    patchRange: PatchRange
+  ): Promise<FileNameToFileInfoMap | undefined> {
     if (patchNumEquals(patchRange.patchNum, EditPatchSetNum)) {
       return this.getChangeEditFiles(changeNum, patchRange).then(
         res => res && res.files
@@ -1601,14 +1617,19 @@
     return this.getChangeFiles(changeNum, patchRange);
   }
 
-  getChangeRevisionActions(changeNum: NumericChangeId, patchNum: PatchSetNum) {
+  getChangeRevisionActions(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum
+  ): Promise<ActionNameToActionInfoMap | undefined> {
     const req: FetchChangeJSON = {
       changeNum,
       endpoint: '/actions',
-      patchNum,
+      revision: patchNum,
       reportEndpointAsIs: true,
     };
-    return this._getChangeURLAndFetch(req);
+    return this._getChangeURLAndFetch(req) as Promise<
+      ActionNameToActionInfoMap | undefined
+    >;
   }
 
   getChangeSuggestedReviewers(
@@ -1985,7 +2006,7 @@
     return this._getChangeURLAndFetch({
       changeNum,
       endpoint: '/related',
-      patchNum,
+      revision: patchNum,
       reportEndpointAsIs: true,
     }) as Promise<RelatedChangesInfo | undefined>;
   }
@@ -2070,13 +2091,16 @@
     }) as Promise<ChangeInfo[] | undefined>;
   }
 
-  getReviewedFiles(changeNum: NumericChangeId, patchNum: PatchSetNum) {
+  getReviewedFiles(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum
+  ): Promise<string[] | undefined> {
     return this._getChangeURLAndFetch({
       changeNum,
       endpoint: '/files?reviewed',
-      patchNum,
+      revision: patchNum,
       reportEndpointAsIs: true,
-    });
+    }) as Promise<string[] | undefined>;
   }
 
   saveFileReviewed(
@@ -2347,7 +2371,7 @@
   ): Promise<FilePathToDiffInfoMap | undefined> {
     return this._getChangeURLAndFetch({
       changeNum,
-      patchNum,
+      revision: patchNum,
       endpoint: `/fixes/${encodeURIComponent(fixId)}/preview`,
       reportEndpointAsId: true,
     }) as Promise<FilePathToDiffInfoMap | undefined>;
@@ -2409,7 +2433,10 @@
     });
   }
 
-  saveChangeStarred(changeNum: NumericChangeId, starred: boolean) {
+  saveChangeStarred(
+    changeNum: NumericChangeId,
+    starred: boolean
+  ): Promise<Response> {
     // Some servers may require the project name to be provided
     // alongside the change number, so resolve the project name
     // first.
@@ -2498,9 +2525,8 @@
     errFn?: ErrorCallback
   ) {
     const params: GetDiffParams = {
-      context: 'ALL',
       intraline: null,
-      whitespace: whitespace || IgnoreWhitespaceType.IGNORE_NONE,
+      whitespace: whitespace || 'IGNORE_NONE',
     };
     if (isMergeParent(basePatchNum)) {
       params.parent = getParentIndex(basePatchNum);
@@ -2512,7 +2538,7 @@
     const req: FetchChangeJSON = {
       changeNum,
       endpoint,
-      patchNum,
+      revision: patchNum,
       errFn,
       params,
       anonymizedEndpoint: '/files/*/diff',
@@ -2700,7 +2726,7 @@
         {
           changeNum,
           endpoint,
-          patchNum,
+          revision: patchNum,
           reportEndpointAsIs: true,
         },
         noAcceptHeader
@@ -2765,11 +2791,48 @@
   _getDiffCommentsFetchURL(
     changeNum: NumericChangeId,
     endpoint: string,
-    patchNum?: PatchSetNum
+    patchNum?: RevisionId
   ) {
     return this._changeBaseURL(changeNum, patchNum).then(url => url + endpoint);
   }
 
+  getPortedComments(
+    changeNum: NumericChangeId,
+    revision: RevisionId
+  ): Promise<PathToCommentsInfoMap | undefined> {
+    // maintaining a custom error function so that errors do not surface in UI
+    const errFn: ErrorCallback = (response?: Response | null) => {
+      if (response)
+        console.info(`Fetching ported comments failed, ${response.status}`);
+    };
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/ported_comments/',
+      revision,
+      errFn,
+    });
+  }
+
+  getPortedDrafts(
+    changeNum: NumericChangeId,
+    revision: RevisionId
+  ): Promise<PathToCommentsInfoMap | undefined> {
+    // maintaining a custom error function so that errors do not surface in UI
+    const errFn: ErrorCallback = (response?: Response | null) => {
+      if (response)
+        console.info(`Fetching ported drafts failed, ${response.status}`);
+    };
+    return this.getLoggedIn().then(loggedIn => {
+      if (!loggedIn) return {};
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint: '/ported_drafts/',
+        revision,
+        errFn,
+      });
+    });
+  }
+
   saveDiffDraft(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
@@ -2901,7 +2964,7 @@
 
   getB64FileContents(
     changeId: NumericChangeId,
-    patchNum: PatchSetNum,
+    patchNum: RevisionId,
     path: string,
     parentIndex?: number
   ) {
@@ -2974,7 +3037,7 @@
 
   _changeBaseURL(
     changeNum: NumericChangeId,
-    patchNum?: PatchSetNum,
+    revisionId?: RevisionId,
     project?: RepoName
   ): Promise<string> {
     // TODO(kaspern): For full slicer migration, app should warn with a call
@@ -2987,8 +3050,8 @@
       let url = `/changes/${encodeURIComponent(
         project as RepoName
       )}~${changeNum}`;
-      if (patchNum) {
-        url += `/revisions/${patchNum}`;
+      if (revisionId) {
+        url += `/revisions/${revisionId}`;
       }
       return url;
     });
@@ -3022,26 +3085,32 @@
     });
   }
 
-  setChangeTopic(changeNum: NumericChangeId, topic: string | null) {
-    return this._getChangeURLAndSend({
+  setChangeTopic(
+    changeNum: NumericChangeId,
+    topic: string | null
+  ): Promise<string> {
+    return (this._getChangeURLAndSend({
       changeNum,
       method: HttpMethod.PUT,
       endpoint: '/topic',
       body: {topic},
       parseResponse: true,
       reportUrlAsIs: true,
-    });
+    }) as unknown) as Promise<string>;
   }
 
-  setChangeHashtag(changeNum: NumericChangeId, hashtag: HashtagsInput) {
-    return this._getChangeURLAndSend({
+  setChangeHashtag(
+    changeNum: NumericChangeId,
+    hashtag: HashtagsInput
+  ): Promise<Hashtag[]> {
+    return (this._getChangeURLAndSend({
       changeNum,
       method: HttpMethod.POST,
       endpoint: '/hashtags',
       body: hashtag,
       parseResponse: true,
       reportUrlAsIs: true,
-    });
+    }) as unknown) as Promise<Hashtag[]>;
   }
 
   deleteAccountHttpPassword() {
@@ -3165,7 +3234,7 @@
     });
   }
 
-  confirmEmail(token: string) {
+  confirmEmail(token: string): Promise<string | null> {
     const req = {
       method: HttpMethod.PUT,
       url: '/config/server/email.confirm',
@@ -3190,25 +3259,29 @@
     }) as Promise<CapabilityInfoMap | undefined>;
   }
 
-  getTopMenus(errFn?: ErrorCallback) {
+  getTopMenus(errFn?: ErrorCallback): Promise<TopMenuEntryInfo[] | undefined> {
     return this._fetchSharedCacheURL({
       url: '/config/server/top-menus',
       errFn,
       reportUrlAsIs: true,
-    });
+    }) as Promise<TopMenuEntryInfo[] | undefined>;
   }
 
-  setAssignee(changeNum: NumericChangeId, assignee: AssigneeInput) {
+  setAssignee(
+    changeNum: NumericChangeId,
+    assignee: AccountId
+  ): Promise<Response> {
+    const body: AssigneeInput = {assignee};
     return this._getChangeURLAndSend({
       changeNum,
       method: HttpMethod.PUT,
       endpoint: '/assignee',
-      body: {assignee},
+      body,
       reportUrlAsIs: true,
     });
   }
 
-  deleteAssignee(changeNum: NumericChangeId) {
+  deleteAssignee(changeNum: NumericChangeId): Promise<Response> {
     return this._getChangeURLAndSend({
       changeNum,
       method: HttpMethod.DELETE,
@@ -3279,7 +3352,7 @@
    * Given a changeNum, gets the change.
    */
   getChange(
-    changeNum: NumericChangeId,
+    changeNum: ChangeId | NumericChangeId,
     errFn: ErrorCallback
   ): Promise<ChangeInfo | null> {
     // Cannot use _changeBaseURL, as this function is used by _projectLookup.
@@ -3324,16 +3397,8 @@
       return Promise.resolve(project);
     }
 
-    const onError = (response?: Response | null) => {
-      // Fire a page error so that the visual 404 is displayed.
-      this.dispatchEvent(
-        new CustomEvent('page-error', {
-          detail: {response},
-          composed: true,
-          bubbles: true,
-        })
-      );
-    };
+    const onError = (response?: Response | null) =>
+      firePageError(this, response);
 
     return this.getChange(changeNum, onError).then(change => {
       if (!change || !change.project) {
@@ -3395,10 +3460,10 @@
     const anonymizedEndpoint = req.reportEndpointAsIs
       ? req.endpoint
       : req.anonymizedEndpoint;
-    const anonymizedBaseUrl = req.patchNum
+    const anonymizedBaseUrl = req.revision
       ? ANONYMIZED_REVISION_BASE_URL
       : ANONYMIZED_CHANGE_BASE_URL;
-    return this._changeBaseURL(req.changeNum, req.patchNum).then(url =>
+    return this._changeBaseURL(req.changeNum, req.revision).then(url =>
       this._restApiHelper.fetchJSON(
         {
           url: url + req.endpoint,
@@ -3416,7 +3481,7 @@
 
   executeChangeAction(
     changeNum: NumericChangeId,
-    method: HttpMethod,
+    method: HttpMethod | undefined,
     endpoint: string,
     patchNum?: PatchSetNum,
     payload?: RequestPayload
@@ -3424,7 +3489,7 @@
 
   executeChangeAction(
     changeNum: NumericChangeId,
-    method: HttpMethod,
+    method: HttpMethod | undefined,
     endpoint: string,
     patchNum: PatchSetNum | undefined,
     payload: RequestPayload | undefined,
@@ -3436,7 +3501,7 @@
    */
   executeChangeAction(
     changeNum: NumericChangeId,
-    method: HttpMethod,
+    method: HttpMethod | undefined,
     endpoint: string,
     patchNum?: PatchSetNum,
     payload?: RequestPayload,
@@ -3468,7 +3533,7 @@
     return this._getChangeURLAndFetch({
       changeNum,
       endpoint: `/files/${encodedPath}/blame`,
-      patchNum,
+      revision: patchNum,
       params: base ? {base: 't'} : undefined,
       anonymizedEndpoint: '/files/*/blame',
     }) as Promise<BlameInfo[] | undefined>;
@@ -3546,14 +3611,15 @@
       changeNum,
       endpoint: '/revisions/current/mergeable',
       reportEndpointAsIs: true,
-    });
+    }) as Promise<MergeableInfo | undefined>;
   }
 
-  deleteDraftComments(query: DeleteDraftCommentsInput) {
+  deleteDraftComments(query: string): Promise<Response> {
+    const body: DeleteDraftCommentsInput = {query};
     return this._restApiHelper.send({
       method: HttpMethod.POST,
       url: '/accounts/self/drafts:delete',
-      body: {query},
+      body,
     });
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
index d575c88..d75c186 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
@@ -21,6 +21,7 @@
 import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser.js';
 import {ListChangesOption} from '../../../utils/change-util.js';
 import {appContext} from '../../../services/app-context.js';
+import {createChange} from '../../../test/test-data-generators.js';
 
 const basicFixture = fixtureFromElement('gr-rest-api-interface');
 
@@ -693,7 +694,7 @@
       assert.equal(fetchStub.lastCall.args[0].changeNum, '42');
       assert.equal(fetchStub.lastCall.args[0].endpoint,
           '/files?q=test%2Fpath.js');
-      assert.equal(fetchStub.lastCall.args[0].patchNum, 'edit');
+      assert.equal(fetchStub.lastCall.args[0].revision, 'edit');
     });
   });
 
@@ -1090,7 +1091,7 @@
     element._projectLookup = {1: 'test'};
     const fetchStub = sinon.stub(element._restApiHelper, 'fetchJSON')
         .returns(Promise.resolve());
-    const req = {changeNum: 1, endpoint: '/test', patchNum: 1};
+    const req = {changeNum: 1, endpoint: '/test', revision: 1};
     return element._getChangeURLAndFetch(req).then(() => {
       assert.equal(fetchStub.lastCall.args[0].url,
           '/changes/test~1/revisions/1/test');
@@ -1167,7 +1168,7 @@
       const range = {basePatchNum: 'PARENT', patchNum: 2};
       return element.getChangeFiles(123, range).then(() => {
         assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].patchNum, 2);
+        assert.equal(fetchStub.lastCall.args[0].revision, 2);
         assert.isNotOk(fetchStub.lastCall.args[0].params);
       });
     });
@@ -1178,7 +1179,7 @@
       const range = {basePatchNum: 4, patchNum: 5};
       return element.getChangeFiles(123, range).then(() => {
         assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+        assert.equal(fetchStub.lastCall.args[0].revision, 5);
         assert.isOk(fetchStub.lastCall.args[0].params);
         assert.equal(fetchStub.lastCall.args[0].params.base, 4);
         assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
@@ -1191,7 +1192,7 @@
       const range = {basePatchNum: -3, patchNum: 5};
       return element.getChangeFiles(123, range).then(() => {
         assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+        assert.equal(fetchStub.lastCall.args[0].revision, 5);
         assert.isOk(fetchStub.lastCall.args[0].params);
         assert.isNotOk(fetchStub.lastCall.args[0].params.base);
         assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
@@ -1205,7 +1206,7 @@
           .returns(Promise.resolve());
       return element.getDiff(123, 'PARENT', 2, 'foo/bar.baz').then(() => {
         assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].patchNum, 2);
+        assert.equal(fetchStub.lastCall.args[0].revision, 2);
         assert.isOk(fetchStub.lastCall.args[0].params);
         assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
         assert.isNotOk(fetchStub.lastCall.args[0].params.base);
@@ -1217,7 +1218,7 @@
           .returns(Promise.resolve());
       return element.getDiff(123, 4, 5, 'foo/bar.baz').then(() => {
         assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+        assert.equal(fetchStub.lastCall.args[0].revision, 5);
         assert.isOk(fetchStub.lastCall.args[0].params);
         assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
         assert.equal(fetchStub.lastCall.args[0].params.base, 4);
@@ -1229,7 +1230,7 @@
           .returns(Promise.resolve());
       return element.getDiff(123, -3, 5, 'foo/bar.baz').then(() => {
         assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+        assert.equal(fetchStub.lastCall.args[0].revision, 5);
         assert.isOk(fetchStub.lastCall.args[0].params);
         assert.isNotOk(fetchStub.lastCall.args[0].params.base);
         assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
@@ -1343,6 +1344,28 @@
     assert.isTrue(handler.calledOnce);
   });
 
+  test('ported comment errors do not trigger error dialog', () => {
+    const change = createChange();
+    const dispatchStub = sinon.stub(element._restApiHelper, 'dispatchEvent');
+    sinon.stub(element._restApiHelper, 'fetchJSON').returns(Promise.resolve({
+      ok: false}));
+
+    element.getPortedComments(change._number, 'current');
+
+    assert.isFalse(dispatchStub.called);
+  });
+
+  test('ported drafts are not requested user is not logged in', () => {
+    const change = createChange();
+    sinon.stub(element, 'getLoggedIn').returns(Promise.resolve(false));
+    const getChangeURLAndFetchStub = sinon.stub(element,
+        '_getChangeURLAndFetch');
+
+    element.getPortedDrafts(change._number, 'current');
+
+    assert.isFalse(getChangeURLAndFetchStub.called);
+  });
+
   test('saveChangeStarred', async () => {
     sinon.stub(element, 'getFromProjectLookup')
         .returns(Promise.resolve('test'));
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
index 6354aab..6d93604 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
@@ -32,6 +32,7 @@
   RequestPayload,
 } from '../../../../types/common';
 import {HttpMethod} from '../../../../constants/constants';
+import {RpcLogEventDetail} from '../../../../types/events';
 
 const JSON_PREFIX = ")]}'";
 
@@ -168,7 +169,7 @@
 };
 
 interface SendRequestBase {
-  method: HttpMethod;
+  method: HttpMethod | undefined;
   body?: RequestPayload;
   contentType?: string;
   headers?: Record<string, string>;
@@ -270,9 +271,15 @@
       ].join(' ')
     );
     if (req.anonymizedUrl) {
+      const detail: RpcLogEventDetail = {
+        status,
+        method,
+        elapsed,
+        anonymizedUrl: req.anonymizedUrl,
+      };
       this.dispatchEvent(
         new CustomEvent('rpc-log', {
-          detail: {status, method, elapsed, anonymizedUrl: req.anonymizedUrl},
+          detail,
           composed: true,
           bubbles: true,
         })
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts
index b5e5696..48a23c6 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts
@@ -22,7 +22,10 @@
   ChangeInfo,
   ChangeMessageInfo,
   ChangeViewChangeInfo,
+  CommitInfo,
+  PatchSetNum,
   ReviewerUpdateInfo,
+  RevisionInfo,
   Timestamp,
 } from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
@@ -78,8 +81,16 @@
   prev_state?: ReviewerState;
 }
 
+export interface EditRevisionInfo extends Partial<RevisionInfo> {
+  // EditRevisionInfo has less required properties then RevisionInfo
+  _number: PatchSetNum;
+  basePatchNum: PatchSetNum;
+  commit: CommitInfo;
+}
+
 export interface ParsedChangeInfo
-  extends Omit<ChangeViewChangeInfo, 'reviewer_updates'> {
+  extends Omit<ChangeViewChangeInfo, 'reviewer_updates' | 'revisions'> {
+  revisions: {[revisionId: string]: RevisionInfo | EditRevisionInfo};
   reviewer_updates?: ReviewerUpdateInfo[] | FormattedReviewerUpdateInfo[];
 }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index 6bf0776..b0b40dd 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -38,32 +38,23 @@
   {value: '😊', match: 'smile :)'},
   {value: '👍', match: 'thumbs up'},
   {value: '😄', match: 'laugh :D'},
-  {value: '🎉', match: 'party'},
-  {value: '😞', match: 'sad :('},
+  {value: '❤️', match: 'heart <3'},
   {value: '😂', match: "tears :')"},
-  {value: '🙏', match: 'pray'},
+  {value: '🎉', match: 'party'},
+  {value: '😎', match: 'cool |;)'},
+  {value: '😞', match: 'sad :('},
   {value: '😐', match: 'neutral :|'},
   {value: '😮', match: 'shock :O'},
-  {value: '👎', match: 'thumbs down'},
-  {value: '😎', match: 'cool |;)'},
+  {value: '🙏', match: 'pray'},
   {value: '😕', match: 'confused'},
   {value: '👌', match: 'ok'},
   {value: '🔥', match: 'fire'},
-  {value: '👊', match: 'fistbump'},
   {value: '💯', match: '100'},
-  {value: '💔', match: 'broken heart'},
-  {value: '🍺', match: 'beer'},
   {value: '✔', match: 'check'},
   {value: '😋', match: 'tongue'},
   {value: '😭', match: "crying :'("},
-  {value: '🐨', match: 'koala'},
   {value: '🤓', match: 'glasses'},
-  {value: '😆', match: 'grin'},
-  {value: '💩', match: 'poop'},
   {value: '😢', match: 'tear'},
-  {value: '😒', match: 'unamused'},
-  {value: '😉', match: 'wink ;)'},
-  {value: '🍷', match: 'wine'},
   {value: '😜', match: 'winking tongue ;)'},
 ];
 
@@ -288,7 +279,7 @@
 
   _getFontSize() {
     const fontSizePx = getComputedStyle(this).fontSize || '12px';
-    return parseInt(fontSizePx.substr(0, fontSizePx.length - 2), 10);
+    return Number(fontSizePx.substr(0, fontSizePx.length - 2));
   }
 
   _getScrollTop() {
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.ts
index 1f777aa..d55481b 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.ts
@@ -31,7 +31,8 @@
     :host(.code) {
       font-family: var(--monospace-font-family);
       font-size: var(--font-size-code);
-      line-height: var(--line-height-code);
+      /* usually 16px = 12px + 4px */
+      line-height: calc(var(--font-size-code) + var(--spacing-s));
       font-weight: var(--font-weight-normal);
     }
     #emojiSuggestions {
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts b/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
index 180fb2e..fadbfa7 100644
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
@@ -17,6 +17,7 @@
 
 import {patchNumEquals} from '../../../utils/patch-set-util';
 import {ChangeInfo, PatchSetNum} from '../../../types/common';
+import {ParsedChangeInfo} from '../gr-rest-api-interface/gr-reviewer-updates-parser';
 
 type RevNumberToParentCountMap = {[revNumber: number]: number};
 
@@ -26,7 +27,7 @@
    * @param change A change object resulting from a change detail
    *     call that includes revision information.
    */
-  constructor(private change: ChangeInfo) {}
+  constructor(private change: ChangeInfo | ParsedChangeInfo) {}
 
   /**
    * Get the largest number of parents of the commit in any revision. For
diff --git a/polygerrit-ui/app/embed/gr-diff.js b/polygerrit-ui/app/embed/gr-diff.ts
similarity index 70%
rename from polygerrit-ui/app/embed/gr-diff.js
rename to polygerrit-ui/app/embed/gr-diff.ts
index adcf7fd..405d22e 100644
--- a/polygerrit-ui/app/embed/gr-diff.js
+++ b/polygerrit-ui/app/embed/gr-diff.ts
@@ -15,23 +15,21 @@
  * limitations under the License.
  */
 
-window.Gerrit = window.Gerrit || {};
-// We need to use goog.declareModuleId internally in google for TS-imports-JS
-// case. To avoid errors when goog is not available, the empty implementation is
-// added.
-window.goog = window.goog || {declareModuleId(name) {}};
 // TODO(dmfilippov): remove bundled-polymer.js imports when the following issue
 // https://github.com/Polymer/polymer-resin/issues/9 is resolved.
 // Because gr-diff.js is a shared component, it shouldn' pollute global
 // variables. If an application wants to use Polymer global variable -
 // the app must assign/import it and do not rely on the Polymer variable
 // exposed by shared gr-diff component.
-import '../scripts/bundled-polymer.js';
-import '../elements/diff/gr-diff/gr-diff.js';
-import '../elements/diff/gr-diff-cursor/gr-diff-cursor.js';
-import {initDiffAppContext} from './gr-diff-app-context-init.js';
-import {GrDiffLine, GrDiffLineType} from '../elements/diff/gr-diff/gr-diff-line.js';
-import {GrAnnotation} from '../elements/diff/gr-diff-highlight/gr-annotation.js';
+import '../scripts/bundled-polymer';
+import '../elements/diff/gr-diff/gr-diff';
+import '../elements/diff/gr-diff-cursor/gr-diff-cursor';
+import {initDiffAppContext} from './gr-diff-app-context-init';
+import {
+  GrDiffLine,
+  GrDiffLineType,
+} from '../elements/diff/gr-diff/gr-diff-line';
+import {GrAnnotation} from '../elements/diff/gr-diff-highlight/gr-annotation';
 
 // Setup appContext for diff.
 // TODO (dmfilippov): find a better solution
diff --git a/polygerrit-ui/app/gr-diff/gr-diff-root.js b/polygerrit-ui/app/gr-diff/gr-diff-root.ts
similarity index 87%
rename from polygerrit-ui/app/gr-diff/gr-diff-root.js
rename to polygerrit-ui/app/gr-diff/gr-diff-root.ts
index bb5d602..fbe81fb 100644
--- a/polygerrit-ui/app/gr-diff/gr-diff-root.js
+++ b/polygerrit-ui/app/gr-diff/gr-diff-root.ts
@@ -15,5 +15,4 @@
  * limitations under the License.
  */
 
-window.Gerrit = window.Gerrit || {};
-import '../elements/diff/gr-diff/gr-diff.js';
+import '../elements/diff/gr-diff/gr-diff';
diff --git a/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts b/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
index cfbad9d..48a4848 100644
--- a/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
+++ b/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
@@ -22,8 +22,19 @@
 
 // The mixinBehaviors clears all type information about superClass.
 // As a workaround, we define IronFitMixin with correct type.
+// Due to the following issues:
+// https://github.com/microsoft/TypeScript/issues/15870
+// https://github.com/microsoft/TypeScript/issues/9944
+// we have to import IronFitBehavior in the same file where IronFitMixin
+// is used. To ensure that this import can't be avoided, the second parameter
+// is added. Usage example:
+// class Element extends IronFitMixin(PolymerElement, IronFitBehavior as IronFitBehavior)
+// The code 'IronFitBehavior as IronFitBehavior' required, becuase IronFitBehavior
+// defined as an object, not as IronFitBehavior instance.
+
 export const IronFitMixin = <T extends Constructor<PolymerElement>>(
-  superClass: T
+  superClass: T,
+  _: IronFitBehavior
 ): T & Constructor<IronFitBehavior> =>
   // TODO(TS): mixinBehaviors in some lib is returning: `new () => T` instead
   // which will fail the type check due to missing IronFitBehavior interface
diff --git a/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts b/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts
index daed2b8..4884ec2 100644
--- a/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts
+++ b/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts
@@ -22,9 +22,20 @@
 
 // The mixinBehaviors clears all type information about superClass.
 // As a workaround, we define IronOverlayMixin with correct type.
+// Due to the following issues:
+// https://github.com/microsoft/TypeScript/issues/15870
+// https://github.com/microsoft/TypeScript/issues/9944
+// we have to import IronOverlayBehavior in the same file where IronOverlayMixin
+// is used. To ensure that this import can't be avoided, the second parameter
+// is added. Usage example:
+// class Element extends IronOverlayMixin(PolymerElement, IronOverlayBehavior as IronOverlayBehavior)
+// The code 'IronOverlayBehavior as IronOverlayBehavior' required, because
+// IronOverlayBehavior defined as an object, not as IronOverlayBehavior instance.
 export const IronOverlayMixin = <T extends Constructor<PolymerElement>>(
-  superClass: T
+  superClass: T,
+  _: IronOverlayBehavior
 ): T & Constructor<IronOverlayBehavior> =>
-  // TODO(TS): mixinBehaviors in some lib is returning: `new () => T` instead
-  // which will fail the type check due to missing IronOverlayBehavior interface
+  // TODO(TS): mixinBehaviors in some lib is returning: `new () => T`
+  // instead which will fail the type check due to missing
+  // IronOverlayBehavior interface
   mixinBehaviors([IronOverlayBehavior], superClass) as any;
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
index 80df630..9744bc9 100644
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
@@ -103,6 +103,10 @@
 import {property} from '@polymer/decorators';
 import {PolymerElement} from '@polymer/polymer';
 import {Constructor} from '../../utils/common-util';
+import {
+  CustomKeyboardEvent,
+  ShortcutTriggeredEventDetail,
+} from '../../types/events';
 
 /** Enum for all special shortcuts */
 export enum SPECIAL_SHORTCUT {
@@ -125,7 +129,7 @@
 export enum ShortcutSection {
   ACTIONS = 'Actions',
   DIFFS = 'Diffs',
-  EVERYWHERE = 'Everywhere',
+  EVERYWHERE = 'Global Shortcuts',
   FILE_LIST = 'File list',
   NAVIGATION = 'Navigation',
   REPLY_DIALOG = 'Reply dialog',
@@ -196,6 +200,7 @@
   TOGGLE_ALL_INLINE_DIFFS = 'TOGGLE_ALL_INLINE_DIFFS',
   TOGGLE_INLINE_DIFF = 'TOGGLE_INLINE_DIFF',
   TOGGLE_HIDE_ALL_COMMENT_THREADS = 'TOGGLE_HIDE_ALL_COMMENT_THREADS',
+  OPEN_FILE_LIST = 'OPEN_FILE_LIST',
 
   OPEN_FIRST_FILE = 'OPEN_FIRST_FILE',
   OPEN_LAST_FILE = 'OPEN_LAST_FILE',
@@ -528,23 +533,6 @@
 // Must be declared outside behavior implementation to be accessed inside
 // behavior functions.
 
-/**
- * Keyboard events emitted from polymer elements.
- */
-export interface CustomKeyboardEvent extends CustomEvent, EventApi {
-  event: CustomKeyboardEvent;
-  detail: {
-    keyboardEvent?: CustomKeyboardEvent;
-    // TODO(TS): maybe should mark as optional and check before accessing
-    key: string;
-  };
-  readonly altKey: boolean;
-  readonly changedTouches: TouchList;
-  readonly ctrlKey: boolean;
-  readonly metaKey: boolean;
-  readonly shiftKey: boolean;
-  readonly keyCode: number;
-}
 function getKeyboardEvent(e: CustomKeyboardEvent): CustomKeyboardEvent {
   const event = dom(e.detail ? e.detail.keyboardEvent : e);
   // TODO(TS): worth checking if this still holds or not, if no, remove this.
@@ -563,6 +551,14 @@
 
   private readonly bindings = new Map<Shortcut, string[]>();
 
+  public _testOnly_getBindings() {
+    return this.bindings;
+  }
+
+  public _testOnly_isEmpty() {
+    return this.activeHosts.size === 0 && this.listeners.size === 0;
+  }
+
   private readonly listeners = new Set<ShortcutListener>();
 
   bindShortcut(shortcut: Shortcut, ...bindings: string[]) {
@@ -851,14 +847,14 @@
             return true;
           }
         }
-
+        const detail: ShortcutTriggeredEventDetail = {
+          event: e,
+          goKey: this._inGoKeyMode(),
+          vKey: this.inVKeyMode(),
+        };
         this.dispatchEvent(
           new CustomEvent('shortcut-triggered', {
-            detail: {
-              event: e,
-              goKey: this._inGoKeyMode(),
-              vKey: this.inVKeyMode(),
-            },
+            detail,
             composed: true,
             bubbles: true,
           })
@@ -993,7 +989,7 @@
       }
 
       private inVKeyMode() {
-        return (
+        return !!(
           this._shortcut_v_key_last_pressed &&
           Date.now() - this._shortcut_v_key_last_pressed <= V_KEY_TIMEOUT_MS
         );
@@ -1029,7 +1025,7 @@
       }
 
       _inGoKeyMode() {
-        return (
+        return !!(
           this._shortcut_go_key_last_pressed &&
           Date.now() - this._shortcut_go_key_last_pressed <= GO_KEY_TIMEOUT_MS
         );
@@ -1082,7 +1078,7 @@
   _shortcut_v_key_last_pressed: number | null;
   _shortcut_go_table: Map<string, string>;
   _shortcut_v_table: Map<string, string>;
-  keyboardShortcuts(): {[key: string]: string};
+  keyboardShortcuts(): {[key: string]: string | null};
   createTitle(name: Shortcut, section: ShortcutSection): string;
   bindShortcut(shortcut: Shortcut, ...bindings: string[]): void;
   shouldSuppressKeyboardShortcut(event: CustomKeyboardEvent): boolean;
@@ -1091,6 +1087,8 @@
   getKeyboardEvent(e: CustomKeyboardEvent): CustomKeyboardEvent;
   addKeyboardShortcutDirectoryListener(listener: ShortcutListener): void;
   removeKeyboardShortcutDirectoryListener(listener: ShortcutListener): void;
+  // TODO(TS): Remove underscore. Apparently not a private method.
+  _throttleWrap(eventListener: EventListener): EventListener;
 }
 
 export function _testOnly_getShortcutManagerInstance() {
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index 3315e50b..0e2307b 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -35,6 +35,11 @@
     name: "BSD-3-Clause",
     allowed: true
   };
+
+  public static BsdZeroClause: LicenseType = {
+    name: "BSD-Zero-Clause",
+    allowed: true
+  };
 }
 
 /** List of licenses texts. Add the licenses here if there is no text file with license
@@ -287,7 +292,44 @@
   {
     name: "polymer-bridges",
     license: SharedLicenses.Polymer2018
-  }
+  },
+  {
+    name: "rxjs",
+    license: {
+      name: "rxjs",
+      type: LicenseTypes.Apache2_0,
+      packageLicenseFile: "LICENSE.txt"
+    },
+    // The following directories are not real packages, but contains package.json
+    nonPackages: [
+      "ajax", "fetch", "internal-compatibility", "operators", "testing",
+      "webSocket", "src/ajax", "src/fetch", "src/internal-compatibility",
+      "src/operators", "src/testing", "src/webSocket"],
+  },
+  {
+    name: "lit-element",
+    license: {
+      name: "lit-element",
+      type: LicenseTypes.Bsd3,
+      packageLicenseFile: "LICENSE"
+    },
+  },
+  {
+    name: "lit-html",
+    license: {
+      name: "lit-html",
+      type: LicenseTypes.Bsd3,
+      packageLicenseFile: "LICENSE"
+    },
+  },
+  {
+    name: "tslib",
+    license: {
+      name: "tslib",
+      type: LicenseTypes.BsdZeroClause,
+      packageLicenseFile: "LICENSE.txt"
+    },
+  },
 ];
 
 export default packages;
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index a67bf4a..fad72ef 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -28,9 +28,11 @@
     "@webcomponents/shadycss": "^1.9.2",
     "@webcomponents/webcomponentsjs": "^1.3.3",
     "ba-linkify": "file:../../lib/ba-linkify/src/",
+    "lit-element": "^2.4.0",
     "page": "^1.11.5",
     "polymer-bridges": "file:../../polymer-bridges/",
-    "polymer-resin": "^2.0.1"
+    "polymer-resin": "^2.0.1",
+    "rxjs": "^6.6.2"
   },
   "license": "Apache-2.0",
   "private": true
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
index 8d9be62..feb1a82 100644
--- a/polygerrit-ui/app/rules.bzl
+++ b/polygerrit-ui/app/rules.bzl
@@ -34,7 +34,7 @@
         result.append(_get_ts_compiled_path(outdir, f))
     return result
 
-def compile_ts(name, srcs, ts_outdir):
+def compile_ts(name, srcs, ts_outdir, include_tests = False):
     """Compiles srcs files with the typescript compiler
 
     Args:
@@ -50,16 +50,31 @@
     # List of files produced by the typescript compiler
     generated_js = _get_ts_output_files(ts_outdir, srcs)
 
+    all_srcs = srcs + [
+        ":tsconfig.json",
+        ":tsconfig_bazel.json",
+        "@ui_npm//:node_modules",
+    ]
+    ts_project = "tsconfig_bazel.json"
+
+    if include_tests:
+        all_srcs = all_srcs + [
+            ":tsconfig_bazel_test.json",
+            "@ui_dev_npm//:node_modules",
+        ]
+        ts_project = "tsconfig_bazel_test.json"
+
     # Run the compiler
     native.genrule(
         name = ts_rule_name,
-        srcs = srcs + [
-            ":tsconfig.json",
-            "@ui_npm//:node_modules",
-        ],
+        srcs = all_srcs,
         outs = generated_js,
         cmd = " && ".join([
-            "$(location //tools/node_tools:tsc-bin) --project $(location :tsconfig.json) --outdir $(RULEDIR)/" + ts_outdir + " --baseUrl ./external/ui_npm/node_modules",
+            "$(location //tools/node_tools:tsc-bin) --project $(location :" +
+            ts_project +
+            ") --outdir $(RULEDIR)/" +
+            ts_outdir +
+            " --baseUrl ./external/ui_npm/node_modules/",
         ]),
         tools = ["//tools/node_tools:tsc-bin"],
     )
diff --git a/polygerrit-ui/app/scripts/bundled-polymer.ts b/polygerrit-ui/app/scripts/bundled-polymer.ts
new file mode 100644
index 0000000..a52cc6b
--- /dev/null
+++ b/polygerrit-ui/app/scripts/bundled-polymer.ts
@@ -0,0 +1,33 @@
+/**
+ * @license
+ * Copyright (C) 2020 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.
+ */
+
+// This file is a replacement for the
+// polymer-bridges/polymer/polymer.html file. The polymer.html file loads
+// other scripts to setup different global variables. Because plugins
+// expects that Polymer is available we must setup all Polymer global
+// variables
+//
+// The bundled-polymer.js imports all scripts in the same order as the
+// polymer.html does and must be imported in all es6-modules instead
+// of the polymer.html file.
+
+import './js/bundled-polymer-bridges';
+
+import {importHref} from './import-href';
+
+window.Polymer = window.Polymer || {};
+window.Polymer.importHref = importHref;
diff --git a/polygerrit-ui/app/gr-diff/gr-diff-root.js b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.d.ts
similarity index 82%
copy from polygerrit-ui/app/gr-diff/gr-diff-root.js
copy to polygerrit-ui/app/scripts/js/bundled-polymer-bridges.d.ts
index bb5d602..7041300 100644
--- a/polygerrit-ui/app/gr-diff/gr-diff-root.js
+++ b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.d.ts
@@ -15,5 +15,5 @@
  * limitations under the License.
  */
 
-window.Gerrit = window.Gerrit || {};
-import '../elements/diff/gr-diff/gr-diff.js';
+// We can't convert bundled-polymer.js to ts. To allow import
+// bundled-polymer.js from .ts files we should add this .d.ts file
diff --git a/polygerrit-ui/app/scripts/bundled-polymer.js b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js
similarity index 96%
rename from polygerrit-ui/app/scripts/bundled-polymer.js
rename to polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js
index 6fef454..d04b533 100644
--- a/polygerrit-ui/app/scripts/bundled-polymer.js
+++ b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js
@@ -15,6 +15,9 @@
  * limitations under the License.
  */
 
+// This file can't be converted to TS - it imports some .js file which
+// can't be imported into typescript
+
 // This file is a replacement for the
 // polymer-bridges/polymer/polymer.html file. The polymer.html file loads
 // other scripts to setup different global variables. Because plugins
@@ -69,6 +72,4 @@
 
 // This is needed due to the Polymer.IronFocusablesHelper in gr-overlay.ts
 import 'polymer-bridges/iron-overlay-behavior/iron-focusables-helper_bridge.js';
-import {importHref} from './import-href.js';
 
-window.Polymer.importHref = importHref;
diff --git a/polygerrit-ui/app/scripts/polymer-resin-install.ts b/polygerrit-ui/app/scripts/polymer-resin-install.ts
new file mode 100644
index 0000000..ee03171
--- /dev/null
+++ b/polygerrit-ui/app/scripts/polymer-resin-install.ts
@@ -0,0 +1,72 @@
+/**
+ * @license
+ * Copyright (C) 2020 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.
+ */
+
+import 'polymer-resin/standalone/polymer-resin';
+
+export type SafeTypeBridge = (
+  value: unknown,
+  type: string,
+  fallback: unknown
+) => unknown;
+
+export type ReportHandler = (
+  isDisallowedValue: boolean,
+  printfFormatString: string,
+  ...printfArgs: unknown[]
+) => void;
+
+declare global {
+  interface Window {
+    security: {
+      polymer_resin: {
+        SafeType: {
+          CONSTANT: string;
+          HTML: string;
+          JAVASCRIPT: string;
+          RESOURCE_URL: string;
+          /** Unprivileged but possibly wrapped string. */
+          STRING: string;
+          STYLE: string;
+          URL: string;
+        };
+        CONSOLE_LOGGING_REPORT_HANDLER: ReportHandler;
+        install(options: {
+          UNSAFE_passThruDisallowedValues?: boolean;
+          allowedIdentifierPrefixes?: string[];
+          reportHandler?: ReportHandler;
+          safeTypesBridge?: SafeTypeBridge;
+        }): void;
+      };
+    };
+  }
+}
+
+const security = window.security;
+
+export const _testOnly_defaultResinReportHandler =
+  security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER;
+
+export function installPolymerResin(
+  safeTypesBridge: SafeTypeBridge,
+  reportHandler = security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER
+) {
+  window.security.polymer_resin.install({
+    allowedIdentifierPrefixes: [''],
+    reportHandler,
+    safeTypesBridge,
+  });
+}
diff --git a/polygerrit-ui/app/scripts/util.ts b/polygerrit-ui/app/scripts/util.ts
index 59ade33..bf7120f 100644
--- a/polygerrit-ui/app/scripts/util.ts
+++ b/polygerrit-ui/app/scripts/util.ts
@@ -49,8 +49,7 @@
     // True if the promise is either resolved or reject (possibly cancelled)
     let isDone = false;
 
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    let rejectPromise: (reason?: any) => void;
+    let rejectPromise: (reason?: unknown) => void;
 
     const wrappedPromise: CancelablePromise<T> = new Promise(
       (resolve, reject) => {
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 047e9e0..ba33954 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -25,6 +25,7 @@
  */
 export enum KnownExperimentId {
   PATCHSET_COMMENTS = 'UiFeature__patchset_comments',
-  PATCHSET_CHOICE_FOR_COMMENT_LINKS = 'UiFeature__patchset_choice_for_comment_links',
   NEW_CONTEXT_CONTROLS = 'UiFeature__new_context_controls',
+  CI_REBOOT_CHECKS = 'UiFeature__ci_reboot_checks',
+  NEW_CHANGE_SUMMARY_UI = 'UiFeature__new_change_summary_ui',
 }
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
index b5330e7..8fe7c35 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
@@ -201,7 +201,7 @@
       return false;
     }
 
-    const expiration = new Date(parseInt(token.expires_at, 10) * 1000);
+    const expiration = new Date(Number(token.expires_at) * 1000);
     if (Date.now() >= expiration.getTime()) {
       return false;
     }
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 743e0f4..e6139e5 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -20,6 +20,10 @@
 // TODO(dmfilippov): TS-fix-any use more specific type instead if possible
 export type EventDetails = any;
 
+export const PORTING_COMMENTS_DIFF_LATENCY_LABEL = 'PortingCommentsDiffLatency';
+export const PORTING_COMMENTS_CHANGE_LATENCY_LABEL =
+  'PortingCommentsChangeLatency';
+
 export interface Timer {
   reset(): this;
   end(): this;
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index 29cdd1e..f3aacdf 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -127,8 +127,8 @@
       oldOnError(msg, url, line, column, error);
     }
     if (error) {
-      line = line || (error as any).lineNumber;
-      column = column || (error as any).columnNumber;
+      line = line || error.lineNumber;
+      column = column || error.columnNumber;
       let shortenedErrorStack = msg;
       if (error.stack) {
         const errorStackLines = error.stack.split('\n');
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
index 1ef2483..924ddd9 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -14,7 +14,23 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-export const grReportingMock = {
+import {ReportingService, Timer} from './gr-reporting';
+
+export class MockTimer implements Timer {
+  end(): this {
+    return this;
+  }
+
+  reset(): this {
+    return this;
+  }
+
+  withMaximum(_: number): this {
+    return this;
+  }
+}
+
+export const grReportingMock: ReportingService = {
   appStarted: () => {},
   beforeLocationChanged: () => {},
   changeDisplayed: () => {},
@@ -25,7 +41,7 @@
   diffViewFullyLoaded: () => {},
   fileListDisplayed: () => {},
   getTimer: () => {
-    return {end: () => {}};
+    return new MockTimer();
   },
   locationChanged: () => {},
   onVisibilityChange: () => {},
diff --git a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
index 10715cf..19a8dc5 100644
--- a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 
+import {HttpMethod} from '../../../constants/constants';
 import {
   AccountDetailInfo,
   AccountExternalIdInfo,
@@ -22,7 +23,6 @@
   NumericChangeId,
   ServerInfo,
   ProjectInfo,
-  ActionInfo,
   AccountCapabilityInfo,
   SuggestedReviewerInfo,
   GroupNameToGroupInfoMap,
@@ -30,7 +30,6 @@
   PatchSetNum,
   RequestPayload,
   PreferencesInput,
-  DiffPreferencesInfo,
   EditPreferencesInfo,
   DiffPreferenceInput,
   SshKeyInfo,
@@ -87,13 +86,26 @@
   EmailAddress,
   FixId,
   FilePathToDiffInfoMap,
-  DiffInfo,
   BlameInfo,
   PatchRange,
   ImagesForDiff,
+  ActionNameToActionInfoMap,
+  RevisionId,
+  GroupName,
+  DashboardId,
+  HashtagsInput,
+  Hashtag,
+  FileNameToFileInfoMap,
+  TopMenuEntryInfo,
+  MergeableInfo,
+  CommitInfo,
 } from '../../../types/common';
+import {
+  DiffInfo,
+  DiffPreferencesInfo,
+  IgnoreWhitespaceType,
+} from '../../../types/diff';
 import {ParsedChangeInfo} from '../../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
-import {HttpMethod, IgnoreWhitespaceType} from '../../../constants/constants';
 
 export type ErrorCallback = (response?: Response | null, err?: Error) => void;
 export type CancelConditionCallback = () => boolean;
@@ -121,27 +133,6 @@
   REVISION = 1,
 }
 
-// TODO(TS) remove interface when GrChangeActions is converted to typescript
-export interface GrChangeActions extends Element {
-  RevisionActions?: Record<string, string>;
-  ChangeActions: Record<string, string>;
-  ActionType: Record<string, string>;
-  primaryActionKeys: string[];
-  push(propName: 'primaryActionKeys', value: string): void;
-  hideQuickApproveAction(): void;
-  setActionOverflow(type: ActionType, key: string, overflow: boolean): void;
-  setActionPriority(
-    type: ActionType,
-    key: string,
-    overflow: ActionPriority
-  ): void;
-  setActionHidden(type: ActionType, key: string, hidden: boolean): void;
-  addActionButton(type: ActionType, label: string): string;
-  removeActionButton(key: string): void;
-  setActionButtonProp(key: string, prop: string, value: string): void;
-  getActionDetails(actionName: string): ActionInfo;
-}
-
 export interface GetDiffCommentsOutput {
   baseComments: CommentInfo[];
   comments: CommentInfo[];
@@ -214,7 +205,7 @@
   ): Promise<GroupNameToGroupInfoMap | undefined>;
   executeChangeAction(
     changeNum: NumericChangeId,
-    method: HttpMethod,
+    method: HttpMethod | undefined,
     endpoint: string,
     patchNum?: PatchSetNum,
     payload?: RequestPayload,
@@ -230,10 +221,15 @@
 
   getChangeDetail(
     changeNum: number | string,
-    opt_errFn?: Function,
+    opt_errFn?: ErrorCallback,
     opt_cancelCondition?: Function
   ): Promise<ParsedChangeInfo | null | undefined>;
 
+  getChange(
+    changeNum: ChangeId | NumericChangeId,
+    errFn: ErrorCallback
+  ): Promise<ChangeInfo | null>;
+
   savePreferences(prefs: PreferencesInput): Promise<Response>;
 
   getDiffPreferences(): Promise<DiffPreferencesInfo | undefined>;
@@ -376,17 +372,23 @@
   ): Promise<GroupNameToGroupInfoMap | undefined>;
 
   getGroupConfig(
-    group: GroupId,
+    group: GroupId | GroupName,
     errFn?: ErrorCallback
   ): Promise<GroupInfo | undefined>;
 
   getIsAdmin(): Promise<boolean | undefined>;
 
-  getIsGroupOwner(groupName: GroupId): Promise<boolean>;
+  getIsGroupOwner(groupName: GroupName): Promise<boolean>;
 
-  saveGroupName(groupId: GroupId, name: string): Promise<Response>;
+  saveGroupName(
+    groupId: GroupId | GroupName,
+    name: GroupName
+  ): Promise<Response>;
 
-  saveGroupOwner(groupId: GroupId, ownerId: string): Promise<Response>;
+  saveGroupOwner(
+    groupId: GroupId | GroupName,
+    ownerId: string
+  ): Promise<Response>;
 
   saveGroupDescription(
     groupId: GroupId,
@@ -399,19 +401,19 @@
   ): Promise<Response>;
 
   saveChangeReview(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
+    changeNum: ChangeId | NumericChangeId,
+    patchNum: RevisionId,
     review: ReviewInput
   ): Promise<Response>;
   saveChangeReview(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
+    changeNum: ChangeId | NumericChangeId,
+    patchNum: RevisionId,
     review: ReviewInput,
     errFn: ErrorCallback
   ): Promise<Response | undefined>;
   saveChangeReview(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
+    changeNum: ChangeId | NumericChangeId,
+    patchNum: RevisionId,
     review: ReviewInput,
     errFn?: ErrorCallback
   ): Promise<Response>;
@@ -421,6 +423,12 @@
     downloadCommands?: boolean
   ): Promise<false | EditInfo | undefined>;
 
+  getChangeActionURL(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum | undefined,
+    endpoint: string
+  ): Promise<string>;
+
   createChange(
     project: RepoName,
     branch: BranchName,
@@ -452,6 +460,16 @@
     cancelCondition?: CancelConditionCallback
   ): Promise<ChangeInfo | undefined | null>;
 
+  getPortedComments(
+    changeNum: NumericChangeId,
+    revision: RevisionId
+  ): Promise<PathToCommentsInfoMap | undefined>;
+
+  getPortedDrafts(
+    changeNum: NumericChangeId,
+    revision: RevisionId
+  ): Promise<PathToCommentsInfoMap | undefined>;
+
   getDiffComments(
     changeNum: NumericChangeId
   ): Promise<PathToCommentsInfoMap | undefined>;
@@ -646,30 +664,32 @@
   ): Promise<GroupAuditEventInfo[] | undefined>;
 
   getGroupMembers(
-    groupName: GroupId,
+    groupName: GroupId | GroupName,
     errFn?: ErrorCallback
   ): Promise<AccountInfo[] | undefined>;
 
-  getIncludedGroup(groupName: GroupId): Promise<GroupInfo[] | undefined>;
+  getIncludedGroup(
+    groupName: GroupId | GroupName
+  ): Promise<GroupInfo[] | undefined>;
 
   saveGroupMember(
-    groupName: GroupId,
+    groupName: GroupId | GroupName,
     groupMember: AccountId
   ): Promise<AccountInfo>;
 
   saveIncludedGroup(
-    groupName: GroupId,
+    groupName: GroupId | GroupName,
     includedGroup: GroupId,
     errFn?: ErrorCallback
   ): Promise<GroupInfo | undefined>;
 
   deleteGroupMember(
-    groupName: GroupId,
+    groupName: GroupId | GroupName,
     groupMember: AccountId
   ): Promise<Response>;
 
   deleteIncludedGroup(
-    groupName: GroupId,
+    groupName: GroupId | GroupName,
     includedGroup: GroupId
   ): Promise<Response>;
 
@@ -761,4 +781,100 @@
     diff: DiffInfo,
     patchRange: PatchRange
   ): Promise<ImagesForDiff>;
+
+  getChangeRevisionActions(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum
+  ): Promise<ActionNameToActionInfoMap | undefined>;
+
+  confirmEmail(token: string): Promise<string | null>;
+
+  getDefaultPreferences(): Promise<PreferencesInfo | undefined>;
+
+  addAccountEmail(email: string): Promise<Response>;
+
+  addAccountEmail(
+    email: string,
+    errFn?: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  saveChangeReviewed(
+    changeNum: NumericChangeId,
+    reviewed: boolean
+  ): Promise<Response | undefined>;
+
+  saveChangeStarred(
+    changeNum: NumericChangeId,
+    starred: boolean
+  ): Promise<Response>;
+
+  getDashboard(
+    project: RepoName,
+    dashboard: DashboardId,
+    errFn?: ErrorCallback
+  ): Promise<DashboardInfo | undefined>;
+
+  deleteDraftComments(query: string): Promise<Response>;
+
+  setAssignee(
+    changeNum: NumericChangeId,
+    assignee: AccountId
+  ): Promise<Response>;
+
+  deleteAssignee(changeNum: NumericChangeId): Promise<Response>;
+
+  setChangeHashtag(
+    changeNum: NumericChangeId,
+    hashtag: HashtagsInput
+  ): Promise<Hashtag[]>;
+
+  setChangeTopic(
+    changeNum: NumericChangeId,
+    topic: string | null
+  ): Promise<string>;
+
+  getChangeFiles(
+    changeNum: NumericChangeId,
+    patchRange: PatchRange
+  ): Promise<FileNameToFileInfoMap | undefined>;
+
+  getChangeOrEditFiles(
+    changeNum: NumericChangeId,
+    patchRange: PatchRange
+  ): Promise<FileNameToFileInfoMap | undefined>;
+
+  getReviewedFiles(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum
+  ): Promise<string[] | undefined>;
+
+  saveFileReviewed(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    path: string,
+    reviewed: boolean
+  ): Promise<Response>;
+
+  saveFileReviewed(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    path: string,
+    reviewed: boolean,
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  getTopMenus(errFn?: ErrorCallback): Promise<TopMenuEntryInfo[] | undefined>;
+
+  setInProjectLookup(changeNum: NumericChangeId, project: RepoName): void;
+  getMergeable(changeNum: NumericChangeId): Promise<MergeableInfo | undefined>;
+
+  putChangeCommitMessage(
+    changeNum: NumericChangeId,
+    message: string
+  ): Promise<Response>;
+
+  getChangeCommitInfo(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum
+  ): Promise<CommitInfo | undefined>;
 }
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.ts b/polygerrit-ui/app/styles/gr-change-list-styles.ts
index 1dbc917..6de4e6f 100644
--- a/polygerrit-ui/app/styles/gr-change-list-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.ts
@@ -32,6 +32,13 @@
       gr-change-list-item:focus {
         background-color: var(--selection-background-color);
       }
+      gr-change-list-item[highlight] {
+        background-color: var(--assignee-highlight-color);
+      }
+      gr-change-list-item[highlight][selected],
+      gr-change-list-item[highlight]:focus {
+        background-color: var(--assignee-highlight-selection-color);
+      }
       .groupTitle td,
       .cell {
         vertical-align: middle;
@@ -84,6 +91,8 @@
       .owner,
       .assignee,
       .updated,
+      .submitted,
+      .waiting,
       .size,
       .status,
       .repo {
@@ -163,6 +172,8 @@
         .repo,
         .branch,
         .updated,
+        .submitted,
+        .waiting,
         .label,
         .assignee,
         .groupHeader .star,
diff --git a/polygerrit-ui/app/styles/shared-styles.ts b/polygerrit-ui/app/styles/shared-styles.ts
index fbc62e2..695ae24 100644
--- a/polygerrit-ui/app/styles/shared-styles.ts
+++ b/polygerrit-ui/app/styles/shared-styles.ts
@@ -177,6 +177,19 @@
         font-weight: var(--font-weight-bold);
       }
 
+      .assistive-tech-only {
+        user-select: none;
+        clip: rect(1px, 1px, 1px, 1px);
+        height: 1px;
+        margin: 0;
+        overflow: hidden;
+        padding: 0;
+        position: absolute;
+        white-space: nowrap;
+        width: 1px;
+        z-index: -1000;
+      }
+
       /** BEGIN: loading spiner */
       .loadingSpin {
         border: 2px solid var(--disabled-button-background-color);
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 835ef0a..d7b96c8 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -18,12 +18,16 @@
 // Mark the file as a module. Otherwise typescript assumes this is a script
 // and $_documentContainer is a global variable.
 // See: https://www.typescriptlang.org/docs/handbook/modules.html
-export {};
+import {
+  createStyle,
+  safeStyleSheet,
+  setInnerHtml,
+} from '../../utils/inner-html-util';
 
-const $_documentContainer = document.createElement('template');
+const customStyle = document.createElement('custom-style');
+customStyle.setAttribute('id', 'light-theme');
 
-$_documentContainer.innerHTML = `
-<custom-style id="light-theme"><style is="custom-style">
+const styleSheet = safeStyleSheet`
   html {
     /**
      * When adding a new color variable make sure to also add it to the other
@@ -71,6 +75,9 @@
     --view-background-color: var(--background-color-primary);
     /* unique background colors */
     --assignee-highlight-color: #fcfad6;
+    /* TODO: Find a nicer way to combine the --assignee-highlight-color and the
+       --selection-background-color than to just invent another unique color. */
+    --assignee-highlight-selection-color: #f6f4d0;
     --chip-selected-background-color: #e8f0fe;
     --edit-mode-background-color: #ebf5fb;
     --emphasis-color: #fff9c4;
@@ -115,7 +122,6 @@
     --font-size-h3: 1.143rem;   /* 16px */
     --font-size-h2: 1.429rem;   /* 20px */
     --font-size-h1: 1.714rem;   /* 24px */
-    --line-height-code: 1.334;      /* 16px */
     --line-height-mono: 1.286rem;   /* 18px */
     --line-height-small: 1.143rem;  /* 16px */
     --line-height-normal: 1.429rem; /* 20px */
@@ -127,6 +133,7 @@
     --font-weight-h1: 400;
     --font-weight-h2: 400;
     --font-weight-h3: 400;
+    --context-control-button-font: var(--font-weight-normal) var(--font-size-normal) var(--font-family);
 
     /* spacing */
     --spacing-xxs: 1px;
@@ -229,7 +236,8 @@
       --spacing-xl: 12px;
       --spacing-xxl: 16px;
     }
-  }
-</style></custom-style>`;
+  }`;
 
-document.head.appendChild($_documentContainer.content);
+setInnerHtml(customStyle, createStyle(styleSheet));
+
+document.head.appendChild(customStyle);
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index 3032984..4d3e6d8 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -15,10 +15,17 @@
  * limitations under the License.
  */
 
+import {
+  createStyle,
+  safeStyleSheet,
+  setInnerHtml,
+} from '../../utils/inner-html-util';
+
 function getStyleEl() {
-  const $_documentContainer = document.createElement('template');
-  $_documentContainer.innerHTML = `
-  <custom-style id="dark-theme"><style is="custom-style">
+  const customStyle = document.createElement('custom-style');
+  customStyle.setAttribute('id', 'dark-theme');
+
+  const styleSheet = safeStyleSheet`
     html {
       /**
        * Sections and variables must stay consistent with app-theme.js.
@@ -55,6 +62,7 @@
       /*   empty, because inheriting from app-theme is just fine
       /* unique background colors */
       --assignee-highlight-color: #3a361c;
+      --assignee-highlight-selection-color: #423e24;
       --chip-selected-background-color: #3c4455;
       --edit-mode-background-color: #5c0a36;
       --emphasis-color: #383f4a;
@@ -156,16 +164,17 @@
       /* paper and iron component overrides */
       --iron-overlay-backdrop-background-color: white;
 
-      /* rules applied to <html> */
+      /* rules applied to html */
       background-color: var(--view-background-color);
     }
-  </style></custom-style>`;
+  `;
 
-  return $_documentContainer;
+  setInnerHtml(customStyle, createStyle(styleSheet));
+  return customStyle;
 }
 
 export function applyTheme() {
-  document.head.appendChild(getStyleEl().content);
+  document.head.appendChild(getStyleEl());
 }
 
 export function removeTheme() {
diff --git a/polygerrit-ui/app/gr-diff/gr-diff-root.js b/polygerrit-ui/app/styles/themes/dark-theme_test.js
similarity index 61%
copy from polygerrit-ui/app/gr-diff/gr-diff-root.js
copy to polygerrit-ui/app/styles/themes/dark-theme_test.js
index bb5d602..4f6466f 100644
--- a/polygerrit-ui/app/gr-diff/gr-diff-root.js
+++ b/polygerrit-ui/app/styles/themes/dark-theme_test.js
@@ -15,5 +15,14 @@
  * limitations under the License.
  */
 
-window.Gerrit = window.Gerrit || {};
-import '../elements/diff/gr-diff/gr-diff.js';
+import '../../test/common-test-setup-karma.js';
+import {applyTheme, removeTheme} from './dark-theme.js';
+
+suite('dark-theme_test.js', () => {
+  test('apply and remove theme', () => {
+    applyTheme();
+    assert.equal(document.head.querySelectorAll('#dark-theme').length, 1);
+    removeTheme();
+    assert.equal(document.head.querySelectorAll('#dark-theme').length, 0);
+  });
+});
diff --git a/polygerrit-ui/app/test/@types/sinon-esm.d.ts b/polygerrit-ui/app/test/@types/sinon-esm.d.ts
new file mode 100644
index 0000000..9074a7a
--- /dev/null
+++ b/polygerrit-ui/app/test/@types/sinon-esm.d.ts
@@ -0,0 +1,27 @@
+/**
+ * @license
+ * Copyright (C) 2020 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.
+ */
+
+declare module 'sinon/pkg/sinon-esm' {
+  // sinon-esm doesn't have it's own d.ts, reexport all types from sinon
+  // This is a trick - @types/sinon adds interfaces and sinon instance
+  // to a global variables/namespace. We reexport it here, so we
+  // can use in our code when importing sinon-esm
+  // eslint-disable-next-line import/no-default-export
+  export default sinon;
+  const sinon: Sinon.SinonStatic;
+  export {SinonSpy, SinonFakeTimers, SinonStubbedMember};
+}
diff --git a/polygerrit-ui/app/test/common-test-setup-karma.js b/polygerrit-ui/app/test/common-test-setup-karma.ts
similarity index 68%
rename from polygerrit-ui/app/test/common-test-setup-karma.js
rename to polygerrit-ui/app/test/common-test-setup-karma.ts
index 2335f28..3d07d8a 100644
--- a/polygerrit-ui/app/test/common-test-setup-karma.js
+++ b/polygerrit-ui/app/test/common-test-setup-karma.ts
@@ -14,14 +14,23 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import './common-test-setup.js';
-import '@polymer/test-fixture/test-fixture.js';
-import 'chai/chai.js';
-self.assert = window.chai.assert;
-self.expect = window.chai.expect;
+import './common-test-setup';
+import '@polymer/test-fixture/test-fixture';
+import 'chai/chai';
+
+declare global {
+  interface Window {
+    flush: typeof flushImpl;
+    fixtureFromTemplate: typeof fixtureFromTemplateImpl;
+    fixtureFromElement: typeof fixtureFromElementImpl;
+  }
+  let flush: typeof flushImpl;
+  let fixtureFromTemplate: typeof fixtureFromTemplateImpl;
+  let fixtureFromElement: typeof fixtureFromElementImpl;
+}
 
 // Workaround for https://github.com/karma-runner/karma-mocha/issues/227
-let unhandledError = null;
+let unhandledError: ErrorEvent;
 
 window.addEventListener('error', e => {
   // For uncaught error mochajs doesn't print the full stack trace.
@@ -31,7 +40,7 @@
   unhandledError = e;
 });
 
-let originalOnBeforeUnload;
+let originalOnBeforeUnload: typeof window.onbeforeunload;
 
 suiteSetup(() => {
   // This suiteSetup() method is called only once before all tests
@@ -39,7 +48,7 @@
   // Can't use window.addEventListener("beforeunload",...) here,
   // the handler is raised too late.
   originalOnBeforeUnload = window.onbeforeunload;
-  window.onbeforeunload = e => {
+  window.onbeforeunload = function (e: BeforeUnloadEvent) {
     // If a test reloads a page, we can't prevent it.
     // However we can print earror and the stack trace with assert.fail
     try {
@@ -48,7 +57,9 @@
       console.error('Page reloading attempt detected.');
       console.error(e.stack.toString());
     }
-    originalOnBeforeUnload(e);
+    if (originalOnBeforeUnload) {
+      originalOnBeforeUnload.call(this, e);
+    }
   };
 });
 
@@ -64,18 +75,21 @@
 // Keep the original one for use in test utils methods.
 const nativeSetTimeout = window.setTimeout;
 
+function flushImpl(): Promise<void>;
+function flushImpl(callback: () => void): void;
 /**
  * Triggers a flush of any pending events, observations, etc and calls you back
  * after they have been processed if callback is passed; otherwise returns
  * promise.
- *
- * @param {function()} callback
  */
-function flush(callback) {
+function flushImpl(callback?: () => void): Promise<void> | void {
   // Ideally, this function would be a call to Polymer.dom.flush, but that
   // doesn't support a callback yet
   // (https://github.com/Polymer/polymer-dev/issues/851)
-  window.Polymer.dom.flush();
+  // The type is used only in one place, disable eslint warning instead of
+  // creating an interface
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  (window as any).Polymer.dom.flush();
   if (callback) {
     nativeSetTimeout(callback, 0);
   } else {
@@ -85,19 +99,12 @@
   }
 }
 
-self.flush = flush;
+self.flush = flushImpl;
 
 class TestFixtureIdProvider {
-  static get instance() {
-    if (!TestFixtureIdProvider._instance) {
-      TestFixtureIdProvider._instance = new TestFixtureIdProvider();
-    }
-    return TestFixtureIdProvider._instance;
-  }
+  public static readonly instance: TestFixtureIdProvider = new TestFixtureIdProvider();
 
-  constructor() {
-    this.fixturesCount = 1;
-  }
+  private fixturesCount = 1;
 
   generateNewFixtureId() {
     this.fixturesCount++;
@@ -105,22 +112,24 @@
   }
 }
 
+interface TagTestFixture<T extends Element> {
+  instantiate(model?: unknown): T;
+}
+
 class TestFixture {
-  constructor(fixtureId) {
-    this.fixtureId = fixtureId;
-  }
+  constructor(private readonly fixtureId: string) {}
 
   /**
    * Create an instance of a fixture's template.
    *
-   * @param {Object} model - see Data-bound sections at
+   * @param model - see Data-bound sections at
    *   https://www.webcomponents.org/element/@polymer/test-fixture
-   * @return {HTMLElement | HTMLElement[]} - if the fixture's template contains
+   * @return - if the fixture's template contains
    *   a single element, returns the appropriated instantiated element.
    *   Otherwise, it return an array of all instantiated elements from the
    *   template.
    */
-  instantiate(model) {
+  instantiate(model?: unknown): HTMLElement | HTMLElement[] {
     // The window.fixture method is defined in common-test-setup.js
     return window.fixture(this.fixtureId, model);
   }
@@ -153,10 +162,9 @@
  *   });
  * }
  *
- * @param {HTMLTemplateElement} template - a template for a fixture
- * @return {TestFixture} - the instance of TestFixture class
+ * @param template - a template for a fixture
  */
-function fixtureFromTemplate(template) {
+function fixtureFromTemplateImpl(template: HTMLTemplateElement): TestFixture {
   const fixtureId = TestFixtureIdProvider.instance.generateNewFixtureId();
   const testFixture = document.createElement('test-fixture');
   testFixture.setAttribute('id', fixtureId);
@@ -183,14 +191,17 @@
  *   });
  * }
  *
- * @param {HTMLTemplateElement} template - a template for a fixture
- * @return {TestFixture} - the instance of TestFixture class
+ * @param tagName - a template for a fixture is <tagName></tagName>
  */
-function fixtureFromElement(tagName) {
+function fixtureFromElementImpl<T extends keyof HTMLElementTagNameMap>(
+  tagName: T
+): TagTestFixture<HTMLElementTagNameMap[T]> {
   const template = document.createElement('template');
   template.innerHTML = `<${tagName}></${tagName}>`;
-  return fixtureFromTemplate(template);
+  return (fixtureFromTemplate(template) as unknown) as TagTestFixture<
+    HTMLElementTagNameMap[T]
+  >;
 }
 
-window.fixtureFromTemplate = fixtureFromTemplate;
-window.fixtureFromElement = fixtureFromElement;
+window.fixtureFromTemplate = fixtureFromTemplateImpl;
+window.fixtureFromElement = fixtureFromElementImpl;
diff --git a/polygerrit-ui/app/test/common-test-setup.js b/polygerrit-ui/app/test/common-test-setup.js
deleted file mode 100644
index 500187a..0000000
--- a/polygerrit-ui/app/test/common-test-setup.js
+++ /dev/null
@@ -1,153 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-// TODO(dmfilippov): remove bundled-polymer.js imports when the following issue
-// https://github.com/Polymer/polymer-resin/issues/9 is resolved.
-import '../scripts/bundled-polymer.js';
-import 'polymer-resin/standalone/polymer-resin.js';
-import '@polymer/iron-test-helpers/iron-test-helpers.js';
-import './test-router.js';
-import {_testOnlyInitAppContext} from './test-app-context-init';
-import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnlyResetRestApi} from '../elements/shared/gr-js-api-interface/gr-plugin-rest-api.js';
-import {_testOnlyResetGrRestApiSharedObjects} from '../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {cleanupTestUtils, TestKeyboardShortcutBinder} from './test-utils.js';
-import {flushDebouncers} from '@polymer/polymer/lib/utils/debounce';
-import {_testOnly_getShortcutManagerInstance} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import sinon from 'sinon/pkg/sinon-esm.js';
-import {safeTypesBridge} from '../utils/safe-types-util.js';
-import {_testOnly_initGerritPluginApi} from '../elements/shared/gr-js-api-interface/gr-gerrit.js';
-import {initGlobalVariables} from '../elements/gr-app-global-var-init.js';
-window.sinon = sinon;
-
-security.polymer_resin.install({
-  allowedIdentifierPrefixes: [''],
-  reportHandler(isViolation, fmt, ...args) {
-    const log = security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER;
-    log(isViolation, fmt, ...args);
-    if (isViolation) {
-      // This will cause the test to fail if there is a data binding
-      // violation.
-      throw new Error(
-          'polymer-resin violation: ' + fmt +
-        JSON.stringify(args));
-    }
-  },
-  safeTypesBridge,
-});
-
-const cleanups = [];
-
-// For karma always set our implementation
-// (karma doesn't provide the fixture method)
-window.fixture = function(fixtureId, model) {
-  // This method is inspired by web-component-tester method
-  cleanups.push(() => document.getElementById(fixtureId).restore());
-  return document.getElementById(fixtureId).create(model);
-};
-
-setup(() => {
-  window.Gerrit = {};
-  initGlobalVariables();
-
-  // If the following asserts fails - then window.stub is
-  // overwritten by some other code.
-  assert.equal(cleanups.length, 0);
-  // The following calls is nessecary to avoid influence of previously executed
-  // tests.
-  TestKeyboardShortcutBinder.push();
-  _testOnlyInitAppContext();
-  _testOnly_initGerritPluginApi();
-  const mgr = _testOnly_getShortcutManagerInstance();
-  assert.equal(mgr.activeHosts.size, 0);
-  assert.equal(mgr.listeners.size, 0);
-  document.getSelection().removeAllRanges();
-  const pl = _testOnly_resetPluginLoader();
-  // For testing, always init with empty plugin list
-  // Since when serve in gr-app, we always retrieve the list
-  // from project config and init loading after that, all
-  // `awaitPluginsLoaded` will rely on that to kick off,
-  // in testing, we want to kick start this earlier.
-  // You still can manually call _testOnly_resetPluginLoader
-  // to reset this behavior if you need to test something specific.
-  pl.loadPlugins([]);
-  _testOnlyResetGrRestApiSharedObjects();
-  _testOnlyResetRestApi();
-});
-
-// For karma always set our implementation
-// (karma doesn't provide the stub method)
-window.stub = function(tagName, implementation) {
-  // This method is inspired by web-component-tester method
-  const proto = document.createElement(tagName).constructor.prototype;
-  const stubs = Object.keys(implementation)
-      .map(key => sinon.stub(proto, key).callsFake(implementation[key]));
-  cleanups.push(() => {
-    stubs.forEach(stub => {
-      stub.restore();
-    });
-  });
-};
-
-// Very simple function to catch unexpected elements in documents body.
-// It can't catch everything, but in most cases it is enough.
-function checkChildAllowed(element) {
-  const allowedTags = ['SCRIPT', 'IRON-A11Y-ANNOUNCER'];
-  if (allowedTags.includes(element.tagName)) {
-    return;
-  }
-  if (element.tagName === 'TEST-FIXTURE') {
-    if (element.children.length == 0 ||
-        (element.children.length == 1 &&
-        element.children[0].tagName === 'TEMPLATE')) {
-      return;
-    }
-    assert.fail(`Test fixture
-        ${element.outerHTML}` +
-        `isn't resotred after the test is finished. Please ensure that ` +
-        `restore() method is called for this test-fixture. Usually the call` +
-        `happens automatically.`);
-    return;
-  }
-  if (element.tagName === 'DIV' && element.id === 'gr-hovercard-container' &&
-      element.childNodes.length === 0) {
-    return;
-  }
-  assert.fail(
-      `The following node remains in document after the test:
-      ${element.tagName}
-      Outer HTML:
-      ${element.outerHTML},
-      Stack trace:
-      ${element.stackTrace}`);
-}
-function checkGlobalSpace() {
-  for (const child of document.body.children) {
-    checkChildAllowed(child);
-  }
-}
-
-teardown(() => {
-  sinon.restore();
-  cleanupTestUtils();
-  cleanups.forEach(cleanup => cleanup());
-  cleanups.splice(0);
-  TestKeyboardShortcutBinder.pop();
-  checkGlobalSpace();
-  // Clean Polymer debouncer queue, so next tests will not be affected.
-  flushDebouncers();
-});
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
new file mode 100644
index 0000000..71c45f7
--- /dev/null
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -0,0 +1,201 @@
+/**
+ * @license
+ * 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.
+ */
+// This should be the first import to install handler before any other code
+import './source-map-support-install';
+// TODO(dmfilippov): remove bundled-polymer.js imports when the following issue
+// https://github.com/Polymer/polymer-resin/issues/9 is resolved.
+import '../scripts/bundled-polymer';
+import '@polymer/iron-test-helpers/iron-test-helpers';
+import './test-router';
+import {_testOnlyInitAppContext} from './test-app-context-init';
+import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
+import {_testOnlyResetRestApi} from '../elements/shared/gr-js-api-interface/gr-plugin-rest-api';
+import {_testOnlyResetGrRestApiSharedObjects} from '../elements/shared/gr-rest-api-interface/gr-rest-api-interface';
+import {
+  cleanupTestUtils,
+  getCleanupsCount,
+  registerTestCleanup,
+  TestKeyboardShortcutBinder,
+} from './test-utils';
+import {flushDebouncers} from '@polymer/polymer/lib/utils/debounce';
+import {_testOnly_getShortcutManagerInstance} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import sinon, {SinonSpy} from 'sinon/pkg/sinon-esm';
+import {safeTypesBridge} from '../utils/safe-types-util';
+import {_testOnly_initGerritPluginApi} from '../elements/shared/gr-js-api-interface/gr-gerrit';
+import {initGlobalVariables} from '../elements/gr-app-global-var-init';
+import 'chai/chai';
+import {
+  _testOnly_defaultResinReportHandler,
+  installPolymerResin,
+} from '../scripts/polymer-resin-install';
+import {hasOwnProperty} from '../utils/common-util';
+
+declare global {
+  interface Window {
+    assert: typeof chai.assert;
+    expect: typeof chai.expect;
+    fixture: typeof fixtureImpl;
+    stub: typeof stubImpl;
+    sinon: typeof sinon;
+  }
+  let assert: typeof chai.assert;
+  let expect: typeof chai.expect;
+  let stub: typeof stubImpl;
+  let sinon: typeof sinon;
+}
+window.assert = chai.assert;
+window.expect = chai.expect;
+
+window.sinon = sinon;
+
+installPolymerResin(safeTypesBridge, (isViolation, fmt, ...args) => {
+  const log = _testOnly_defaultResinReportHandler;
+  log(isViolation, fmt, ...args);
+  if (isViolation) {
+    // This will cause the test to fail if there is a data binding
+    // violation.
+    throw new Error('polymer-resin violation: ' + fmt + JSON.stringify(args));
+  }
+});
+
+interface TestFixtureElement extends HTMLElement {
+  restore(): void;
+  create(model?: unknown): HTMLElement | HTMLElement[];
+}
+
+function getFixtureElementById(fixtureId: string) {
+  return document.getElementById(fixtureId) as TestFixtureElement;
+}
+
+// For karma always set our implementation
+// (karma doesn't provide the fixture method)
+function fixtureImpl(fixtureId: string, model: unknown) {
+  // This method is inspired by web-component-tester method
+  registerTestCleanup(() => getFixtureElementById(fixtureId).restore());
+  return getFixtureElementById(fixtureId).create(model);
+}
+
+window.fixture = fixtureImpl;
+
+setup(() => {
+  window.Gerrit = {};
+  initGlobalVariables();
+
+  // If the following asserts fails - then window.stub is
+  // overwritten by some other code.
+  assert.equal(getCleanupsCount(), 0);
+  // The following calls is nessecary to avoid influence of previously executed
+  // tests.
+  TestKeyboardShortcutBinder.push();
+  _testOnlyInitAppContext();
+  _testOnly_initGerritPluginApi();
+  const mgr = _testOnly_getShortcutManagerInstance();
+  assert.isTrue(mgr._testOnly_isEmpty());
+  const selection = document.getSelection();
+  if (selection) {
+    selection.removeAllRanges();
+  }
+  const pl = _testOnly_resetPluginLoader();
+  // For testing, always init with empty plugin list
+  // Since when serve in gr-app, we always retrieve the list
+  // from project config and init loading after that, all
+  // `awaitPluginsLoaded` will rely on that to kick off,
+  // in testing, we want to kick start this earlier.
+  // You still can manually call _testOnly_resetPluginLoader
+  // to reset this behavior if you need to test something specific.
+  pl.loadPlugins([]);
+  _testOnlyResetGrRestApiSharedObjects();
+  _testOnlyResetRestApi();
+});
+
+// For karma always set our implementation
+// (karma doesn't provide the stub method)
+function stubImpl<T extends keyof HTMLElementTagNameMap>(
+  tagName: T,
+  implementation: Partial<HTMLElementTagNameMap[T]>
+) {
+  // This method is inspired by web-component-tester method
+  const proto = document.createElement(tagName).constructor
+    .prototype as HTMLElementTagNameMap[T];
+  let key: keyof HTMLElementTagNameMap[T];
+  const stubs: SinonSpy[] = [];
+  for (key in implementation) {
+    if (hasOwnProperty(implementation, key)) {
+      stubs.push(sinon.stub(proto, key).callsFake(implementation[key]));
+    }
+  }
+  registerTestCleanup(() => {
+    stubs.forEach(stub => {
+      stub.restore();
+    });
+  });
+}
+
+window.stub = stubImpl;
+
+// Very simple function to catch unexpected elements in documents body.
+// It can't catch everything, but in most cases it is enough.
+function checkChildAllowed(element: Element) {
+  const allowedTags = ['SCRIPT', 'IRON-A11Y-ANNOUNCER'];
+  if (allowedTags.includes(element.tagName)) {
+    return;
+  }
+  if (element.tagName === 'TEST-FIXTURE') {
+    if (
+      element.children.length === 0 ||
+      (element.children.length === 1 &&
+        element.children[0].tagName === 'TEMPLATE')
+    ) {
+      return;
+    }
+    assert.fail(
+      `Test fixture
+        ${element.outerHTML}` +
+        "isn't resotred after the test is finished. Please ensure that " +
+        'restore() method is called for this test-fixture. Usually the call' +
+        'happens automatically.'
+    );
+    return;
+  }
+  if (
+    element.tagName === 'DIV' &&
+    element.id === 'gr-hovercard-container' &&
+    element.childNodes.length === 0
+  ) {
+    return;
+  }
+  assert.fail(
+    `The following node remains in document after the test:
+      ${element.tagName}
+      Outer HTML:
+      ${element.outerHTML}`
+  );
+}
+function checkGlobalSpace() {
+  for (const child of document.body.children) {
+    checkChildAllowed(child);
+  }
+}
+
+teardown(() => {
+  sinon.restore();
+  cleanupTestUtils();
+  TestKeyboardShortcutBinder.pop();
+  checkGlobalSpace();
+  // Clean Polymer debouncer queue, so next tests will not be affected.
+  flushDebouncers();
+});
diff --git a/polygerrit-ui/app/test/source-map-support-install.ts b/polygerrit-ui/app/test/source-map-support-install.ts
new file mode 100644
index 0000000..b8798e2
--- /dev/null
+++ b/polygerrit-ui/app/test/source-map-support-install.ts
@@ -0,0 +1,33 @@
+/**
+ * @license
+ * Copyright (C) 2020 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.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and doesn't allow "declare global".
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+declare global {
+  interface Window {
+    sourceMapSupport: {
+      install(): void;
+    };
+  }
+}
+
+// The karma.conf.js file loads required module before any other modules
+// The source-map-support.js can't be imported with import ... statement
+window.sourceMapSupport.install();
diff --git a/polygerrit-ui/app/test/test-app-context-init.js b/polygerrit-ui/app/test/test-app-context-init.ts
similarity index 79%
rename from polygerrit-ui/app/test/test-app-context-init.js
rename to polygerrit-ui/app/test/test-app-context-init.ts
index 68e68f0..7f19903 100644
--- a/polygerrit-ui/app/test/test-app-context-init.js
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -16,14 +16,17 @@
  */
 
 // Init app context before any other imports
-import {initAppContext} from '../services/app-context-init.js';
-import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock.js';
-import {appContext} from '../services/app-context.js';
+import {initAppContext} from '../services/app-context-init';
+import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock';
+import {AppContext, appContext} from '../services/app-context';
 
 export function _testOnlyInitAppContext() {
   initAppContext();
 
-  function setMock(serviceName, setupMock) {
+  function setMock<T extends keyof AppContext>(
+    serviceName: T,
+    setupMock: AppContext[T]
+  ) {
     Object.defineProperty(appContext, serviceName, {
       get() {
         return setupMock;
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
new file mode 100644
index 0000000..3b464a5
--- /dev/null
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -0,0 +1,415 @@
+/**
+ * @license
+ * Copyright (C) 2020 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.
+ */
+
+import {
+  AccountId,
+  AccountInfo,
+  AccountsConfigInfo,
+  ApprovalInfo,
+  AuthInfo,
+  BranchName,
+  ChangeConfigInfo,
+  ChangeId,
+  ChangeInfo,
+  ChangeInfoId,
+  ChangeMessageId,
+  ChangeMessageInfo,
+  ChangeViewChangeInfo,
+  CommentLinkInfo,
+  CommentLinks,
+  CommitId,
+  CommitInfo,
+  ConfigInfo,
+  DownloadInfo,
+  EditPatchSetNum,
+  GerritInfo,
+  EmailAddress,
+  GitPersonInfo,
+  GitRef,
+  InheritedBooleanInfo,
+  MaxObjectSizeLimitInfo,
+  MergeableInfo,
+  NumericChangeId,
+  PatchSetNum,
+  PluginConfigInfo,
+  PreferencesInfo,
+  RepoName,
+  Reviewers,
+  RevisionInfo,
+  SchemesInfoMap,
+  ServerInfo,
+  SubmitTypeInfo,
+  SuggestInfo,
+  Timestamp,
+  TimezoneOffset,
+  UserConfigInfo,
+  AccountDetailInfo,
+} from '../types/common';
+import {
+  AccountsVisibility,
+  AppTheme,
+  AuthType,
+  ChangeStatus,
+  DateFormat,
+  DefaultBase,
+  DefaultDisplayNameConfig,
+  DiffViewMode,
+  EmailStrategy,
+  InheritedBooleanInfoConfiguredValue,
+  MergeabilityComputationBehavior,
+  RevisionKind,
+  SubmitType,
+  TimeFormat,
+} from '../constants/constants';
+import {formatDate} from '../utils/date-util';
+import {GetDiffCommentsOutput} from '../services/services/gr-rest-api/gr-rest-api';
+import {AppElementChangeViewParams} from '../elements/gr-app-types';
+import {GerritView} from '../elements/core/gr-navigation/gr-navigation';
+import {
+  EditRevisionInfo,
+  ParsedChangeInfo,
+} from '../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+
+export function dateToTimestamp(date: Date): Timestamp {
+  const nanosecondSuffix = '.000000000';
+  return (formatDate(date, 'YYYY-MM-DD HH:mm:ss') +
+    nanosecondSuffix) as Timestamp;
+}
+
+export function createCommentLink(match = 'test'): CommentLinkInfo {
+  return {
+    match,
+  };
+}
+
+export function createInheritedBoolean(value = false): InheritedBooleanInfo {
+  return {
+    value,
+    configured_value: value
+      ? InheritedBooleanInfoConfiguredValue.TRUE
+      : InheritedBooleanInfoConfiguredValue.FALSE,
+  };
+}
+
+export function createMaxObjectSizeLimit(): MaxObjectSizeLimitInfo {
+  return {};
+}
+
+export function createSubmitType(
+  value: Exclude<SubmitType, SubmitType.INHERIT> = SubmitType.MERGE_IF_NECESSARY
+): SubmitTypeInfo {
+  return {
+    value,
+    configured_value: SubmitType.INHERIT,
+    inherited_value: value,
+  };
+}
+
+export function createCommentLinks(): CommentLinks {
+  return {};
+}
+
+export function createConfig(): ConfigInfo {
+  return {
+    private_by_default: createInheritedBoolean(),
+    work_in_progress_by_default: createInheritedBoolean(),
+    max_object_size_limit: createMaxObjectSizeLimit(),
+    default_submit_type: createSubmitType(),
+    submit_type: SubmitType.INHERIT,
+    commentlinks: createCommentLinks(),
+  };
+}
+
+export function createAccountWithId(id = 5): AccountInfo {
+  return {
+    _account_id: id as AccountId,
+  };
+}
+
+export function createAccountDetailWithId(id = 5): AccountDetailInfo {
+  return {
+    _account_id: id as AccountId,
+    registered_on: dateToTimestamp(new Date(2020, 10, 15, 14, 5, 8)),
+  };
+}
+
+export function createAccountWithEmail(email = 'test@'): AccountInfo {
+  return {
+    email: email as EmailAddress,
+  };
+}
+
+export function createAccountWithIdNameAndEmail(id = 5): AccountInfo {
+  return {
+    _account_id: id as AccountId,
+    email: `user-${id}@` as EmailAddress,
+    name: `User-${id}`,
+  };
+}
+
+export function createReviewers(): Reviewers {
+  return {};
+}
+
+export const TEST_PROJECT_NAME: RepoName = 'test-project' as RepoName;
+export const TEST_BRANCH_ID: BranchName = 'test-branch' as BranchName;
+export const TEST_CHANGE_ID: ChangeId = 'TestChangeId' as ChangeId;
+export const TEST_CHANGE_INFO_ID: ChangeInfoId = `${TEST_PROJECT_NAME}~${TEST_BRANCH_ID}~${TEST_CHANGE_ID}` as ChangeInfoId;
+export const TEST_SUBJECT = 'Test subject';
+export const TEST_NUMERIC_CHANGE_ID = 42 as NumericChangeId;
+
+export const TEST_CHANGE_CREATED = new Date(2020, 1, 1, 1, 2, 3);
+export const TEST_CHANGE_UPDATED = new Date(2020, 10, 6, 5, 12, 34);
+
+export function createGitPerson(name = 'Test name'): GitPersonInfo {
+  return {
+    name,
+    email: `${name}@`,
+    date: dateToTimestamp(new Date(2019, 11, 6, 14, 5, 8)),
+    tz: 0 as TimezoneOffset,
+  };
+}
+
+export function createCommit(): CommitInfo {
+  return {
+    parents: [],
+    author: createGitPerson(),
+    committer: createGitPerson(),
+    subject: 'Test commit subject',
+    message: 'Test commit message',
+  };
+}
+
+export function createRevision(patchSetNum = 1): RevisionInfo {
+  return {
+    _number: patchSetNum as PatchSetNum,
+    commit: createCommit(),
+    created: dateToTimestamp(TEST_CHANGE_CREATED),
+    kind: RevisionKind.REWORK,
+    ref: 'refs/changes/5/6/1' as GitRef,
+    uploader: createAccountWithId(),
+  };
+}
+
+export function createEditRevision(): EditRevisionInfo {
+  return {
+    _number: EditPatchSetNum,
+    basePatchNum: 1 as PatchSetNum,
+    commit: createCommit(),
+  };
+}
+
+export function createChangeMessage(id = 'cm_id_1'): ChangeMessageInfo {
+  return {
+    id: id as ChangeMessageId,
+    date: dateToTimestamp(TEST_CHANGE_CREATED),
+    message: `This is a message with id ${id}`,
+  };
+}
+
+export function createRevisions(
+  count: number
+): {[revisionId: string]: RevisionInfo} {
+  const revisions: {[revisionId: string]: RevisionInfo} = {};
+  const revisionDate = TEST_CHANGE_CREATED;
+  const revisionIdStart = 1; // The same as getCurrentRevision
+  for (let i = 0; i < count; i++) {
+    const revisionId = (i + revisionIdStart).toString(16);
+    const revision: RevisionInfo = {
+      ...createRevision(i + 1),
+      created: dateToTimestamp(revisionDate),
+      ref: `refs/changes/5/6/${i + 1}` as GitRef,
+    };
+    revisions[revisionId] = revision;
+    // advance 1 day
+    revisionDate.setDate(revisionDate.getDate() + 1);
+  }
+  return revisions;
+}
+
+export function getCurrentRevision(count: number): CommitId {
+  const revisionIdStart = 1; // The same as createRevisions
+  return (count + revisionIdStart).toString(16) as CommitId;
+}
+
+export function createChangeMessages(count: number): ChangeMessageInfo[] {
+  const messageIdStart = 1000;
+  const messages: ChangeMessageInfo[] = [];
+  const messageDate = TEST_CHANGE_CREATED;
+  for (let i = 0; i < count; i++) {
+    messages.push({
+      ...createChangeMessage((i + messageIdStart).toString(16)),
+      date: dateToTimestamp(messageDate),
+    });
+    messageDate.setDate(messageDate.getDate() + 1);
+  }
+  return messages;
+}
+
+export function createChange(): ChangeInfo {
+  return {
+    id: TEST_CHANGE_INFO_ID,
+    project: TEST_PROJECT_NAME,
+    branch: TEST_BRANCH_ID,
+    change_id: TEST_CHANGE_ID,
+    subject: TEST_SUBJECT,
+    status: ChangeStatus.NEW,
+    created: dateToTimestamp(TEST_CHANGE_CREATED),
+    updated: dateToTimestamp(TEST_CHANGE_UPDATED),
+    insertions: 0,
+    deletions: 0,
+    _number: TEST_NUMERIC_CHANGE_ID,
+    owner: createAccountWithId(),
+    // This is documented as optional, but actually always set.
+    reviewers: createReviewers(),
+  };
+}
+
+export function createChangeViewChange(): ChangeViewChangeInfo {
+  return {
+    ...createChange(),
+    revisions: {
+      abc: createRevision(),
+    },
+    current_revision: 'abc' as CommitId,
+  };
+}
+
+export function createParsedChange(): ParsedChangeInfo {
+  return createChangeViewChange();
+}
+
+export function createAccountsConfig(): AccountsConfigInfo {
+  return {
+    visibility: AccountsVisibility.ALL,
+    default_display_name: DefaultDisplayNameConfig.FULL_NAME,
+  };
+}
+
+export function createAuth(): AuthInfo {
+  return {
+    auth_type: AuthType.OPENID,
+    editable_account_fields: [],
+  };
+}
+
+export function createChangeConfig(): ChangeConfigInfo {
+  return {
+    large_change: 500,
+    reply_label: 'Reply',
+    reply_tooltip: 'Reply and score',
+    // The default update_delay is 5 minutes, but we don't want to accidentally
+    // start polling in tests
+    update_delay: 0,
+    mergeability_computation_behavior:
+      MergeabilityComputationBehavior.REF_UPDATED_AND_CHANGE_REINDEX,
+    enable_attention_set: false,
+    enable_assignee: false,
+  };
+}
+
+export function createDownloadSchemes(): SchemesInfoMap {
+  return {};
+}
+
+export function createDownloadInfo(): DownloadInfo {
+  return {
+    schemes: createDownloadSchemes(),
+    archives: ['tgz', 'tar'],
+  };
+}
+
+export function createGerritInfo(): GerritInfo {
+  return {
+    all_projects: 'All-Projects',
+    all_users: 'All-Users',
+    doc_search: false,
+  };
+}
+
+export function createPluginConfig(): PluginConfigInfo {
+  return {
+    has_avatars: false,
+    js_resource_paths: [],
+    html_resource_paths: [],
+  };
+}
+
+export function createSuggestInfo(): SuggestInfo {
+  return {
+    from: 0,
+  };
+}
+
+export function createUserConfig(): UserConfigInfo {
+  return {
+    anonymous_coward_name: 'Name of user not set',
+  };
+}
+
+export function createServerInfo(): ServerInfo {
+  return {
+    accounts: createAccountsConfig(),
+    auth: createAuth(),
+    change: createChangeConfig(),
+    download: createDownloadInfo(),
+    gerrit: createGerritInfo(),
+    plugin: createPluginConfig(),
+    suggest: createSuggestInfo(),
+    user: createUserConfig(),
+  };
+}
+
+export function createGetDiffCommentsOutput(): GetDiffCommentsOutput {
+  return {
+    baseComments: [],
+    comments: [],
+  };
+}
+
+export function createMergeable(): MergeableInfo {
+  return {
+    submit_type: SubmitType.MERGE_IF_NECESSARY,
+    mergeable: false,
+  };
+}
+
+export function createPreferences(): PreferencesInfo {
+  return {
+    changes_per_page: 10,
+    theme: AppTheme.LIGHT,
+    date_format: DateFormat.ISO,
+    time_format: TimeFormat.HHMM_24,
+    diff_view: DiffViewMode.SIDE_BY_SIDE,
+    my: [],
+    change_table: [],
+    email_strategy: EmailStrategy.ENABLED,
+    default_base_for_merges: DefaultBase.AUTO_MERGE,
+  };
+}
+
+export function createApproval(): ApprovalInfo {
+  return createAccountWithId();
+}
+
+export function createAppElementChangeViewParams(): AppElementChangeViewParams {
+  return {
+    view: GerritView.CHANGE,
+    changeNum: TEST_NUMERIC_CHANGE_ID,
+    project: TEST_PROJECT_NAME,
+  };
+}
diff --git a/polygerrit-ui/app/test/test-router.js b/polygerrit-ui/app/test/test-router.ts
similarity index 85%
rename from polygerrit-ui/app/test/test-router.js
rename to polygerrit-ui/app/test/test-router.ts
index 9b89744..a378e2d 100644
--- a/polygerrit-ui/app/test/test-router.js
+++ b/polygerrit-ui/app/test/test-router.ts
@@ -14,6 +14,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {GerritNav} from '../elements/core/gr-navigation/gr-navigation.js';
+import {GerritNav} from '../elements/core/gr-navigation/gr-navigation';
 
-GerritNav.setup(url => { /* noop */ }, params => '', () => []);
+GerritNav.setup(
+  () => {
+    /* noop */
+  },
+  () => '',
+  () => [],
+  () => {
+    return {};
+  }
+);
diff --git a/polygerrit-ui/app/test/test-utils.js b/polygerrit-ui/app/test/test-utils.js
deleted file mode 100644
index 32430f1..0000000
--- a/polygerrit-ui/app/test/test-utils.js
+++ /dev/null
@@ -1,140 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader.js';
-import {testOnly_resetInternalState} from '../elements/shared/gr-js-api-interface/gr-api-utils.js';
-import {_testOnly_resetEndpoints} from '../elements/shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {_testOnly_getShortcutManagerInstance} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-
-export const mockPromise = () => {
-  let res;
-  const promise = new Promise(resolve => {
-    res = resolve;
-  });
-  promise.resolve = res;
-  return promise;
-};
-export const isHidden = el => getComputedStyle(el).display === 'none';
-
-// Some tests/elements can define its own binding. We want to restore bindings
-// at the end of the test. The TestKeyboardShortcutBinder store bindings in
-// stack, so it is possible to override bindings in nested suites.
-export class TestKeyboardShortcutBinder {
-  static push() {
-    if (!this.stack) {
-      this.stack = [];
-    }
-    const testBinder = new TestKeyboardShortcutBinder();
-    this.stack.push(testBinder);
-    return _testOnly_getShortcutManagerInstance();
-  }
-
-  static pop() {
-    this.stack.pop()._restoreShortcuts();
-  }
-
-  constructor() {
-    this._originalBinding = new Map(
-        _testOnly_getShortcutManagerInstance().bindings);
-  }
-
-  _restoreShortcuts() {
-    const bindings = _testOnly_getShortcutManagerInstance().bindings;
-    bindings.clear();
-    this._originalBinding.forEach((value, key) => {
-      bindings.set(key, value);
-    });
-  }
-}
-
-// Provide reset plugins function to clear installed plugins between tests.
-// No gr-app found (running tests)
-export const resetPlugins = () => {
-  testOnly_resetInternalState();
-  _testOnly_resetEndpoints();
-  const pl = _testOnly_resetPluginLoader();
-  pl.loadPlugins([]);
-};
-
-const cleanups = [];
-
-function registerTestCleanup(cleanupCallback) {
-  cleanups.push(cleanupCallback);
-}
-
-export function cleanupTestUtils() {
-  cleanups.forEach(cleanup => cleanup());
-  cleanups.splice(0);
-}
-
-export function stubBaseUrl(newUrl) {
-  const originalCanonicalPath = window.CANONICAL_PATH;
-  window.CANONICAL_PATH = newUrl;
-  registerTestCleanup(() => window.CANONICAL_PATH = originalCanonicalPath);
-}
-
-export function generateChange(options) {
-  const change = {
-    _number: 42,
-    project: 'testRepo',
-  };
-  const revisionIdStart = 1;
-  const messageIdStart = 1000;
-  // We want to distinguish between empty arrays/objects and undefined
-  // If an option is not set - the appropriate property is not set
-  // If an options is set - the property always set
-  if (options && typeof options.revisionsCount !== 'undefined') {
-    const revisions = {};
-    for (let i = 0; i < options.revisionsCount; i++) {
-      const revisionId = (i + revisionIdStart).toString(16);
-      revisions[revisionId] = {
-        _number: i+1,
-        commit: {parents: []},
-      };
-    }
-    change.revisions = revisions;
-  }
-  if (options && typeof options.messagesCount !== 'undefined') {
-    const messages = [];
-    for (let i = 0; i < options.messagesCount; i++) {
-      messages.push({
-        id: (i + messageIdStart).toString(16),
-        date: new Date(2020, 1, 1),
-        message: `This is a message N${i + 1}`,
-      });
-    }
-    change.messages = messages;
-  }
-  if (options && options.status) {
-    change.status = options.status;
-  }
-  return change;
-}
-
-/**
- * Forcing an opacity of 0 onto the ironOverlayBackdrop is required, because
- * otherwise the backdrop stays around in the DOM for too long waiting for
- * an animation to finish. This could be considered to be moved to a
- * common-test-setup file.
- */
-export function createIronOverlayBackdropStyleEl() {
-  const ironOverlayBackdropStyleEl = document.createElement('style');
-  document.head.appendChild(ironOverlayBackdropStyleEl);
-  ironOverlayBackdropStyleEl.sheet.insertRule(
-      'body { --iron-overlay-backdrop-opacity: 0; }');
-  return ironOverlayBackdropStyleEl;
-}
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
new file mode 100644
index 0000000..7ebc6e1
--- /dev/null
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -0,0 +1,154 @@
+/**
+ * @license
+ * 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.
+ */
+import '../types/globals';
+import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
+import {testOnly_resetInternalState} from '../elements/shared/gr-js-api-interface/gr-api-utils';
+import {_testOnly_resetEndpoints} from '../elements/shared/gr-js-api-interface/gr-plugin-endpoints';
+import {
+  _testOnly_getShortcutManagerInstance,
+  Shortcut,
+} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+
+export interface MockPromise extends Promise<unknown> {
+  resolve: (value?: unknown) => void;
+}
+
+export const mockPromise = () => {
+  let res: (value?: unknown) => void;
+  const promise: MockPromise = new Promise(resolve => {
+    res = resolve;
+  }) as MockPromise;
+  promise.resolve = res!;
+  return promise;
+};
+
+export function isHidden(el: Element | undefined | null) {
+  if (!el) return true;
+  return getComputedStyle(el).display === 'none';
+}
+
+export function query(el: Element | undefined, selectors: string) {
+  if (!el) return null;
+  const root = el.shadowRoot || el;
+  return root.querySelector(selectors);
+}
+
+// Some tests/elements can define its own binding. We want to restore bindings
+// at the end of the test. The TestKeyboardShortcutBinder store bindings in
+// stack, so it is possible to override bindings in nested suites.
+export class TestKeyboardShortcutBinder {
+  private static stack: TestKeyboardShortcutBinder[] = [];
+
+  static push() {
+    const testBinder = new TestKeyboardShortcutBinder();
+    this.stack.push(testBinder);
+    return _testOnly_getShortcutManagerInstance();
+  }
+
+  static pop() {
+    const item = this.stack.pop();
+    if (!item) {
+      throw new Error('stack is empty');
+    }
+    item._restoreShortcuts();
+  }
+
+  private readonly originalBinding: Map<Shortcut, string[]>;
+
+  constructor() {
+    this.originalBinding = new Map(
+      _testOnly_getShortcutManagerInstance()._testOnly_getBindings()
+    );
+  }
+
+  _restoreShortcuts() {
+    const bindings = _testOnly_getShortcutManagerInstance()._testOnly_getBindings();
+    bindings.clear();
+    this.originalBinding.forEach((value, key) => {
+      bindings.set(key, value);
+    });
+  }
+}
+
+// Provide reset plugins function to clear installed plugins between tests.
+// No gr-app found (running tests)
+export const resetPlugins = () => {
+  testOnly_resetInternalState();
+  _testOnly_resetEndpoints();
+  const pl = _testOnly_resetPluginLoader();
+  pl.loadPlugins([]);
+};
+
+export type CleanupCallback = () => void;
+
+const cleanups: CleanupCallback[] = [];
+
+export function getCleanupsCount() {
+  return cleanups.length;
+}
+
+export function registerTestCleanup(cleanupCallback: CleanupCallback) {
+  cleanups.push(cleanupCallback);
+}
+
+export function cleanupTestUtils() {
+  cleanups.forEach(cleanup => cleanup());
+  cleanups.splice(0);
+}
+
+export function stubBaseUrl(newUrl: string) {
+  const originalCanonicalPath = window.CANONICAL_PATH;
+  window.CANONICAL_PATH = newUrl;
+  registerTestCleanup(() => (window.CANONICAL_PATH = originalCanonicalPath));
+}
+
+/**
+ * Forcing an opacity of 0 onto the ironOverlayBackdrop is required, because
+ * otherwise the backdrop stays around in the DOM for too long waiting for
+ * an animation to finish. This could be considered to be moved to a
+ * common-test-setup file.
+ */
+export function createIronOverlayBackdropStyleEl() {
+  const ironOverlayBackdropStyleEl = document.createElement('style');
+  document.head.appendChild(ironOverlayBackdropStyleEl);
+  ironOverlayBackdropStyleEl.sheet!.insertRule(
+    'body { --iron-overlay-backdrop-opacity: 0; }'
+  );
+  return ironOverlayBackdropStyleEl;
+}
+
+/**
+ * Promisify an event callback to simplify async...await tests.
+ *
+ * Use like this:
+ *   await listenOnce(el, 'render');
+ *   ...
+ */
+export function listenOnce(el: EventTarget, eventType: string) {
+  return new Promise(resolve => {
+    const listener = () => {
+      removeEventListener();
+      resolve();
+    };
+    el.addEventListener(eventType, listener);
+    let removeEventListener = () => {
+      el.removeEventListener(eventType, listener);
+      removeEventListener = () => {};
+    };
+    registerTestCleanup(removeEventListener);
+  });
+}
diff --git a/polygerrit-ui/app/tsconfig.json b/polygerrit-ui/app/tsconfig.json
index bc6c2df..15294f4 100644
--- a/polygerrit-ui/app/tsconfig.json
+++ b/polygerrit-ui/app/tsconfig.json
@@ -37,14 +37,18 @@
     /* Advanced Options */
     "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */
     "incremental": true,
-    "experimentalDecorators": true
+    "experimentalDecorators": true,
+
+    "allowUmdGlobalAccess": true
   },
   // With the * pattern (without an extension), only supported files
   // are included. The supported files are .ts, .tsx, .d.ts.
   // If allowJs is set to true, .js and .jsx files are included as well.
   // Note: gerrit doesn't have .tsx and .jsx files
   "include": [
-    // This items below must be in sync with the src_dirs list in the BUILD file
+    // Items below must be in sync with the src_dirs list in the BUILD file
+    // Also items must be in sync with tsconfig_bazel.json, tsconfig_bazel_test.json
+    // (include and exclude arrays are overriden when extends)
     "constants/**/*",
     "elements/**/*",
     "embed/**/*",
@@ -56,7 +60,6 @@
     "styles/**/*",
     "types/**/*",
     "utils/**/*",
-    // Directory for test utils (not included in src_dirs in the BUILD file)
     "test/**/*"
   ]
 }
diff --git a/polygerrit-ui/app/tsconfig_bazel.json b/polygerrit-ui/app/tsconfig_bazel.json
new file mode 100644
index 0000000..6365bf0
--- /dev/null
+++ b/polygerrit-ui/app/tsconfig_bazel.json
@@ -0,0 +1,29 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "typeRoots": [
+      "../../external/ui_npm/node_modules/@types",
+      "../../external/ui_dev_npm/node_modules/@types"
+    ]
+  },
+  "include": [
+    // Items below must be in sync with the src_dirs list in the BUILD file
+    // Also items must be in sync with tsconfig.json, tsconfig_bazel_test.json
+    // (include and exclude arrays are overriden when extends)
+    "constants/**/*",
+    "elements/**/*",
+    "embed/**/*",
+    "gr-diff/**/*",
+    "mixins/**/*",
+    "samples/**/*",
+    "scripts/**/*",
+    "services/**/*",
+    "styles/**/*",
+    "types/**/*",
+    "utils/**/*"
+  ],
+  "exclude": [
+    "**/*_test.ts",
+    "**/*_test.js"
+  ]
+}
diff --git a/polygerrit-ui/app/tsconfig_bazel_test.json b/polygerrit-ui/app/tsconfig_bazel_test.json
new file mode 100644
index 0000000..efd2978
--- /dev/null
+++ b/polygerrit-ui/app/tsconfig_bazel_test.json
@@ -0,0 +1,32 @@
+{
+  "extends": "./tsconfig_bazel.json",
+  "compilerOptions": {
+    "typeRoots": [
+      "./test/@types",
+      "../../external/ui_dev_npm/node_modules/@polymer/iron-test-helpers",
+      "../../external/ui_npm/node_modules/@types",
+      "../../external/ui_dev_npm/node_modules/@types"
+    ],
+    "paths": {
+      "@polymer/iron-test-helpers/*": ["../../ui_dev_npm/node_modules/@polymer/iron-test-helpers/*"]
+    }
+  },
+  "include": [
+    // Items below must be in sync with the src_dirs list in the BUILD file
+    // Also items must be in sync with tsconfig.json, tsconfig_test.json
+    // (include and exclude arrays are overriden when extends)
+    "constants/**/*",
+    "elements/**/*",
+    "embed/**/*",
+    "gr-diff/**/*",
+    "mixins/**/*",
+    "samples/**/*",
+    "scripts/**/*",
+    "services/**/*",
+    "styles/**/*",
+    "types/**/*",
+    "utils/**/*",
+    "test/**/*"
+  ],
+  "exclude": []
+}
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index caa5f7f..b454b6e 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -37,12 +37,19 @@
   TimeFormat,
   EmailStrategy,
   DefaultBase,
-  IgnoreWhitespaceType,
   UserPriority,
   DiffViewMode,
   DraftsAction,
   NotifyType,
+  EmailFormat,
+  AuthType,
+  MergeStrategy,
+  EditableAccountField,
+  MergeabilityComputationBehavior,
 } from '../constants/constants';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+
+import {DiffInfo, IgnoreWhitespaceType, WebLinkInfo} from './diff';
 
 export type BrandType<T, BrandName extends string> = T &
   {[__brand in BrandName]: never};
@@ -53,6 +60,13 @@
 export type RequireProperties<T, K extends keyof T> = Omit<T, K> &
   Required<Pick<T, K>>;
 
+export type PropertyType<T, K extends keyof T> = ReturnType<() => T[K]>;
+
+export type ElementPropertyDeepChange<
+  T,
+  K extends keyof T
+> = PolymerDeepPropertyChange<PropertyType<T, K>, PropertyType<T, K>>;
+
 /**
  * Type alias for parsed json object to make code cleaner
  */
@@ -80,6 +94,10 @@
 export type RobotId = BrandType<string, '_robotId'>;
 export type RobotRunId = BrandType<string, '_robotRunId'>;
 
+// RevisionId '0' is the same as 'current'. However, we want to avoid '0'
+// in our code, so it is not added here as a possible value.
+export type RevisionId = 'current' | CommitId | PatchSetNum;
+
 // The UUID of the suggested fix.
 export type FixId = BrandType<string, '_fixId'>;
 export type EmailAddress = BrandType<string, '_emailAddress'>;
@@ -120,6 +138,7 @@
 export type StarLabel = BrandType<string, '_startLabel'>;
 export type CommitId = BrandType<string, '_commitId'>;
 export type LabelName = BrandType<string, '_labelName'>;
+export type GroupName = BrandType<string, '_groupName'>;
 
 // The UUID of the group
 export type GroupId = BrandType<string, '_groupId'>;
@@ -145,10 +164,14 @@
 export type LabelValueToDescriptionMap = {[labelValue: string]: string};
 
 /**
- * The LabelInfo entity contains information about a label on a change, always corresponding to the current patch set.
+ * The LabelInfo entity contains information about a label on a change, always
+ * corresponding to the current patch set.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#label-info
  */
-export type LabelInfo = QuickLabelInfo | DetailedLabelInfo;
+export type LabelInfo =
+  | QuickLabelInfo
+  | DetailedLabelInfo
+  | (QuickLabelInfo & DetailedLabelInfo);
 
 interface LabelCommonInfo {
   optional?: boolean; // not set if false
@@ -164,12 +187,39 @@
   default_value?: number;
 }
 
+/**
+ * LabelInfo when DETAILED_LABELS are requested.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#_fields_set_by_code_detailed_labels_code
+ */
 export interface DetailedLabelInfo extends LabelCommonInfo {
+  // This is not set when the change has no reviewers.
   all?: ApprovalInfo[];
-  values?: LabelValueToDescriptionMap; // A map of all values that are allowed for this label
+  // Docs claim that 'values' is optional, but it is actually always set.
+  values: LabelValueToDescriptionMap; // A map of all values that are allowed for this label
   default_value?: number;
 }
 
+export function isQuickLabelInfo(
+  l: LabelInfo
+): l is QuickLabelInfo | (QuickLabelInfo & DetailedLabelInfo) {
+  const quickLabelInfo = l as QuickLabelInfo;
+  return (
+    quickLabelInfo.approved !== undefined ||
+    quickLabelInfo.rejected !== undefined ||
+    quickLabelInfo.recommended !== undefined ||
+    quickLabelInfo.disliked !== undefined ||
+    quickLabelInfo.blocking !== undefined ||
+    quickLabelInfo.blocking !== undefined ||
+    quickLabelInfo.value !== undefined
+  );
+}
+
+export function isDetailedLabelInfo(
+  label: LabelInfo
+): label is DetailedLabelInfo | (QuickLabelInfo & DetailedLabelInfo) {
+  return !!(label as DetailedLabelInfo).values;
+}
+
 // https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#contributor-agreement-input
 export interface ContributorAgreementInput {
   name?: string;
@@ -201,7 +251,7 @@
   created: Timestamp;
   updated: Timestamp;
   submitted?: Timestamp;
-  submitter: AccountInfo;
+  submitter?: AccountInfo;
   starred?: boolean; // not set if false
   stars?: StarLabel[];
   reviewed?: boolean; // not set if false
@@ -212,9 +262,10 @@
   deletions: number; // Number of deleted lines
   total_comment_count?: number;
   unresolved_comment_count?: number;
+  // TODO(TS): Use changed_id everywhere in code instead of (legacy) _number
   _number: NumericChangeId;
   owner: AccountInfo;
-  actions?: ActionInfo[];
+  actions?: ActionNameToActionInfoMap;
   requirements?: Requirement[];
   labels?: LabelNameToInfoMap;
   permitted_labels?: LabelNameToValueMap;
@@ -325,17 +376,17 @@
  */
 export interface GroupBaseInfo {
   id: GroupId;
-  name: string;
+  name: GroupName;
 }
 
 /**
  * The GroupInfo entity contains information about a group. This can be a
  * Gerrit internal group, or an external group that is known to Gerrit.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html#group-info
  */
 export interface GroupInfo {
   id: GroupId;
-  name?: string;
+  name?: GroupName;
   url?: string;
   options?: GroupOptionsInfo;
   description?: string;
@@ -353,10 +404,10 @@
 /**
  * The 'GroupInput' entity contains information for the creation of a new
  * internal group.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html#group-input
  */
 export interface GroupInput {
-  name?: string;
+  name?: GroupName;
   uuid?: string;
   description?: string;
   visible_to_all?: string;
@@ -407,14 +458,35 @@
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#action-info
  */
 export interface ActionInfo {
-  __key?: string;
-  __url?: string;
   method?: HttpMethod; // Most actions use POST, PUT or DELETE to cause state changes.
   label?: string; // Short title to display to a user describing the action
   title?: string; // Longer text to display describing the action
   enabled?: boolean; // not set if false
 }
 
+export interface ActionNameToActionInfoMap {
+  [actionType: string]: ActionInfo | undefined;
+  // List of actions explicitly used in code:
+  wip?: ActionInfo;
+  publishEdit?: ActionInfo;
+  rebaseEdit?: ActionInfo;
+  deleteEdit?: ActionInfo;
+  edit?: ActionInfo;
+  stopEdit?: ActionInfo;
+  download?: ActionInfo;
+  rebase?: ActionInfo;
+  cherrypick?: ActionInfo;
+  move?: ActionInfo;
+  revert?: ActionInfo;
+  revert_submission?: ActionInfo;
+  abandon?: ActionInfo;
+  submit?: ActionInfo;
+  topic?: ActionInfo;
+  hashtags?: ActionInfo;
+  assignee?: ActionInfo;
+  ready?: ActionInfo;
+}
+
 /**
  * The Requirement entity contains information about a requirement relative to
  * a change.
@@ -471,7 +543,7 @@
   fetch?: {[protocol: string]: FetchInfo};
   commit?: CommitInfo;
   files?: {[filename: string]: FileInfo};
-  actions?: ActionInfo[];
+  actions?: ActionNameToActionInfoMap;
   reviewed?: boolean;
   commit_with_footers?: boolean;
   push_certificate?: PushCertificateInfo;
@@ -645,16 +717,6 @@
 }
 
 /**
- * The WebLinkInfo entity describes a link to an external site.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#web-link-info
- */
-export interface WebLinkInfo {
-  name: string;
-  url: string;
-  image_url: string;
-}
-
-/**
  * The VotingRangeInfo entity describes the continuous voting range from minto
  * max values.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#voting-range-info
@@ -667,7 +729,7 @@
 /**
  * The AccountsConfigInfo entity contains information about Gerrit configuration
  * from the accounts section.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#accounts-config-info
  */
 export interface AccountsConfigInfo {
   visibility: string;
@@ -677,13 +739,13 @@
 /**
  * The AuthInfo entity contains information about the authentication
  * configuration of the Gerrit server.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
  */
 export interface AuthInfo {
-  type: string;
-  use_contributor_agreements: boolean;
+  auth_type: AuthType; // docs incorrectly names it 'type'
+  use_contributor_agreements?: boolean;
   contributor_agreements?: ContributorAgreementInfo;
-  editable_account_fields: string;
+  editable_account_fields: EditableAccountField[];
   login_url?: string;
   login_text?: string;
   switch_account_url?: string;
@@ -730,17 +792,17 @@
 /**
  * The ChangeConfigInfo entity contains information about Gerrit configuration
  * from the change section.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#change-config-info
  */
 export interface ChangeConfigInfo {
-  allow_blame: boolean;
-  large_change: string;
+  allow_blame?: boolean;
+  large_change: number;
   reply_label: string;
   reply_tooltip: string;
-  update_delay: string;
-  submit_whole_topic: boolean;
-  disable_private_changes: boolean;
-  mergeability_computation_behavior: string;
+  update_delay: number;
+  submit_whole_topic?: boolean;
+  disable_private_changes?: boolean;
+  mergeability_computation_behavior: MergeabilityComputationBehavior;
   enable_attention_set: boolean;
   enable_assignee: boolean;
 }
@@ -748,10 +810,10 @@
 /**
  * The ChangeIndexConfigInfo entity contains information about Gerrit
  * configuration from the index.change section.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#change-index-config-info
  */
 export interface ChangeIndexConfigInfo {
-  index_mergeable: boolean;
+  index_mergeable?: boolean;
 }
 
 /**
@@ -837,11 +899,11 @@
 /**
  * The DownloadInfo entity contains information about supported download
  * options.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#download-info
  */
 export interface DownloadInfo {
   schemes: SchemesInfoMap;
-  archives: string;
+  archives: string[];
 }
 
 export type CloneCommandMap = {[name: string]: string};
@@ -885,9 +947,9 @@
 export interface GerritInfo {
   all_projects: string; // Doc contains incorrect name
   all_users: string; // Doc contains incorrect name
-  doc_search: string;
+  doc_search: boolean;
   doc_url?: string;
-  edit_gpg_keys: boolean;
+  edit_gpg_keys?: boolean;
   report_bug_url?: string;
   // The following property is missed in doc
   primary_weblink_name?: string;
@@ -896,7 +958,7 @@
 /**
  * The IndexConfigInfo entity contains information about Gerrit configuration
  * from the index section.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#index-config-info
  */
 export interface IndexConfigInfo {
   change: ChangeIndexConfigInfo;
@@ -953,10 +1015,11 @@
 /**
  * The PluginConfigInfo entity contains information about Gerrit extensions by
  * plugins.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#plugin-config-info
  */
 export interface PluginConfigInfo {
   has_avatars: boolean;
+  // The following 2 properies exists in Java class, but don't mention in docs
   js_resource_paths: string[];
   html_resource_paths: string[];
 }
@@ -981,22 +1044,32 @@
   change: ChangeConfigInfo;
   download: DownloadInfo;
   gerrit: GerritInfo;
-  index: IndexConfigInfo;
-  note_db_enabled: boolean;
+  // docs mentions index property, but it doesn't exists in Java class
+  // index: IndexConfigInfo;
+  note_db_enabled?: boolean;
   plugin: PluginConfigInfo;
   receive?: ReceiveInfo;
+  sshd?: SshdInfo;
   suggest: SuggestInfo;
   user: UserConfigInfo;
   default_theme?: string;
 }
 
 /**
+ * The SshdInfo entity contains information about Gerrit configuration from the sshd section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#sshd-info
+ * This entity doesn’t contain any data, but the presence of this (empty) entity
+ * in the ServerInfo entity means that SSHD is enabled on the server.
+ */
+export type SshdInfo = {};
+
+/**
  * The SuggestInfo entity contains information about Gerritconfiguration from
  * the suggest section.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#suggest-info
  */
 export interface SuggestInfo {
-  from: string;
+  from: number;
 }
 
 /**
@@ -1049,17 +1122,17 @@
 
 /**
  * The TopMenuEntryInfo entity contains information about a top menu entry.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#top-menu-entry-info
  */
 export interface TopMenuEntryInfo {
   name: string;
-  items: string;
+  items: TopMenuItemInfo[];
 }
 
 /**
  * The TopMenuItemInfo entity contains information about a menu item ina top
  * menu entry.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#top-menu-item-info
  */
 export interface TopMenuItemInfo {
   url: string;
@@ -1071,7 +1144,7 @@
 /**
  * The UserConfigInfo entity contains information about Gerrit configuration
  * from the user section.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#user-config-info
  */
 export interface UserConfigInfo {
   anonymous_coward_name: string;
@@ -1081,7 +1154,8 @@
  * The CommentInfo entity contains information about an inline comment.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
  */
-export interface CommentInfo extends CommentInput {
+export interface CommentInfo {
+  // TODO(TS): Make this required.
   patch_set?: PatchSetNum;
   id: UrlEncodedCommentId;
   path?: string;
@@ -1101,6 +1175,11 @@
 
 export type PathToCommentsInfoMap = {[path: string]: CommentInfo[]};
 
+export type PortedCommentsAndDrafts = {
+  portedComments?: PathToCommentsInfoMap;
+  portedDrafts?: PathToCommentsInfoMap;
+};
+
 /**
  * The CommentRange entity describes the range of an inline comment.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-range
@@ -1152,103 +1231,9 @@
 
 export type LabelTypeInfoValues = {[value: string]: string};
 
-/**
- * The DiffContent entity contains information about the content differences in a file.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-content
- */
-export interface DiffContent {
-  a?: string[];
-  b?: string[];
-  ab?: string[];
-  // The inner array is always of length two. The first entry is the 'skip'
-  // length. The second entry is the 'edit' length.
-  edit_a?: number[][];
-  edit_b?: number[][];
-  due_to_rebase?: boolean;
-  due_to_move?: boolean;
-  skip?: string;
-  common?: string;
-  keyLocation?: boolean;
-}
-
-/**
- * The DiffFileMetaInfo entity contains meta information about a file diff.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-file-meta-info
- */
-export interface DiffFileMetaInfo {
-  name: string;
-  content_type: string;
-  lines: string;
-  web_links?: WebLinkInfo[];
-  language?: string;
-}
-
-/**
- * The DiffInfo entity contains information about the diff of a file in a revision.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-info
- */
-export interface DiffInfo {
-  meta_a: DiffFileMetaInfo;
-  meta_b: DiffFileMetaInfo;
-  change_type: string;
-  intraline_status: string;
-  diff_header: string[];
-  content: DiffContent[];
-  web_links?: DiffWebLinkInfo[];
-  binary: boolean;
-}
-
 export type FilePathToDiffInfoMap = {[path: string]: DiffInfo};
 
 /**
- * The DiffWebLinkInfo entity describes a link on a diff screen to an external site.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-web-link-info
- */
-export interface DiffWebLinkInfo {
-  name: string;
-  url: string;
-  image_url: string;
-  show_on_side_by_side_diff_view: string;
-  show_on_unified_diff_view: string;
-}
-
-/**
- * The DiffPreferencesInfo entity contains information about the diff preferences of a user.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#diff-preferences-info
- */
-export interface DiffPreferencesInfo {
-  context: number;
-  expand_all_comments?: boolean;
-  ignore_whitespace: IgnoreWhitespaceType;
-  intraline_difference?: boolean;
-  line_length: number;
-  cursor_blink_rate: number;
-  manual_review?: boolean;
-  retain_header?: boolean;
-  show_line_endings?: boolean;
-  show_tabs?: boolean;
-  show_whitespace_errors?: boolean;
-  skip_deleted?: boolean;
-  skip_uncommented?: boolean;
-  syntax_highlighting?: boolean;
-  hide_top_menu?: boolean;
-  auto_hide_diff_table_header?: boolean;
-  hide_line_numbers?: boolean;
-  tab_size: number;
-  font_size: number;
-  hide_empty_pane?: boolean;
-  match_brackets?: boolean;
-  line_wrapping?: boolean;
-  // TODO(TS): show_file_comment_button exists in JS code, but doesn't exist in the doc.
-  // Either remove or update doc
-  show_file_comment_button?: boolean;
-  // TODO(TS): theme exists in JS code, but doesn't exist in the doc.
-  // Either remove or update doc
-  theme?: string;
-}
-export type DiffPreferencesInfoKey = keyof DiffPreferencesInfo;
-
-/**
  * The RangeInfo entity stores the coordinates of a range.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#range-info
  */
@@ -1288,18 +1273,18 @@
  * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#inherited-boolean-info
  */
 export interface InheritedBooleanInfo {
-  value: string;
+  value: boolean;
   configured_value: InheritedBooleanInfoConfiguredValue;
-  inherited_value?: string;
+  inherited_value?: boolean;
 }
 
 /**
- * The MaxObjectSizeLimitInfo entity contains information about themax object
+ * The MaxObjectSizeLimitInfo entity contains information about the max object
  * size limit of a project.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#max-object-size-limit-info
  */
 export interface MaxObjectSizeLimitInfo {
-  value?: number;
+  value?: string;
   configured_value?: string;
   summary?: string;
 }
@@ -1365,7 +1350,7 @@
 
 /**
  * The ConfigInfo entity contains information about the effective
- * projectconfiguration.
+ * project configuration.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-info
  */
 export interface ConfigInfo {
@@ -1392,7 +1377,8 @@
 }
 
 /**
- * The ProjectAccessInfo entity contains information about the access rights for a project
+ * The ProjectAccessInfo entity contains information about the access rights for
+ * a project.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-access.html#project-access-info
  */
 export interface ProjectAccessInfo {
@@ -1455,11 +1441,12 @@
   project: RepoName;
   defining_project: RepoName;
   ref: string; // The name of the ref in which the dashboard is defined, without the refs/meta/dashboards/ prefix
+  path: string;
   description?: string;
   foreach?: string;
   url: string;
   is_default?: boolean;
-  title?: boolean;
+  title?: string;
   sections: DashboardSectionInfo[];
 }
 
@@ -1683,25 +1670,11 @@
 /**
  * The PreferencesInput entity contains information for setting the user preferences. Fields which are not set will not be updated
  * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
+ *
+ * Note: the doc missed several properties. Java code uses the same class (GeneralPreferencesInfo)
+ * both for input data and for response data.
  */
-export interface PreferencesInput {
-  changes_per_page?: 10 | 25 | 50 | 100;
-  theme?: AppTheme;
-  expand_inline_diffs?: boolean;
-  download_scheme?: string;
-  date_format?: DateFormat;
-  time_format?: TimeFormat;
-  relative_date_in_change_table?: boolean;
-  diff_view?: DiffViewMode;
-  size_bar_in_change_table?: boolean;
-  legacycid_in_change_table?: boolean;
-  mute_common_path_prefixes?: boolean;
-  signed_off_by?: boolean;
-  my?: TopMenuItemInfo[];
-  change_table?: string[];
-  email_strategy?: EmailStrategy;
-  default_base_for_merges?: DefaultBase;
-}
+export type PreferencesInput = Partial<PreferencesInfo>;
 
 /**
  * The DiffPreferencesInput entity contains information for setting the diff preferences of a user. Fields which are not set will not be updated
@@ -1798,6 +1771,8 @@
   default_base_for_merges: DefaultBase;
   publish_comments_on_push?: boolean;
   work_in_progress_by_default?: boolean;
+  // The email_format doesn't mentioned in doc, but exists in Java class GeneralPreferencesInfo
+  email_format?: EmailFormat;
   // The following property doesn't exist in RestAPI, it is added by GrRestApiInterface
   default_diff_view?: DiffViewMode;
 }
@@ -1971,8 +1946,8 @@
   base_patch_set_number: PatchSetNum;
   base_revision: string;
   ref: GitRef;
-  fetch: ProtocolToFetchInfoMap;
-  files: FileNameToFileInfoMap;
+  fetch?: ProtocolToFetchInfoMap;
+  files?: FileNameToFileInfoMap;
 }
 
 export type ProtocolToFetchInfoMap = {[protocol: string]: FetchInfo};
@@ -2143,3 +2118,41 @@
   changes: ChangeInfo[];
   non_visible_changes: number;
 }
+
+/**
+ * The RevertSubmissionInfo entity describes the revert changes.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#revert-submission-info
+ */
+export interface RevertSubmissionInfo {
+  revert_changes: ChangeInfo[];
+}
+
+/**
+ * The CherryPickInput entity contains information for cherry-picking a change to a new branch.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#cherrypick-input
+ */
+export interface CherryPickInput {
+  message?: string;
+  destination: BranchName;
+  base?: CommitId;
+  parent?: number;
+  notify?: NotifyType;
+  notify_details: RecipientTypeToNotifyInfoMap;
+  keep_reviewers?: boolean;
+  allow_conflicts?: boolean;
+  topic?: TopicName;
+  allow_empty?: boolean;
+}
+
+/**
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#mergeable-info
+ */
+export interface MergeableInfo {
+  submit_type: SubmitType;
+  strategy?: MergeStrategy;
+  mergeable: boolean;
+  commit_merged?: boolean;
+  content_merged?: boolean;
+  conflicts?: string[];
+  mergeable_into?: string[];
+}
diff --git a/polygerrit-ui/app/types/diff.d.ts b/polygerrit-ui/app/types/diff.d.ts
new file mode 100644
index 0000000..b45b461
--- /dev/null
+++ b/polygerrit-ui/app/types/diff.d.ts
@@ -0,0 +1,239 @@
+/**
+ * @fileoverview The Gerrit diff API.
+ *
+ * This API is used by other apps embedding gr-diff and any breaking changes
+ * should be discussed with the Gerrit core team and properly versioned.
+ *
+ * Should only contain types, no values, so that other apps using gr-diff can
+ * use this solely to type check and generate externs for their separate ts
+ * bundles.
+ *
+ * Should declare all types, to avoid renaming breaking multi-bundle setups.
+ *
+ * Enums should be converted to union types to avoid values in this file.
+ *
+ * @license
+ * Copyright (C) 2020 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.
+ */
+
+/**
+ * The DiffInfo entity contains information about the diff of a file in a
+ * revision.
+ *
+ * If the weblinks-only parameter is specified, only the web_links field is set.
+ */
+export declare interface DiffInfo {
+  /** Meta information about the file on side A as a DiffFileMetaInfo entity. */
+  meta_a: DiffFileMetaInfo;
+  /** Meta information about the file on side B as a DiffFileMetaInfo entity. */
+  meta_b: DiffFileMetaInfo;
+  /** The type of change (ADDED, MODIFIED, DELETED, RENAMED COPIED, REWRITE). */
+  change_type: ChangeType;
+  /** Intraline status (OK, ERROR, TIMEOUT). */
+  intraline_status: 'OK' | 'Error' | 'Timeout';
+  /** A list of strings representing the patch set diff header. */
+  diff_header: string[];
+  /** The content differences in the file as a list of DiffContent entities. */
+  content: DiffContent[];
+  /**
+   * Links to the file diff in external sites as a list of DiffWebLinkInfo
+   * entries.
+   */
+  web_links?: DiffWebLinkInfo[];
+  /** Whether the file is binary. */
+  binary?: boolean;
+}
+
+/**
+ * The DiffFileMetaInfo entity contains meta information about a file diff.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-file-meta-info
+ */
+export declare interface DiffFileMetaInfo {
+  /** The name of the file. */
+  name: string;
+  /** The content type of the file. */
+  content_type: string;
+  /** The total number of lines in the file. */
+  lines: number;
+  /** Links to the file in external sites as a list of WebLinkInfo entries. */
+  web_links: WebLinkInfo[];
+  // TODO: Not documented.
+  language?: string;
+}
+
+export declare type ChangeType =
+  | 'ADDED'
+  | 'MODIFIED'
+  | 'DELETED'
+  | 'RENAMED'
+  | 'COPIED'
+  | 'REWRITE';
+
+/**
+ * The DiffContent entity contains information about the content differences in
+ * a file.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-content
+ */
+export declare interface DiffContent {
+  /** Content only in the file on side A (deleted in B). */
+  a?: string[];
+  /** Content only in the file on side B (added in B). */
+  b?: string[];
+  /** Content in the file on both sides (unchanged). */
+  ab?: string[];
+  /**
+   * Text sections deleted from side A as a DiffIntralineInfo entity.
+   *
+   * Only present during a replace, i.e. both a and b are present.
+   */
+  edit_a?: DiffIntralineInfo[];
+  /**
+   * Text sections inserted in side B as a DiffIntralineInfo entity.
+   *
+   * Only present during a replace, i.e. both a and b are present.
+   */
+  edit_b?: DiffIntralineInfo[];
+  /** Indicates whether this entry was introduced by a rebase. */
+  due_to_rebase?: boolean;
+  /** @deprecated Use move_details instead. */
+  due_to_move?: boolean;
+
+  /**
+   * Provides info about a move operation the chunk.
+   * It's presence indicates the current chunk exists due to a move.
+   */
+  move_details?: {
+    /** Indicates whether the content of the chunk changes while moving code */
+    changed: boolean;
+    /**
+     * Indicates the range (line numbers) on the other side of the comparison
+     * where the code related to the current chunk came from/went to.
+     */
+    range: {
+      start: number;
+      end: number;
+    };
+  };
+  /**
+   * Count of lines skipped on both sides when the file is too large to include
+   * all common lines.
+   */
+  skip?: number;
+  /**
+   * Set to true if the region is common according to the requested
+   * ignore-whitespace parameter, but a and b contain differing amounts of
+   * whitespace. When present and true a and b are used instead of ab.
+   */
+  common?: boolean;
+  // TODO: Undocumented, but used in code.
+  keyLocation?: boolean;
+}
+
+/**
+ * The DiffWebLinkInfo entity describes a link on a diff screen to an external
+ * site.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-web-link-info
+ */
+export declare interface DiffWebLinkInfo {
+  /** The link name. */
+  name: string;
+  /** The link URL. */
+  url: string;
+  /** URL to the icon of the link. */
+  image_url: string;
+  // TODO: Are these really of type string? Not able to trigger them, but the
+  // docs sound more like boolean.
+  show_on_side_by_side_diff_view: string;
+  show_on_unified_diff_view: string;
+}
+/**
+ * The DiffIntralineInfo entity contains information about intraline edits in a
+ * file.
+ *
+ * The information consists of a list of <skip length, mark length> pairs, where
+ * the skip length is the number of characters between the end of the previous
+ * edit and the start of this edit, and the mark length is the number of edited
+ * characters following the skip. The start of the edits is from the beginning
+ * of the related diff content lines.
+ *
+ * Note that the implied newline character at the end of each line is included
+ * in the length calculation, and thus it is possible for the edits to span
+ * newlines.
+ */
+export declare type SkipLength = number;
+export declare type MarkLength = number;
+export declare type DiffIntralineInfo = [SkipLength, MarkLength];
+
+/**
+ * The WebLinkInfo entity describes a link to an external site.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#web-link-info
+ */
+export declare interface WebLinkInfo {
+  /** The link name. */
+  name: string;
+  /** The link URL. */
+  url: string;
+  /** URL to the icon of the link. */
+  image_url: string;
+}
+
+/**
+ * The DiffPreferencesInfo entity contains information about the diff
+ * preferences of a user.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#diff-preferences-info
+ */
+export declare interface DiffPreferencesInfo {
+  context: number;
+  expand_all_comments?: boolean;
+  ignore_whitespace: IgnoreWhitespaceType;
+  intraline_difference?: boolean;
+  line_length: number;
+  cursor_blink_rate: number;
+  manual_review?: boolean;
+  retain_header?: boolean;
+  show_line_endings?: boolean;
+  show_tabs?: boolean;
+  show_whitespace_errors?: boolean;
+  skip_deleted?: boolean;
+  skip_uncommented?: boolean;
+  syntax_highlighting?: boolean;
+  hide_top_menu?: boolean;
+  auto_hide_diff_table_header?: boolean;
+  hide_line_numbers?: boolean;
+  tab_size: number;
+  font_size: number;
+  hide_empty_pane?: boolean;
+  match_brackets?: boolean;
+  line_wrapping?: boolean;
+  // TODO(TS): show_file_comment_button exists in JS code, but doesn't exist in
+  // the doc. Either remove or update doc
+  show_file_comment_button?: boolean;
+  // TODO(TS): theme exists in JS code, but doesn't exist in the doc.
+  // Either remove or update doc
+  theme?: string;
+}
+
+export declare type DiffPreferencesInfoKey = keyof DiffPreferencesInfo;
+
+/**
+ * Whether whitespace changes should be ignored and if yes, which whitespace
+ * changes should be ignored
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#diff-preferences-input
+ */
+export declare type IgnoreWhitespaceType =
+  | 'IGNORE_NONE'
+  | 'IGNORE_TRAILING'
+  | 'IGNORE_LEADING_AND_TRAILING'
+  | 'IGNORE_ALL';
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
new file mode 100644
index 0000000..529904a
--- /dev/null
+++ b/polygerrit-ui/app/types/events.ts
@@ -0,0 +1,174 @@
+/**
+ * @license
+ * Copyright (C) 2020 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.
+ */
+import {EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {PatchSetNum} from './common';
+import {UIComment} from '../utils/comment-util';
+
+export interface TitleChangeEventDetail {
+  title: string;
+}
+
+export type TitleChangeEvent = CustomEvent<TitleChangeEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'title-change': TitleChangeEvent;
+  }
+}
+
+export interface PageErrorEventDetail {
+  response: Response;
+}
+
+export type PageErrorEvent = CustomEvent<PageErrorEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'page-error': PageErrorEvent;
+  }
+}
+
+export interface LocationChangeEventDetail {
+  hash: string;
+  pathname: string;
+}
+
+export type LocationChangeEvent = CustomEvent<LocationChangeEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'location-change': LocationChangeEvent;
+  }
+}
+
+export interface RpcLogEventDetail {
+  status: number | null;
+  method: string;
+  elapsed: number;
+  anonymizedUrl: string;
+}
+
+export type RpcLogEvent = CustomEvent<RpcLogEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'rpc-log': RpcLogEvent;
+  }
+}
+
+export interface ShortcutTriggeredEventDetail {
+  event: CustomKeyboardEvent;
+  goKey: boolean;
+  vKey: boolean;
+}
+
+export type ShortcutTriggeredEvent = CustomEvent<ShortcutTriggeredEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'shortcut-triggered': ShortcutTriggeredEvent;
+  }
+}
+
+export interface EditableContentSaveEventDetail {
+  content: string;
+}
+
+export type EditableContentSaveEvent = CustomEvent<
+  EditableContentSaveEventDetail
+>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'editable-content-save': EditableContentSaveEvent;
+  }
+}
+
+export interface OpenFixPreviewEventDetail {
+  patchNum?: PatchSetNum;
+  comment?: UIComment;
+}
+
+export type OpenFixPreviewEvent = CustomEvent<OpenFixPreviewEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'open-fix-preview': OpenFixPreviewEvent;
+  }
+}
+
+// Type for the custom event to switch tab.
+interface SwitchTabEventDetail {
+  // name of the tab to set as active, from custom event
+  tab?: string;
+  // index of tab to set as active, from paper-tabs event
+  value?: number;
+  // scroll into the tab afterwards, from custom event
+  scrollIntoView?: boolean;
+}
+
+export type SwitchTabEvent = CustomEvent<SwitchTabEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'show-primary-tab': SwitchTabEvent;
+    'show-secondary-tab': SwitchTabEvent;
+  }
+}
+
+export interface ReloadEventDetail {
+  clearPatchset: boolean;
+}
+
+export type ReloadEvent = CustomEvent<ReloadEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    reload: ReloadEvent;
+  }
+}
+
+export interface ShowAlertEventDetail {
+  message: string;
+}
+
+export type ShowAlertEvent = CustomEvent<ShowAlertEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'show-alert': ShowAlertEvent;
+  }
+}
+
+/**
+ * Keyboard events emitted from polymer elements.
+ */
+export interface CustomKeyboardEvent extends CustomEvent, EventApi {
+  event: CustomKeyboardEvent;
+  detail: {
+    keyboardEvent?: CustomKeyboardEvent;
+    // TODO(TS): maybe should mark as optional and check before accessing
+    key: string;
+  };
+  readonly altKey: boolean;
+  readonly changedTouches: TouchList;
+  readonly ctrlKey: boolean;
+  readonly metaKey: boolean;
+  readonly shiftKey: boolean;
+  readonly keyCode: number;
+  readonly repeat: boolean;
+}
diff --git a/polygerrit-ui/app/types/globals.ts b/polygerrit-ui/app/types/globals.ts
index 645c991..28cac87 100644
--- a/polygerrit-ui/app/types/globals.ts
+++ b/polygerrit-ui/app/types/globals.ts
@@ -23,12 +23,6 @@
   interface Window {
     CANONICAL_PATH?: string;
     INITIAL_DATA?: {[key: string]: ParsedJSON};
-    ShadyCSS?: {
-      getComputedStyleValue(el: Element, name: string): string;
-    };
-    ShadyDOM?: {
-      inUse?: boolean;
-    };
     HTMLImports?: {whenReady: (cb: () => void) => void};
     linkify(
       text: string,
@@ -36,9 +30,22 @@
     ): void;
     ASSETS_PATH?: string;
     // TODO(TS): define gerrit type
-    Gerrit?: any;
+    Gerrit?: {
+      Nav?: unknown;
+      getRootElement?: unknown;
+      Auth?: unknown;
+      _pluginLoader?: unknown;
+      _endpoints?: unknown;
+      slotToContent?: unknown;
+      rangesEqual?: unknown;
+      SUGGESTIONS_PROVIDERS_USERS_TYPES?: unknown;
+      RevisionInfo?: unknown;
+      CoverageType?: unknown;
+      hiddenscroll?: unknown;
+      flushPreinstalls?: () => void;
+    };
     // TODO(TS): define polymer type
-    Polymer?: unknown;
+    Polymer?: {importHref?: unknown};
     // TODO(TS): remove page when better workaround is found
     // page shouldn't be exposed in window and it shouldn't be used
     // it's defined because of limitations from typescript, which don't import .mjs
@@ -52,54 +59,60 @@
     };
     STATIC_RESOURCE_PATH?: string;
 
+    PRELOADED_QUERIES?: {
+      dashboardQuery?: string[];
+    };
+
+    VERSION_INFO?: string;
+
     /** Enhancements on Gr elements or utils */
     // TODO(TS): should clean up those and removing them may break certain plugin behaviors
     // TODO(TS): as @brohlfs suggested, to avoid importing anything from elements/ to types/
     // use any for them for now
-    GrDisplayNameUtils: any;
-    GrAnnotation: any;
-    GrAttributeHelper: any;
-    GrDiffLine: any;
-    GrDiffLineType: any;
-    GrDiffGroup: any;
-    GrDiffGroupType: any;
-    GrDiffBuilder: any;
-    GrDiffBuilderSideBySide: any;
-    GrDiffBuilderImage: any;
-    GrDiffBuilderUnified: any;
-    GrDiffBuilderBinary: any;
-    GrChangeActionsInterface: any;
-    GrChangeReplyInterface: any;
-    GrEditConstants: any;
-    GrDomHooksManager: any;
-    GrDomHook: any;
-    GrEtagDecorator: any;
-    GrThemeApi: any;
-    SiteBasedCache: any;
-    FetchPromisesCache: any;
-    GrRestApiHelper: any;
-    GrLinkTextParser: any;
-    GrPluginEndpoints: any;
-    GrReviewerUpdatesParser: any;
-    GrPopupInterface: any;
-    GrCountStringFormatter: any;
-    GrReviewerSuggestionsProvider: any;
-    util: any;
-    Auth: any;
-    EventEmitter: any;
-    GrAdminApi: any;
-    GrAnnotationActionsContext: any;
-    GrAnnotationActionsInterface: any;
-    GrChangeMetadataApi: any;
-    GrEmailSuggestionsProvider: any;
-    GrGroupSuggestionsProvider: any;
-    GrEventHelper: any;
-    GrPluginRestApi: any;
-    GrRepoApi: any;
-    GrSettingsApi: any;
-    GrStylesApi: any;
-    PluginLoader: any;
-    GrPluginActionContext: any;
+    GrDisplayNameUtils: unknown;
+    GrAnnotation: unknown;
+    GrAttributeHelper: unknown;
+    GrDiffLine: unknown;
+    GrDiffLineType: unknown;
+    GrDiffGroup: unknown;
+    GrDiffGroupType: unknown;
+    GrDiffBuilder: unknown;
+    GrDiffBuilderSideBySide: unknown;
+    GrDiffBuilderImage: unknown;
+    GrDiffBuilderUnified: unknown;
+    GrDiffBuilderBinary: unknown;
+    GrChangeActionsInterface: unknown;
+    GrChangeReplyInterface: unknown;
+    GrEditConstants: unknown;
+    GrDomHooksManager: unknown;
+    GrDomHook: unknown;
+    GrEtagDecorator: unknown;
+    GrThemeApi: unknown;
+    SiteBasedCache: unknown;
+    FetchPromisesCache: unknown;
+    GrRestApiHelper: unknown;
+    GrLinkTextParser: unknown;
+    GrPluginEndpoints: unknown;
+    GrReviewerUpdatesParser: unknown;
+    GrPopupInterface: unknown;
+    GrCountStringFormatter: unknown;
+    GrReviewerSuggestionsProvider: unknown;
+    util: unknown;
+    Auth: unknown;
+    EventEmitter: unknown;
+    GrAdminApi: unknown;
+    GrAnnotationActionsContext: unknown;
+    GrAnnotationActionsInterface: unknown;
+    GrChangeMetadataApi: unknown;
+    GrEmailSuggestionsProvider: unknown;
+    GrGroupSuggestionsProvider: unknown;
+    GrEventHelper: unknown;
+    GrPluginRestApi: unknown;
+    GrRepoApi: unknown;
+    GrSettingsApi: unknown;
+    GrStylesApi: unknown;
+    PluginLoader: unknown;
+    GrPluginActionContext: unknown;
     _apiUtils: {};
   }
 
@@ -112,4 +125,16 @@
       usedJSHeapSize: number;
     };
   }
+
+  interface Event {
+    // path is a non-standard property. Actually, this is optional property,
+    // but marking it as optional breaks CustomKeyboardEvent
+    // TODO(TS): replace with composedPath if possible
+    readonly path: EventTarget[];
+  }
+
+  interface Error {
+    lineNumber?: number; // non-standard property
+    columnNumber?: number; // non-standard property
+  }
 }
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index 3768391..b40d618 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -14,12 +14,19 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {Side} from '../constants/constants';
+import {DiffViewMode, Side} from '../constants/constants';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
 import {GrDiffLine} from '../elements/diff/gr-diff/gr-diff-line';
 import {FlattenedNodesObserver} from '@polymer/polymer/lib/utils/flattened-nodes-observer';
 import {PaperInputElement} from '@polymer/paper-input/paper-input';
-import {CommitId} from './common';
+import {
+  ChangeId,
+  CommitId,
+  NumericChangeId,
+  PatchRange,
+  PatchSetNum,
+} from './common';
+import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
 
 export function notUndefined<T>(x: T | undefined): x is T {
   return x !== undefined;
@@ -165,3 +172,68 @@
   addListener?(listener: DiffLayerListener): void;
   removeListener?(listener: DiffLayerListener): void;
 }
+
+export interface ChangeViewState {
+  changeNum: NumericChangeId | null;
+  patchRange: PatchRange | null;
+  selectedFileIndex: number;
+  showReplyDialog: boolean;
+  showDownloadDialog: boolean;
+  diffMode: DiffViewMode | null;
+  numFilesShown: number | null;
+  scrollTop?: number;
+  diffViewMode?: boolean;
+}
+
+export interface ChangeListViewState {
+  changeNum?: ChangeId;
+  patchRange?: PatchRange;
+  // TODO(TS): seems only one of 2 selected... is required
+  selectedFileIndex?: number;
+  selectedChangeIndex?: number;
+  showReplyDialog?: boolean;
+  showDownloadDialog?: boolean;
+  diffMode?: DiffViewMode;
+  numFilesShown?: number;
+  scrollTop?: number;
+  query?: string | null;
+  offset?: number;
+}
+
+export interface DashboardViewState {
+  selectedChangeIndex: number;
+}
+
+export interface ViewState {
+  changeView: ChangeViewState;
+  changeListView: ChangeListViewState;
+  dashboardView: DashboardViewState;
+}
+
+export interface PatchSetFile {
+  path: string;
+  basePath?: string;
+  patchNum?: PatchSetNum;
+}
+
+export interface PatchNumOnly {
+  patchNum: PatchSetNum;
+}
+
+export function isPatchSetFile(
+  x: PatchSetFile | PatchNumOnly
+): x is PatchSetFile {
+  return !!(x as PatchSetFile).path;
+}
+
+export interface FileRange {
+  basePath?: string;
+  path: string;
+}
+
+export function isPolymerSpliceChange<
+  T,
+  U extends Array<{} | null | undefined>
+>(x: T | PolymerSpliceChange<U>): x is PolymerSpliceChange<U> {
+  return (x as PolymerSpliceChange<U>).indexSplices !== undefined;
+}
diff --git a/polygerrit-ui/app/utils/access-util_test.js b/polygerrit-ui/app/utils/access-util_test.ts
similarity index 66%
rename from polygerrit-ui/app/utils/access-util_test.js
rename to polygerrit-ui/app/utils/access-util_test.ts
index 209c2ff..f098d89 100644
--- a/polygerrit-ui/app/utils/access-util_test.js
+++ b/polygerrit-ui/app/utils/access-util_test.ts
@@ -15,28 +15,37 @@
  * limitations under the License.
  */
 
-import '../test/common-test-setup-karma.js';
-import {toSortedPermissionsArray} from './access-util.js';
+import '../test/common-test-setup-karma';
+import {toSortedPermissionsArray} from './access-util';
 
 suite('access-util tests', () => {
   test('toSortedPermissionsArray', () => {
     const rules = {
       'global:Project-Owners': {
-        action: 'ALLOW', force: false,
+        action: 'ALLOW',
+        force: false,
       },
       '4c97682e6ce6b7247f3381b6f1789356666de7f': {
-        action: 'ALLOW', force: false,
+        action: 'ALLOW',
+        force: false,
       },
     };
     const expectedResult = [
-      {id: '4c97682e6ce6b7247f3381b6f1789356666de7f', value: {
-        action: 'ALLOW', force: false,
-      }},
-      {id: 'global:Project-Owners', value: {
-        action: 'ALLOW', force: false,
-      }},
+      {
+        id: '4c97682e6ce6b7247f3381b6f1789356666de7f',
+        value: {
+          action: 'ALLOW',
+          force: false,
+        },
+      },
+      {
+        id: 'global:Project-Owners',
+        value: {
+          action: 'ALLOW',
+          force: false,
+        },
+      },
     ];
     assert.deepEqual(toSortedPermissionsArray(rules), expectedResult);
   });
 });
-
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index 4504ffd..7e425f8 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -15,13 +15,7 @@
  * limitations under the License.
  */
 
-import {
-  AccountId,
-  AccountInfo,
-  ChangeInfo,
-  EmailAddress,
-  ServerInfo,
-} from '../types/common';
+import {AccountId, AccountInfo, EmailAddress} from '../types/common';
 import {AccountTag} from '../constants/constants';
 
 export function accountKey(account: AccountInfo): AccountId | EmailAddress {
@@ -37,24 +31,3 @@
 export function removeServiceUsers(accounts?: AccountInfo[]): AccountInfo[] {
   return accounts?.filter(a => !isServiceUser(a)) || [];
 }
-
-export function isAttentionSetEnabled(config: ServerInfo): boolean {
-  return !!config?.change?.enable_attention_set;
-}
-
-export function canHaveAttention(account: AccountInfo): boolean {
-  return !!account && !!account._account_id && !isServiceUser(account);
-}
-
-export function hasAttention(
-  config: ServerInfo,
-  account: AccountInfo,
-  change: ChangeInfo
-): boolean {
-  return (
-    isAttentionSetEnabled(config) &&
-    canHaveAttention(account) &&
-    !!account._account_id &&
-    !!change?.attention_set?.hasOwnProperty(account._account_id)
-  );
-}
diff --git a/polygerrit-ui/app/utils/admin-nav-util.ts b/polygerrit-ui/app/utils/admin-nav-util.ts
index bb95066..06f4e3a 100644
--- a/polygerrit-ui/app/utils/admin-nav-util.ts
+++ b/polygerrit-ui/app/utils/admin-nav-util.ts
@@ -24,7 +24,7 @@
   RepoName,
   GroupId,
   AccountDetailInfo,
-  CapabilityInfo,
+  AccountCapabilityInfo,
 } from '../types/common';
 import {MenuLink} from '../elements/plugins/gr-admin-api/gr-admin-api';
 import {hasOwnProperty} from './common-util';
@@ -54,12 +54,27 @@
   },
 ];
 
+export interface AdminLink {
+  url: string;
+  text: string;
+  capability: string | null;
+  noBaseUrl: boolean;
+  view: null;
+  viewableToAll: boolean;
+  target: '_blank' | null;
+}
+
+export interface AdminLinks {
+  links: NavLink[];
+  expandedSection?: SubsectionInterface;
+}
+
 export function getAdminLinks(
-  account: AccountDetailInfo,
-  getAccountCapabilities: (params?: string[]) => Promise<CapabilityInfo>,
+  account: AccountDetailInfo | undefined,
+  getAccountCapabilities: () => Promise<AccountCapabilityInfo>,
   getAdminMenuLinks: () => MenuLink[],
   options?: AdminNavLinksOption
-) {
+): Promise<AdminLinks> {
   if (!account) {
     return Promise.resolve(
       _filterLinks(link => !!link.viewableToAll, getAdminMenuLinks, options)
@@ -78,9 +93,9 @@
   filterFn: (link: NavLink) => boolean,
   getAdminMenuLinks: () => MenuLink[],
   options?: AdminNavLinksOption
-) {
-  let links = ADMIN_LINKS.slice(0);
-  let expandedSection;
+): AdminLinks {
+  let links: NavLink[] = ADMIN_LINKS.slice(0);
+  let expandedSection: SubsectionInterface | undefined = undefined;
 
   const isExternalLink = (link: MenuLink) => link.url[0] !== '/';
 
@@ -90,18 +105,18 @@
       return {
         url: link.url,
         name: link.text,
-        capability: link.capability || null,
+        capability: link.capability || undefined,
         noBaseUrl: !isExternalLink(link),
         view: null,
         viewableToAll: !link.capability,
         target: isExternalLink(link) ? '_blank' : null,
-      } as NavLink;
+      };
     })
   );
 
   links = links.filter(filterFn);
 
-  const filteredLinks = [];
+  const filteredLinks: NavLink[] = [];
   const repoName = options && options.repoName;
   const groupId = options && options.groupId;
   const groupName = options && options.groupName;
diff --git a/polygerrit-ui/app/utils/async-util.ts b/polygerrit-ui/app/utils/async-util.ts
index 7e63f70..119b09b 100644
--- a/polygerrit-ui/app/utils/async-util.ts
+++ b/polygerrit-ui/app/utils/async-util.ts
@@ -26,7 +26,7 @@
  */
 export function asyncForeach<T>(
   array: T[],
-  fn: (item: T, stopCallback: () => void) => Promise<T>
+  fn: (item: T, stopCallback: () => void) => Promise<unknown>
 ): Promise<T | void> {
   if (!array.length) {
     return Promise.resolve();
diff --git a/polygerrit-ui/app/utils/attention-set-util.ts b/polygerrit-ui/app/utils/attention-set-util.ts
new file mode 100644
index 0000000..b0aefcb
--- /dev/null
+++ b/polygerrit-ui/app/utils/attention-set-util.ts
@@ -0,0 +1,63 @@
+/**
+ * @license
+ * Copyright (C) 2020 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.
+ */
+
+import {AccountInfo, ChangeInfo} from '../types/common';
+import {isServiceUser} from './account-util';
+
+// You would typically use a ServerInfo here, but this utility does not care
+// about all the other parameters in that object.
+interface SimpleServerInfo {
+  change?: {
+    enable_attention_set?: boolean;
+  };
+}
+
+const CONFIG_ENABLED: SimpleServerInfo = {
+  change: {enable_attention_set: true},
+};
+
+export function isAttentionSetEnabled(config?: SimpleServerInfo): boolean {
+  return !!config?.change?.enable_attention_set;
+}
+
+export function canHaveAttention(account?: AccountInfo): boolean {
+  return !!account?._account_id && !isServiceUser(account);
+}
+
+export function hasAttention(
+  config?: SimpleServerInfo,
+  account?: AccountInfo,
+  change?: ChangeInfo
+): boolean {
+  return (
+    isAttentionSetEnabled(config) &&
+    canHaveAttention(account) &&
+    !!change?.attention_set?.hasOwnProperty(account!._account_id!)
+  );
+}
+
+export function getReason(account?: AccountInfo, change?: ChangeInfo) {
+  if (!hasAttention(CONFIG_ENABLED, account, change)) return '';
+  const entry = change!.attention_set![account!._account_id!];
+  return entry?.reason ? entry.reason : '';
+}
+
+export function getLastUpdate(account?: AccountInfo, change?: ChangeInfo) {
+  if (!hasAttention(CONFIG_ENABLED, account, change)) return '';
+  const entry = change!.attention_set![account!._account_id!];
+  return entry?.last_update ? entry.last_update : '';
+}
diff --git a/polygerrit-ui/app/utils/attention-set-util_test.js b/polygerrit-ui/app/utils/attention-set-util_test.js
new file mode 100644
index 0000000..71735d5
--- /dev/null
+++ b/polygerrit-ui/app/utils/attention-set-util_test.js
@@ -0,0 +1,57 @@
+/**
+ * @license
+ * Copyright (C) 2020 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.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {
+  hasAttention, getReason,
+} from './attention-set-util.js';
+
+const KERMIT = {
+  email: 'kermit@gmail.com',
+  username: 'kermit',
+  name: 'Kermit The Frog',
+  _account_id: '31415926535',
+};
+
+suite('attention-set-util', () => {
+  test('hasAttention', () => {
+    const config = {
+      change: {enable_attention_set: true},
+    };
+    const change = {
+      attention_set: {
+        31415926535: {
+          reason: 'a good reason',
+        },
+      },
+    };
+
+    assert.isTrue(hasAttention(config, KERMIT, change));
+  });
+
+  test('getReason', () => {
+    const change = {
+      attention_set: {
+        31415926535: {
+          reason: 'a good reason',
+        },
+      },
+    };
+
+    assert.equal(getReason(KERMIT, change), 'a good reason');
+  });
+});
diff --git a/polygerrit-ui/app/utils/change-metadata-util.ts b/polygerrit-ui/app/utils/change-metadata-util.ts
new file mode 100644
index 0000000..6ce1483
--- /dev/null
+++ b/polygerrit-ui/app/utils/change-metadata-util.ts
@@ -0,0 +1,75 @@
+/**
+ * @license
+ * Copyright (C) 2020 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.
+ */
+
+import {ParsedChangeInfo} from '../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+
+export enum Metadata {
+  OWNER = 'Owner',
+  REVIEWERS = 'Reviewers',
+  REPO_BRANCH = 'Repo | Branch',
+  SUBMITTED = 'Submitted',
+  PARENT = 'Parent',
+  STRATEGY = 'Strategy',
+  UPDATED = 'Updated',
+  CC = 'CC',
+  HASHTAGS = 'Hashtags',
+  TOPIC = 'Topic',
+  UPLOADER = 'Uploader',
+  AUTHOR = 'Author',
+  COMMITTER = 'Committer',
+  ASSIGNEE = 'Assignee',
+  CHERRY_PICK_OF = 'Cherry pick of',
+}
+
+export const DisplayRules = {
+  ALWAYS_SHOW: [
+    Metadata.OWNER,
+    Metadata.REVIEWERS,
+    Metadata.REPO_BRANCH,
+    Metadata.SUBMITTED,
+  ],
+  SHOW_IF_SET: [
+    Metadata.CC,
+    Metadata.HASHTAGS,
+    Metadata.TOPIC,
+    Metadata.UPLOADER,
+    Metadata.AUTHOR,
+    Metadata.COMMITTER,
+    Metadata.ASSIGNEE,
+    Metadata.CHERRY_PICK_OF,
+  ],
+  ALWAYS_HIDE: [Metadata.PARENT, Metadata.STRATEGY, Metadata.UPDATED],
+};
+
+export function isSectionSet(section: Metadata, change?: ParsedChangeInfo) {
+  switch (section) {
+    case Metadata.CC:
+      return !!change?.reviewers?.CC?.length;
+    case Metadata.HASHTAGS:
+      return !!change?.hashtags?.length;
+    case Metadata.TOPIC:
+      return !!change?.topic;
+    case Metadata.UPLOADER:
+    case Metadata.AUTHOR:
+    case Metadata.COMMITTER:
+    case Metadata.ASSIGNEE:
+      return false;
+    case Metadata.CHERRY_PICK_OF:
+      return !!change?.cherry_pick_of_change;
+  }
+  return true;
+}
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index 2c9d9c3..47924e6 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -16,7 +16,12 @@
  */
 import {getBaseUrl} from './url-util';
 import {ChangeStatus} from '../constants/constants';
-import {NumericChangeId, PatchSetNum, ChangeInfo} from '../types/common';
+import {
+  NumericChangeId,
+  PatchSetNum,
+  ChangeInfo,
+  AccountInfo,
+} from '../types/common';
 import {ParsedChangeInfo} from '../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 
 // This can be wrong! See WARNING above
@@ -167,6 +172,23 @@
   return states;
 }
 
+export function isOwner(change?: ChangeInfo, account?: AccountInfo) {
+  if (!change || !account) return false;
+  return change.owner?._account_id === account._account_id;
+}
+
 export function changeStatusString(change: ChangeInfo) {
   return changeStatuses(change).join(', ');
 }
+
+export function isRemovableReviewer(
+  change?: ChangeInfo,
+  reviewer?: AccountInfo
+): boolean {
+  if (!change?.removable_reviewers || !reviewer) return false;
+  return change.removable_reviewers.some(
+    account =>
+      account._account_id === reviewer._account_id ||
+      (!reviewer._account_id && account.email === reviewer.email)
+  );
+}
diff --git a/polygerrit-ui/app/utils/change-util_test.js b/polygerrit-ui/app/utils/change-util_test.js
index 20b9578..fd181fe 100644
--- a/polygerrit-ui/app/utils/change-util_test.js
+++ b/polygerrit-ui/app/utils/change-util_test.js
@@ -21,6 +21,7 @@
   changePath,
   changeStatuses,
   changeStatusString,
+  isRemovableReviewer,
 } from './change-util.js';
 
 suite('change-util tests', () => {
@@ -198,5 +199,19 @@
     assert.deepEqual(statuses, ['Merge Conflict', 'WIP', 'Private']);
     assert.equal(statusString, 'Merge Conflict, WIP, Private');
   });
+
+  test('isRemovableReviewer', () => {
+    let change = {
+      removable_reviewers: [{_account_id: 1}],
+    };
+    const reviewer = {_account_id: 1};
+
+    assert.equal(isRemovableReviewer(change, reviewer), true);
+
+    change = {
+      removable_reviewers: [{_account_id: 2}],
+    };
+    assert.equal(isRemovableReviewer(change, reviewer), false);
+  });
 });
 
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
new file mode 100644
index 0000000..5af9bb7
--- /dev/null
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -0,0 +1,167 @@
+/**
+ * @license
+ * Copyright (C) 2020 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.
+ */
+import {
+  CommentBasics,
+  CommentInfo,
+  PatchSetNum,
+  RobotCommentInfo,
+  Timestamp,
+  UrlEncodedCommentId,
+  CommentRange,
+} from '../types/common';
+import {CommentSide, Side} from '../constants/constants';
+import {parseDate} from './date-util';
+import {LineNumber} from '../elements/diff/gr-diff/gr-diff-line';
+import {CommentIdToCommentThreadMap} from '../elements/diff/gr-comment-api/gr-comment-api';
+
+export interface DraftCommentProps {
+  __draft?: boolean;
+  __draftID?: string;
+  __date?: Date;
+}
+
+export type DraftInfo = CommentBasics & DraftCommentProps;
+
+/**
+ * Each of the type implements or extends CommentBasics.
+ */
+export type Comment = DraftInfo | CommentInfo | RobotCommentInfo;
+
+export interface UIStateCommentProps {
+  // The `side` of the comment is PARENT or REVISION, but this is LEFT or RIGHT.
+  // TODO(TS): Remove the naming confusion of commentSide being of type of Side,
+  // but side being of type CommentSide. :-)
+  __commentSide?: Side;
+  // TODO(TS): Remove this. Seems to be exactly the same as `path`??
+  __path?: string;
+  collapsed?: boolean;
+  // TODO(TS): Consider allowing this only for drafts.
+  __editing?: boolean;
+  __otherEditing?: boolean;
+}
+
+export type UIDraft = DraftInfo & UIStateCommentProps;
+
+export type UIHuman = CommentInfo & UIStateCommentProps;
+
+export type UIRobot = RobotCommentInfo & UIStateCommentProps;
+
+export type UIComment = UIHuman | UIRobot | UIDraft;
+
+export type CommentMap = {[path: string]: boolean};
+
+export function isRobot<T extends CommentInfo>(
+  x: T | DraftInfo | RobotCommentInfo | undefined
+): x is RobotCommentInfo {
+  return !!x && !!(x as RobotCommentInfo).robot_id;
+}
+
+export function isDraft<T extends CommentInfo>(
+  x: T | UIDraft | undefined
+): x is UIDraft {
+  return !!x && !!(x as UIDraft).__draft;
+}
+
+interface SortableComment {
+  __draft?: boolean;
+  __date?: Date;
+  updated?: Timestamp;
+  id?: UrlEncodedCommentId;
+}
+
+export function sortComments<T extends SortableComment>(comments: T[]): T[] {
+  return comments.slice(0).sort((c1, c2) => {
+    const d1 = !!c1.__draft;
+    const d2 = !!c2.__draft;
+    if (d1 !== d2) return d1 ? 1 : -1;
+
+    const date1 = (c1.updated && parseDate(c1.updated)) || c1.__date;
+    const date2 = (c2.updated && parseDate(c2.updated)) || c2.__date;
+    const dateDiff = date1!.valueOf() - date2!.valueOf();
+    if (dateDiff !== 0) return dateDiff;
+
+    const id1 = c1.id ?? '';
+    const id2 = c2.id ?? '';
+    return id1.localeCompare(id2);
+  });
+}
+
+export function createCommentThreads(comments: UIComment[]) {
+  const sortedComments = sortComments(comments);
+  const threads: CommentThread[] = [];
+  const idThreadMap: CommentIdToCommentThreadMap = {};
+  for (const comment of sortedComments) {
+    if (!comment.id) continue;
+    // If the comment is in reply to another comment, find that comment's
+    // thread and append to it.
+    if (comment.in_reply_to) {
+      const thread = idThreadMap[comment.in_reply_to];
+      if (thread) {
+        thread.comments.push(comment);
+        idThreadMap[comment.id] = thread;
+        continue;
+      }
+    }
+
+    // Otherwise, this comment starts its own thread.
+    if (!comment.__path && !comment.path) {
+      throw new Error('Comment missing required "path".');
+    }
+    const newThread: CommentThread = {
+      comments: [comment],
+      patchNum: comment.patch_set,
+      commentSide: comment.side ?? CommentSide.REVISION,
+      path: comment.__path || comment.path!,
+      line: comment.line,
+      range: comment.range,
+      rootId: comment.id,
+      diffSide: comment.__commentSide,
+    };
+    if (!comment.line && !comment.range) {
+      newThread.line = 'FILE';
+    }
+    threads.push(newThread);
+    idThreadMap[comment.id] = newThread;
+  }
+  return threads;
+}
+
+export interface CommentThread {
+  comments: UIComment[];
+  path: string;
+  commentSide: CommentSide;
+  patchNum?: PatchSetNum;
+  line?: LineNumber;
+  /* rootId is optional since we create a empty comment thread element for
+     drafts and then create the draft which becomes the root */
+  rootId?: UrlEncodedCommentId;
+  diffSide?: Side;
+  range?: CommentRange;
+}
+
+export function getLastComment(thread?: CommentThread): UIComment | undefined {
+  const len = thread?.comments.length;
+  return thread && len ? thread.comments[len - 1] : undefined;
+}
+
+export function isUnresolved(thread?: CommentThread): boolean {
+  return !!getLastComment(thread)?.unresolved;
+}
+
+export function isDraftThread(thread?: CommentThread): boolean {
+  return isDraft(getLastComment(thread));
+}
diff --git a/polygerrit-ui/app/utils/comment-util_test.js b/polygerrit-ui/app/utils/comment-util_test.js
new file mode 100644
index 0000000..ad19974
--- /dev/null
+++ b/polygerrit-ui/app/utils/comment-util_test.js
@@ -0,0 +1,34 @@
+/**
+ * @license
+ * Copyright (C) 2020 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.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {
+  isUnresolved,
+} from './comment-util.js';
+
+suite('comment-util', () => {
+  test('isUnresolved', () => {
+    assert.isFalse(isUnresolved(undefined));
+    assert.isFalse(isUnresolved({comments: []}));
+    assert.isTrue(isUnresolved({comments: [{unresolved: true}]}));
+    assert.isFalse(isUnresolved({comments: [{unresolved: false}]}));
+    assert.isTrue(isUnresolved(
+        {comments: [{unresolved: false}, {unresolved: true}]}));
+    assert.isFalse(isUnresolved(
+        {comments: [{unresolved: true}, {unresolved: false}]}));
+  });
+});
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index d144272..5b332ea 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -33,7 +33,8 @@
 }
 
 // TODO(TS): move to common types once we have type utils
-// tslint:disable-next-line:no-any Required for constructor signature.
+//  Required for constructor signature.
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
 export type Constructor<T> = new (...args: any[]) => T;
 
 /**
@@ -44,3 +45,25 @@
   console.error(msg, obj);
   throw new Error(msg);
 }
+
+/**
+ * Returns true, if both sets contain the same members.
+ */
+export function areSetsEqual<T>(a: Set<T>, b: Set<T>): boolean {
+  if (a.size !== b.size) {
+    return false;
+  }
+  return containsAll(a, b);
+}
+
+/**
+ * Returns true, if 'set' contains 'subset'.
+ */
+export function containsAll<T>(set: Set<T>, subSet: Set<T>): boolean {
+  for (const value of subSet) {
+    if (!set.has(value)) {
+      return false;
+    }
+  }
+  return true;
+}
diff --git a/polygerrit-ui/app/utils/common-util_test.js b/polygerrit-ui/app/utils/common-util_test.js
index 60c0b0a..917d652b 100644
--- a/polygerrit-ui/app/utils/common-util_test.js
+++ b/polygerrit-ui/app/utils/common-util_test.js
@@ -16,7 +16,7 @@
  */
 
 import '../test/common-test-setup-karma.js';
-import {hasOwnProperty} from './common-util.js';
+import {hasOwnProperty, areSetsEqual, containsAll} from './common-util.js';
 
 suite('common-util tests', () => {
   suite('hasOwnProperty', () => {
@@ -41,4 +41,29 @@
       assert.isFalse(hasOwnProperty(obj, 'def'));
     });
   });
+
+  test('areSetsEqual', () => {
+    assert.isTrue(areSetsEqual(new Set(), new Set()));
+    assert.isTrue(areSetsEqual(new Set([1]), new Set([1])));
+    assert.isTrue(areSetsEqual(new Set([1, 1, 1, 1]), new Set([1])));
+    assert.isTrue(areSetsEqual(new Set([1, 1, 2, 2]), new Set([2, 1, 2, 1])));
+    assert.isTrue(areSetsEqual(new Set([1, 2, 3, 4]), new Set([4, 3, 2, 1])));
+    assert.isFalse(areSetsEqual(new Set(), new Set([1])));
+    assert.isFalse(areSetsEqual(new Set([1]), new Set([2])));
+    assert.isFalse(areSetsEqual(new Set([1, 2, 4]), new Set([1, 2, 3])));
+  });
+
+  test('containsAll', () => {
+    assert.isTrue(containsAll(new Set(), new Set()));
+    assert.isTrue(containsAll(new Set([1]), new Set()));
+    assert.isTrue(containsAll(new Set([1]), new Set([1])));
+    assert.isTrue(containsAll(new Set([1, 2]), new Set([1])));
+    assert.isTrue(containsAll(new Set([1, 2]), new Set([2])));
+    assert.isTrue(containsAll(new Set([1, 2, 3, 4]), new Set([1, 4])));
+    assert.isTrue(containsAll(new Set([1, 2, 3, 4]), new Set([1, 2, 3, 4])));
+    assert.isFalse(containsAll(new Set(), new Set([2])));
+    assert.isFalse(containsAll(new Set([1]), new Set([2])));
+    assert.isFalse(containsAll(new Set([1, 2, 3, 4]), new Set([5])));
+    assert.isFalse(containsAll(new Set([1, 2, 3, 4]), new Set([1, 2, 3, 5])));
+  });
 });
diff --git a/polygerrit-ui/app/utils/date-util.ts b/polygerrit-ui/app/utils/date-util.ts
index 7748b9d..1dd2d2f 100644
--- a/polygerrit-ui/app/utils/date-util.ts
+++ b/polygerrit-ui/app/utils/date-util.ts
@@ -36,25 +36,29 @@
 }
 
 // similar to fromNow from moment.js
-export function fromNow(date: Date) {
+export function fromNow(date: Date, noAgo = false) {
   const now = new Date();
+  const ago = noAgo ? '' : ' ago';
   const secondsAgo = Math.round((now.valueOf() - date.valueOf()) / 1000);
-  if (secondsAgo <= 44) return 'just now';
-  if (secondsAgo <= 89) return 'a minute ago';
+  if (secondsAgo <= 59) return 'just now';
+  if (secondsAgo <= 119) return `1 minute${ago}`;
   const minutesAgo = Math.round(secondsAgo / 60);
-  if (minutesAgo <= 44) return `${minutesAgo} minutes ago`;
-  if (minutesAgo <= 89) return 'an hour ago';
+  if (minutesAgo <= 59) return `${minutesAgo} minutes${ago}`;
+  if (minutesAgo === 60) return `1 hour${ago}`;
+  if (minutesAgo <= 119) return `1 hour ${minutesAgo - 60} min${ago}`;
   const hoursAgo = Math.round(minutesAgo / 60);
-  if (hoursAgo <= 21) return `${hoursAgo} hours ago`;
-  if (hoursAgo <= 35) return 'a day ago';
+  if (hoursAgo <= 23) return `${hoursAgo} hours${ago}`;
+  if (hoursAgo === 24) return `1 day${ago}`;
+  if (hoursAgo <= 47) return `1 day ${hoursAgo - 24} hr${ago}`;
   const daysAgo = Math.round(hoursAgo / 24);
-  if (daysAgo <= 25) return `${daysAgo} days ago`;
-  if (daysAgo <= 45) return 'a month ago';
+  if (daysAgo <= 30) return `${daysAgo} days${ago}`;
+  if (daysAgo <= 60) return `1 month${ago}`;
   const monthsAgo = Math.round(daysAgo / 30);
-  if (daysAgo <= 319) return `${monthsAgo} months ago`;
-  if (daysAgo <= 547) return 'a year ago';
+  if (monthsAgo <= 11) return `${monthsAgo} months${ago}`;
+  if (monthsAgo === 12) return `1 year${ago}`;
+  if (monthsAgo <= 24) return `1 year ${monthsAgo - 12} m${ago}`;
   const yearsAgo = Math.round(daysAgo / 365);
-  return `${yearsAgo} years ago`;
+  return `${yearsAgo} years${ago}`;
 }
 
 /**
@@ -137,6 +141,7 @@
   if (format.includes('ss')) {
     options.second = '2-digit';
   }
+
   let locale = 'en-US';
   // Workaround for Chrome 80, en-US is using h24 (midnight is 24:00),
   // en-GB is using h23 (midnight is 00:00)
diff --git a/polygerrit-ui/app/utils/date-util_test.js b/polygerrit-ui/app/utils/date-util_test.js
index 7b22cc6..a003c65 100644
--- a/polygerrit-ui/app/utils/date-util_test.js
+++ b/polygerrit-ui/app/utils/date-util_test.js
@@ -39,15 +39,18 @@
       const fakeNow = new Date('May 08 2020 12:00:00');
       sinon.useFakeTimers(fakeNow.getTime());
       assert.equal('just now', fromNow(new Date('May 08 2020 11:59:30')));
-      assert.equal('a minute ago', fromNow(new Date('May 08 2020 11:59:00')));
+      assert.equal('1 minute ago', fromNow(new Date('May 08 2020 11:59:00')));
       assert.equal('5 minutes ago', fromNow(new Date('May 08 2020 11:55:00')));
-      assert.equal('an hour ago', fromNow(new Date('May 08 2020 11:00:00')));
+      assert.equal('1 hour ago', fromNow(new Date('May 08 2020 11:00:00')));
+      assert.equal(
+          '1 hour 5 min ago', fromNow(new Date('May 08 2020 10:55:00')));
       assert.equal('3 hours ago', fromNow(new Date('May 08 2020 9:00:00')));
-      assert.equal('a day ago', fromNow(new Date('May 07 2020 12:00:00')));
+      assert.equal('1 day ago', fromNow(new Date('May 07 2020 12:00:00')));
+      assert.equal('1 day 2 hr ago', fromNow(new Date('May 07 2020 10:00:00')));
       assert.equal('3 days ago', fromNow(new Date('May 05 2020 12:00:00')));
-      assert.equal('a month ago', fromNow(new Date('Apr 05 2020 12:00:00')));
+      assert.equal('1 month ago', fromNow(new Date('Apr 05 2020 12:00:00')));
       assert.equal('2 months ago', fromNow(new Date('Mar 05 2020 12:00:00')));
-      assert.equal('a year ago', fromNow(new Date('May 05 2019 12:00:00')));
+      assert.equal('1 year ago', fromNow(new Date('May 05 2019 12:00:00')));
       assert.equal('10 years ago', fromNow(new Date('May 05 2010 12:00:00')));
     });
   });
@@ -118,4 +121,4 @@
           formatDate(new Date('Jul 03 2013 00:15:00'), timeFormat));
     });
   });
-});
\ No newline at end of file
+});
diff --git a/polygerrit-ui/app/utils/display-name-util.ts b/polygerrit-ui/app/utils/display-name-util.ts
index 21c4e09..7114f98 100644
--- a/polygerrit-ui/app/utils/display-name-util.ts
+++ b/polygerrit-ui/app/utils/display-name-util.ts
@@ -84,5 +84,5 @@
 export const _testOnly_accountEmail = _accountEmail;
 
 export function getGroupDisplayName(group: GroupInfo) {
-  return (group.name || '') + ' (group)';
+  return `${group.name || ''} (group)`;
 }
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index ae6d616..8d02119 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {JsApiService} from '../elements/shared/gr-js-api-interface/gr-js-api-types';
 
@@ -70,27 +69,9 @@
 
 /**
  * Get computed style value.
- *
- * If ShadyCSS is provided, use ShadyCSS api.
- * If `getComputedStyleValue` is provided on the element, use it.
- * Otherwise fallback to native method (in polymer 2).
- *
  */
-export function getComputedStyleValue(
-  name: string,
-  el: Element | LegacyElementMixin
-) {
-  let style;
-  if (window.ShadyCSS) {
-    style = window.ShadyCSS.getComputedStyleValue(el as Element, name);
-    // `getComputedStyleValue` defined through LegacyElementMixin
-    // TODO: It should be safe to just use `getComputedStyle`, but just to be safe
-  } else if ('getComputedStyleValue' in el) {
-    style = el.getComputedStyleValue(name);
-  } else {
-    style = getComputedStyle(el).getPropertyValue(name);
-  }
-  return style;
+export function getComputedStyleValue(name: string, el: Element) {
+  return getComputedStyle(el).getPropertyValue(name).trim();
 }
 
 /**
@@ -211,13 +192,13 @@
 export function descendedFromClass(
   element: Element,
   className: string,
-  opt_stopElement: Element
+  stopElement?: Element
 ) {
   let isDescendant = element.classList.contains(className);
   while (
     !isDescendant &&
     element.parentElement &&
-    (!opt_stopElement || element.parentElement !== opt_stopElement)
+    (!stopElement || element.parentElement !== stopElement)
   ) {
     isDescendant = element.classList.contains(className);
     element = element.parentElement;
@@ -253,3 +234,32 @@
   }
   return _sharedApiEl;
 }
+
+// document.activeElement is not enough, because it's not getting activeElement
+// without looking inside of shadow roots. This will find best activeElement.
+export function findActiveElement(
+  root: DocumentOrShadowRoot | null,
+  ignoreDialogs?: boolean
+): HTMLElement | null {
+  if (root === null) {
+    return null;
+  }
+  if (
+    ignoreDialogs &&
+    root.activeElement &&
+    root.activeElement.nodeName.toUpperCase().includes('DIALOG')
+  ) {
+    return null;
+  }
+  if (root.activeElement?.shadowRoot?.activeElement) {
+    return findActiveElement(root.activeElement.shadowRoot);
+  }
+  if (!root.activeElement) {
+    return null;
+  }
+  // We block some elements
+  if ('BODY' === root.activeElement.nodeName.toUpperCase()) {
+    return null;
+  }
+  return root.activeElement as HTMLElement;
+}
diff --git a/polygerrit-ui/app/utils/event-util.ts b/polygerrit-ui/app/utils/event-util.ts
new file mode 100644
index 0000000..0af8fe2
--- /dev/null
+++ b/polygerrit-ui/app/utils/event-util.ts
@@ -0,0 +1,52 @@
+/**
+ * @license
+ * Copyright (C) 2020 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.
+ */
+
+export enum EventType {
+  SHOW_ALERT = 'show-alert',
+  PAGE_ERROR = 'page-error',
+  TITLE_CHANGE = 'title-change',
+}
+
+export function fireAlert(target: EventTarget, message: string) {
+  target.dispatchEvent(
+    new CustomEvent(EventType.SHOW_ALERT, {
+      detail: {message},
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
+
+export function firePageError(target: EventTarget, response?: Response | null) {
+  target.dispatchEvent(
+    new CustomEvent(EventType.PAGE_ERROR, {
+      detail: {response},
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
+
+export function fireTitleChange(target: EventTarget, title: string) {
+  target.dispatchEvent(
+    new CustomEvent(EventType.TITLE_CHANGE, {
+      detail: {title},
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
diff --git a/polygerrit-ui/app/utils/inner-html-util.ts b/polygerrit-ui/app/utils/inner-html-util.ts
new file mode 100644
index 0000000..549f493
--- /dev/null
+++ b/polygerrit-ui/app/utils/inner-html-util.ts
@@ -0,0 +1,42 @@
+/**
+ * @license
+ * Copyright (C) 2020 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.
+ */
+
+// This file adds some simple checks to match internal google rules.
+// Internally in google it has different implementation
+
+import {BrandType} from '../types/common';
+
+export type SafeHtml = BrandType<string, '_safeHtml'>;
+export type SafeStyleSheet = BrandType<string, '_safeHtml'>;
+
+export function setInnerHtml(el: HTMLElement, innerHTML: SafeHtml) {
+  el.innerHTML = innerHTML;
+}
+
+export function createStyle(styleSheet: SafeStyleSheet): SafeHtml {
+  return `<style>${styleSheet}</style>` as SafeHtml;
+}
+
+export function safeStyleSheet(
+  templateObj: TemplateStringsArray
+): SafeStyleSheet {
+  const styleSheet = templateObj[0];
+  if (/[<>]/.test(styleSheet)) {
+    throw new Error('Forbidden characters in styleSheet string: ' + styleSheet);
+  }
+  return styleSheet as SafeStyleSheet;
+}
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
new file mode 100644
index 0000000..4313745
--- /dev/null
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -0,0 +1,44 @@
+/**
+ * @license
+ * Copyright (C) 2020 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.
+ */
+import {
+  ApprovalInfo,
+  isDetailedLabelInfo,
+  LabelInfo,
+  VotingRangeInfo,
+} from '../types/common';
+
+// Name of the standard Code-Review label.
+export const CODE_REVIEW = 'Code-Review';
+
+export function getVotingRange(label?: LabelInfo): VotingRangeInfo | undefined {
+  if (!label || !isDetailedLabelInfo(label)) return undefined;
+  const values = Object.keys(label.values).map(v => Number(v));
+  values.sort((a, b) => a - b);
+  if (!values.length) return undefined;
+  return {min: values[0], max: values[values.length - 1]};
+}
+
+export function getVotingRangeOrDefault(label?: LabelInfo): VotingRangeInfo {
+  const range = getVotingRange(label);
+  return range ? range : {min: 0, max: 0};
+}
+
+export function getMaxAccounts(label?: LabelInfo): ApprovalInfo[] {
+  if (!label || !isDetailedLabelInfo(label) || !label.all) return [];
+  const votingRange = getVotingRangeOrDefault(label);
+  return label.all.filter(account => account.value === votingRange.max);
+}
diff --git a/polygerrit-ui/app/utils/label-util_test.js b/polygerrit-ui/app/utils/label-util_test.js
new file mode 100644
index 0000000..d6f7b3e
--- /dev/null
+++ b/polygerrit-ui/app/utils/label-util_test.js
@@ -0,0 +1,90 @@
+/**
+ * @license
+ * Copyright (C) 2020 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.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {
+  getVotingRange,
+  getVotingRangeOrDefault,
+  getMaxAccounts,
+} from './label-util.js';
+
+const VALUES_1 = {
+  '-1': 'bad',
+  '0': 'neutral',
+  '+1': 'good',
+};
+
+const VALUES_2 = {
+  '-1': 'bad',
+  '+2': 'perfect',
+  '0': 'neutral',
+  '-2': 'blocking',
+  '+1': 'good',
+};
+
+suite('label-util', () => {
+  test('getVotingRange -1 to +1', () => {
+    const label = {values: VALUES_1};
+    const expectedRange = {min: -1, max: 1};
+    assert.deepEqual(getVotingRange(label), expectedRange);
+    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+  });
+
+  test('getVotingRange -2 to +2', () => {
+    const label = {values: VALUES_2};
+    const expectedRange = {min: -2, max: 2};
+    assert.deepEqual(getVotingRange(label), expectedRange);
+    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+  });
+
+  test('getVotingRange empty values', () => {
+    const label = {
+      values: {},
+    };
+    const expectedRange = {min: 0, max: 0};
+    assert.isUndefined(getVotingRange(label));
+    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+  });
+
+  test('getVotingRange no values', () => {
+    const label = {};
+    const expectedRange = {min: 0, max: 0};
+    assert.isUndefined(getVotingRange(label));
+    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+  });
+
+  test('getMaxAccounts', () => {
+    const label = {
+      values: VALUES_2,
+      all: [
+        {value: 2, _account_id: 314},
+        {value: 1, _account_id: 777},
+      ],
+    };
+
+    const maxAccounts = getMaxAccounts(label);
+
+    assert.equal(maxAccounts.length, 1);
+    assert.equal(maxAccounts[0]._account_id, 314);
+  });
+
+  test('getMaxAccounts unset parameters', () => {
+    assert.isEmpty(getMaxAccounts());
+    assert.isEmpty(getMaxAccounts({}));
+    assert.isEmpty(getMaxAccounts({values: VALUES_2}));
+  });
+});
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index 962278d..8974af8 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -4,9 +4,13 @@
   PatchSetNum,
   EditPatchSetNum,
   BrandType,
+  ParentPatchSetNum,
 } from '../types/common';
 import {RestApiService} from '../services/services/gr-rest-api/gr-rest-api';
-import {ParsedChangeInfo} from '../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {
+  EditRevisionInfo,
+  ParsedChangeInfo,
+} from '../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 
 /**
  * @license
@@ -48,19 +52,11 @@
   wip?: boolean;
 }
 
-interface RevisionWithSha extends RevisionInfo {
-  sha: string;
-}
-
 interface PatchRange {
   patchNum?: PatchSetNum;
   basePatchNum?: PatchSetNum;
 }
 
-interface PatchRangeRecord {
-  base: PatchRange;
-}
-
 /**
  * As patchNum can be either a string (e.g. 'edit', 'PARENT') OR a number,
  * this function checks for patchNum equality.
@@ -82,6 +78,23 @@
   return `${n}`[0] === '-';
 }
 
+export function isPatchSetNum(patchset: string) {
+  if (!isNaN(Number(patchset))) return true;
+  return patchset === EditPatchSetNum || patchset === ParentPatchSetNum;
+}
+
+export function convertToPatchSetNum(
+  patchset: string | undefined
+): PatchSetNum | undefined {
+  if (patchset === undefined) return patchset;
+  if (!isPatchSetNum(patchset)) {
+    console.error('string is not of type PatchSetNum');
+  }
+  const value = Number(patchset);
+  if (!isNaN(value)) return value as PatchSetNum;
+  return patchset as PatchSetNum;
+}
+
 export function isNumber(
   psn: PatchSetNum
 ): psn is BrandType<number, '_patchSet'> {
@@ -114,7 +127,9 @@
  *     doesn't exist.
  *
  */
-export function findEditParentRevision(revisions: RevisionInfo[]) {
+export function findEditParentRevision(
+  revisions: Array<RevisionInfo | EditRevisionInfo>
+) {
   const editInfo = revisions.find(info => info._number === EditPatchSetNum);
 
   if (!editInfo) {
@@ -130,7 +145,9 @@
  * @return Change edit patch set number or -1.
  *
  */
-export function findEditParentPatchNum(revisions: RevisionInfo[]) {
+export function findEditParentPatchNum(
+  revisions: Array<RevisionInfo | EditRevisionInfo>
+) {
   const revisionInfo = findEditParentRevision(revisions);
   // finding parent of 'edit' patchset, hence revisionInfo._number cannot be
   // 'edit' and must be a number
@@ -148,7 +165,9 @@
  * 3, edit, 2, 1.
  *
  */
-export function sortRevisions<T extends RevisionInfo>(revisions: T[]): T[] {
+export function sortRevisions<T extends RevisionInfo | EditRevisionInfo>(
+  revisions: T[]
+): T[] {
   const editParent: number = findEditParentPatchNum(revisions);
   // Map a normal patchNum to 2 * (patchNum - 1) + 1... I.e. 1 -> 1,
   // 2 -> 3, 3 -> 5, etc.
@@ -186,12 +205,10 @@
   let patchNums: PatchSet[] = [];
   if (change.revisions && Object.keys(change.revisions).length) {
     const changeRevisions = change.revisions;
-    const revisions: RevisionWithSha[] = Object.keys(change.revisions).map(
-      sha => {
-        return {sha, ...changeRevisions[sha]};
-      }
-    );
-    patchNums = sortRevisions(revisions).map((e: RevisionWithSha) => {
+    const revisions = Object.keys(change.revisions).map(sha => {
+      return {sha, ...changeRevisions[sha]};
+    });
+    patchNums = sortRevisions(revisions).map(e => {
       // TODO(kaspern): Mark which patchset an edit was made on, if an
       // edit exists -- perhaps with a temporary description.
       return {
@@ -246,7 +263,9 @@
 
 export const _testOnly_computeWipForPatchSets = _computeWipForPatchSets;
 
-export function computeLatestPatchNum(allPatchSets?: PatchSet[]) {
+export function computeLatestPatchNum(
+  allPatchSets?: PatchSet[]
+): PatchSetNum | undefined {
   if (!allPatchSets || !allPatchSets.length) {
     return undefined;
   }
@@ -263,11 +282,7 @@
   return allPatchSets[0].num === EditPatchSetNum;
 }
 
-export function hasEditPatchsetLoaded(patchRangeRecord: PatchRangeRecord) {
-  const patchRange = patchRangeRecord.base;
-  if (!patchRange) {
-    return false;
-  }
+export function hasEditPatchsetLoaded(patchRange: PatchRange) {
   return (
     patchRange.patchNum === EditPatchSetNum ||
     patchRange.basePatchNum === EditPatchSetNum
@@ -283,7 +298,7 @@
  *     meantime. The promise is rejected on network error.
  */
 export function fetchChangeUpdates(
-  change: ChangeInfo,
+  change: ChangeInfo | ParsedChangeInfo,
   restAPI: RestApiService
 ) {
   const knownLatest = computeLatestPatchNum(computeAllPatchSets(change));
@@ -327,5 +342,5 @@
  */
 
 export function getParentIndex(rangeBase: PatchSetNum) {
-  return -parseInt(`${rangeBase}`, 10);
+  return -Number(`${rangeBase}`);
 }
diff --git a/polygerrit-ui/app/utils/path-list-util.ts b/polygerrit-ui/app/utils/path-list-util.ts
index 008abd2..dda6031 100644
--- a/polygerrit-ui/app/utils/path-list-util.ts
+++ b/polygerrit-ui/app/utils/path-list-util.ts
@@ -90,7 +90,7 @@
   return path;
 }
 
-export function isMagicPath(path: string) {
+export function isMagicPath(path?: string) {
   return (
     !!path &&
     (path === SpecialFilePath.COMMIT_MESSAGE ||
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
index 612f05c..0c6fabc 100644
--- a/polygerrit-ui/app/utils/url-util.ts
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -29,13 +29,10 @@
 /**
  * Get the docs base URL from either the server config or by probing.
  *
- * @param config The server config.
- * @param restApi A REST API instance
- * @return A promise that resolves with the docs base
- *     URL.
+ * @return A promise that resolves with the docs base URL.
  */
 export function getDocsBaseUrl(
-  config: ServerInfo,
+  config: ServerInfo | undefined,
   restApi: RestApiService
 ): Promise<string | null> {
   if (!getDocsBaseUrlCachedPromise) {
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index 16a9f78..e5f0380 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -347,6 +347,18 @@
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
   integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
 
+lit-element@^2.4.0:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-2.4.0.tgz#b22607a037a8fc08f5a80736dddf7f3f5d401452"
+  integrity sha512-pBGLglxyhq/Prk2H91nA0KByq/hx/wssJBQFiYqXhGDvEnY31PRGYf1RglVzyLeRysu0IHm2K0P196uLLWmwFg==
+  dependencies:
+    lit-html "^1.1.1"
+
+lit-html@^1.1.1:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-1.3.0.tgz#c80f3cc5793a6dea6c07172be90a70ab20e56034"
+  integrity sha512-0Q1bwmaFH9O14vycPHw8C/IeHMk/uSDldVLIefu/kfbTBGIc44KGH6A8p1bDfxUfHdc8q6Ct7kQklWoHgr4t1Q==
+
 page@^1.11.5:
   version "1.11.5"
   resolved "https://registry.yarnpkg.com/page/-/page-1.11.5.tgz#0cfc8608be337f26f4377f31df0787aef0ca1af7"
@@ -371,3 +383,15 @@
   dependencies:
     "@polymer/polymer" "^3.0.2"
     "@webcomponents/webcomponentsjs" "^2.0.3"
+
+rxjs@^6.6.2:
+  version "6.6.2"
+  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.2.tgz#8096a7ac03f2cc4fe5860ef6e572810d9e01c0d2"
+  integrity sha512-BHdBMVoWC2sL26w//BCu3YzKT4s2jip/WhwsGEDmeKYBhKDZeYezVUnHatYB7L85v5xs0BAQmg6BEYJEKxBabg==
+  dependencies:
+    tslib "^1.9.0"
+
+tslib@^1.9.0:
+  version "1.13.0"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
+  integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
diff --git a/polygerrit-ui/grep-patch-karma.js b/polygerrit-ui/grep-patch-karma.js
new file mode 100644
index 0000000..adf5171
--- /dev/null
+++ b/polygerrit-ui/grep-patch-karma.js
@@ -0,0 +1,47 @@
+/**
+ * @license
+ * Copyright (C) 2020 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.
+ */
+
+// The IntelliJ (and probably other IDEs) passes test names as a regexp in
+// the format:
+// --grep=/some regexp.../
+// But mochajs doesn't expect the '/' characters before and after the regexp.
+// The code below patches input args and removes '/' if they exists.
+function installPatch(karma) {
+  const originalKarmaStart = karma.start;
+
+  karma.start = function(config, ...args) {
+    const regexpGrepPrefix = '--grep=/';
+    const regexpGrepSuffix = '/';
+    if (config && config.args) {
+      for (let i = 0; i < config.args.length; i++) {
+        const arg = config.args[i];
+        if (arg.startsWith(regexpGrepPrefix) && arg.endsWith(regexpGrepSuffix)) {
+          const regexpText = arg.slice(regexpGrepPrefix.length, -regexpGrepPrefix.length);
+          config.args[i] = '--grep=' + regexpText;
+        }
+      }
+    }
+    originalKarmaStart.apply(this, [config, ...args]);
+  }
+
+}
+
+const karma = window.__karma__;
+if (karma && karma.start && !karma.__grep_patch_installed__) {
+  karma.__grep_patch_installed__ = true;
+  installPatch(karma);
+}
diff --git a/polygerrit-ui/karma.conf.js b/polygerrit-ui/karma.conf.js
index 879a5c8..fe3fa0c 100644
--- a/polygerrit-ui/karma.conf.js
+++ b/polygerrit-ui/karma.conf.js
@@ -43,6 +43,20 @@
   }
 }
 
+function runInIde() {
+  // A simple detection of IDE.
+  // Default browserNoActivityTimeout is 30 seconds. An IDE usually
+  // runs karma in background and send commands when a user wants to
+  // execute test. If interval between user executed tests is bigger than
+  // browserNoActivityTimeout, the IDE reports error and doesn't restart
+  // server.
+  // We want to increase browserNoActivityTimeout when tests run in IDE.
+  // Wd don't want to increase it in other cases, oterhise hanging tests
+  // can slow down CI.
+  return !runUnderBazel &&
+      process.argv.some(arg => arg.toLowerCase().contains('intellij'));
+}
+
 module.exports = function(config) {
   const localDirName = path.resolve(__dirname, '../.ts-out/polygerrit-ui/app');
   const rootDir = runUnderBazel ?
@@ -58,7 +72,10 @@
   const testFilesPattern = (typeof config.testFiles == 'string') ?
       testFilesLocationPattern + config.testFiles :
       testFilesLocationPattern + '*_test.js';
+  // Special patch for grep parameters (see details in the grep-patch-karam.js)
+  const additionalFiles = runUnderBazel ? [] : ['polygerrit-ui/grep-patch-karma.js'];
   config.set({
+    browserNoActivityTimeout: runInIde ? 60 * 60 * 1000 : 30 * 1000,
     // base path that will be used to resolve all patterns (eg. files, exclude)
     basePath: '../',
     plugins: [
@@ -76,6 +93,8 @@
 
     // list of files / patterns to load in the browser
     files: [
+      ...additionalFiles,
+      getUiDevNpmFilePath('source-map-support/browser-source-map-support.js'),
       getUiDevNpmFilePath('accessibility-developer-tools/dist/js/axs_testing.js'),
       getUiDevNpmFilePath('sinon/pkg/sinon.js'),
       { pattern: testFilesPattern, type: 'module' },
@@ -100,6 +119,24 @@
       compatibility: 'none',
       plugins: [
         {
+          resolveImport(importSpecifier) {
+            // esm-dev-server interprets .ts files as .js files and
+            // tries to replace all module imports with relative/absolute
+            // paths. In most cases this works correctly. However if
+            // a ts file imports type from .d.ts and there is no
+            // associated .js file then the esm-dev-server responds with
+            // 500 error.
+            // For example the following import .ts file causes problem
+            // import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+            // To avoid problems, we don't resolve imports in .ts files
+            // and instead always return original path
+            if (importSpecifier.context.originalUrl.endsWith(".ts")) {
+              return importSpecifier.source;
+            }
+            return undefined;
+          }
+        },
+        {
           transform(context) {
             if (context.path.endsWith('/node_modules/page/page.js')) {
               const orignalBody = context.body;
@@ -126,6 +163,9 @@
     // available reporters: https://npmjs.org/browse/keyword/karma-reporter
     reporters: ['mocha'],
 
+    mochaReporter: {
+      showDiff: true
+    },
 
     // web server port
     port: 9876,
diff --git a/polygerrit-ui/package.json b/polygerrit-ui/package.json
index 7de55aa..c01ef56 100644
--- a/polygerrit-ui/package.json
+++ b/polygerrit-ui/package.json
@@ -2,7 +2,12 @@
   "name": "polygerrit-ui-dev-dependencies",
   "description": "Gerrit Code Review - Polygerrit dev dependencies",
   "browser": true,
-  "dependencies": {},
+  "dependencies": {
+    "@types/chai": "^4.2.14",
+    "@types/lodash": "^4.14.162",
+    "@types/mocha": "^8.0.3",
+    "@types/sinon": "^9.0.8"
+  },
   "devDependencies": {
     "@open-wc/karma-esm": "^2.16.16",
     "@polymer/iron-test-helpers": "^3.0.1",
@@ -15,7 +20,8 @@
     "karma-mocha-reporter": "^2.2.5",
     "lodash": "^4.17.15",
     "mocha": "7.2.0",
-    "sinon": "^9.0.2"
+    "sinon": "^9.0.2",
+    "source-map-support": "^0.5.19"
   },
   "license": "Apache-2.0",
   "private": true
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index 124b924..e6487a7 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -185,11 +185,23 @@
 		//   'page/page.mjs' -> '/node_modules/page.mjs'
 		//   '@polymer/iron-icon' -> '/node_modules/@polymer/iron-icon.js'
 		//   './element/file' -> './element/file.js'
-		moduleImportRegexp = regexp.MustCompile(`(?m)^(import.*)'(.*?)(\.(m?)js)?';$`)
-		data = moduleImportRegexp.ReplaceAll(data, []byte("$1 '$2.${4}js';"))
+		moduleImportRegexp = regexp.MustCompile(`(?m)^(import.*|export.* from )['"](.*?)(\.(m?)js)?['"];$`)
+		data = moduleImportRegexp.ReplaceAll(data, []byte("$1'$2.${4}js';"))
 
-		moduleImportRegexp = regexp.MustCompile("(?m)^(import.*)'([^/.].*)';$")
-		data = moduleImportRegexp.ReplaceAll(data, []byte("$1 '/node_modules/$2';"))
+		moduleImportRegexp = regexp.MustCompile(`(?m)^(import.*|export.* from )['"]([^/.].*)['"];$`)
+		data = moduleImportRegexp.ReplaceAll(data, []byte("$1'/node_modules/$2';"))
+
+		// The es module version of rxjs can be found in the _esm2015/ directory.
+		moduleImportRegexp = regexp.MustCompile("(?m)^((import|export).*'/node_modules/rxjs)(.*).js(';)$")
+		data = moduleImportRegexp.ReplaceAll(data, []byte("$1/_esm2015$3/index.js$4"))
+
+		// The es module version of tslib.js can be found in tslib.es6.js.
+		moduleImportRegexp = regexp.MustCompile("(?m)^((import|export).*'/node_modules/)tslib.js';$")
+		data = moduleImportRegexp.ReplaceAll(data, []byte("${1}tslib/tslib.es6.js';"))
+
+		// 'lit-element' imports and exports have to be resolved to 'lit-element/lit-element.js'.
+		moduleImportRegexp = regexp.MustCompile("(?m)^((import|export).*'/node_modules/)lit-(element|html).js';$")
+		data = moduleImportRegexp.ReplaceAll(data, []byte("${1}lit-${3}/lit-${3}.js';"))
 
 		if strings.HasSuffix(normalizedContentPath, "/node_modules/page/page.js") {
 			// Can't import page.js directly, because this is undefined.
diff --git a/polygerrit-ui/yarn.lock b/polygerrit-ui/yarn.lock
index dfc5a43..2acd478 100644
--- a/polygerrit-ui/yarn.lock
+++ b/polygerrit-ui/yarn.lock
@@ -989,6 +989,11 @@
   resolved "https://registry.yarnpkg.com/@types/caniuse-api/-/caniuse-api-3.0.0.tgz#af31cc52062be0ab24583be072fd49b634dcc2fe"
   integrity sha512-wT1VfnScjAftZsvLYaefu/UuwYJdYBwD2JDL2OQd01plGmuAoir5V6HnVHgrfh7zEwcasoiyO2wQ+W58sNh2sw==
 
+"@types/chai@^4.2.14":
+  version "4.2.14"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.14.tgz#44d2dd0b5de6185089375d976b4ec5caf6861193"
+  integrity sha512-G+ITQPXkwTrslfG5L/BksmbLUA0M1iybEsmCWPqzSxsRRhJZimBKJkoMi8fr/CPygPTj4zO5pJH7I2/cm9M7SQ==
+
 "@types/command-line-args@^5.0.0":
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/@types/command-line-args/-/command-line-args-5.0.0.tgz#484e704d20dbb8754a8f091eee45cdd22bcff28c"
@@ -1125,6 +1130,11 @@
   dependencies:
     "@types/koa" "*"
 
+"@types/lodash@^4.14.162":
+  version "4.14.162"
+  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.162.tgz#65d78c397e0d883f44afbf1f7ba9867022411470"
+  integrity sha512-alvcho1kRUnnD1Gcl4J+hK0eencvzq9rmzvFPRmP5rPHx9VVsJj6bKLTATPVf9ktgv4ujzh7T+XWKp+jhuODig==
+
 "@types/lru-cache@^5.1.0":
   version "5.1.0"
   resolved "https://registry.yarnpkg.com/@types/lru-cache/-/lru-cache-5.1.0.tgz#57f228f2b80c046b4a1bd5cac031f81f207f4f03"
@@ -1140,6 +1150,11 @@
   resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
   integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==
 
+"@types/mocha@^8.0.3":
+  version "8.0.3"
+  resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-8.0.3.tgz#51b21b6acb6d1b923bbdc7725c38f9f455166402"
+  integrity sha512-vyxR57nv8NfcU0GZu8EUXZLTbCMupIUwy95LJ6lllN+JRPG25CwMHoB1q5xKh8YKhQnHYRAn4yW2yuHbf/5xgg==
+
 "@types/node@*":
   version "14.0.14"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-14.0.14.tgz#24a0b5959f16ac141aeb0c5b3cd7a15b7c64cbce"
@@ -1175,6 +1190,18 @@
     "@types/express-serve-static-core" "*"
     "@types/mime" "*"
 
+"@types/sinon@^9.0.8":
+  version "9.0.8"
+  resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-9.0.8.tgz#1ed0038d356784f75b086104ef83bfd4130bb81b"
+  integrity sha512-IVnI820FZFMGI+u1R+2VdRaD/82YIQTdqLYC9DLPszZuynAJDtCvCtCs3bmyL66s7FqRM3+LPX7DhHnVTaagDw==
+  dependencies:
+    "@types/sinonjs__fake-timers" "*"
+
+"@types/sinonjs__fake-timers@*":
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.2.tgz#3a84cf5ec3249439015e14049bd3161419bf9eae"
+  integrity sha512-dIPoZ3g5gcx9zZEszaxLSVTvMReD3xxyyDnQUjA6IYDG9Ba2AV0otMPs+77sG9ojB4Qr2N2Vk5RnKeuA0X/0bg==
+
 "@types/whatwg-url@^6.4.0":
   version "6.4.0"
   resolved "https://registry.yarnpkg.com/@types/whatwg-url/-/whatwg-url-6.4.0.tgz#1e59b8c64bc0dbdf66d037cf8449d1c3d5270237"
@@ -3741,7 +3768,7 @@
     socket.io-client "2.1.1"
     socket.io-parser "~3.2.0"
 
-source-map-support@~0.5.12:
+source-map-support@^0.5.19, source-map-support@~0.5.12:
   version "0.5.19"
   resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
   integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==
diff --git a/proto/cache.proto b/proto/cache.proto
index 7924cbd..aa71b87 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -76,7 +76,7 @@
 // Instead, we just take the tedious yet simple approach of having a "has_foo"
 // field for each nullable field "foo", indicating whether or not foo is null.
 //
-// Next ID: 24
+// Next ID: 25
 message ChangeNotesStateProto {
   // Effectively required, even though the corresponding ChangeNotesState field
   // is optional, since the field is only absent when NoteDb is disabled, in
@@ -218,7 +218,11 @@
     string operation = 3;
     string reason = 4;
   }
+  // Only includes the most recent attention set update for each user.
   repeated AttentionSetUpdateProto attention_set_update = 23;
+
+  // Includes all attention set updates.
+  repeated AttentionSetUpdateProto all_attention_set_update = 24;
 }
 
 // Serialized form of com.google.gerrit.server.query.change.ConflictKey
@@ -500,3 +504,60 @@
   bytes global_config_revision = 3; // Hash of All-Projects-projects.config. This
                                     // will only be populated for All-Projects.
 }
+
+// Serialized form of com.google.gerrit.server.comment.CommentContextCacheImpl.Key
+// Next ID: 6
+message CommentContextKeyProto {
+  string project = 1;
+  string change_id = 2;
+  int32 patchset = 3;
+  string commentId = 4;
+
+  // hashed with the murmur3_128 hash function
+  string path_hash = 5;
+}
+
+// Serialized form of a list of com.google.gerrit.extensions.common.ContextLineInfo
+// Next ID: 2
+message AllCommentContextProto {
+  message CommentContextProto {
+    int32 line_number = 1;
+    string context_line = 2;
+  }
+  repeated CommentContextProto context = 1;
+}
+
+// Serialized key for
+// com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheKey
+// Next ID: 5
+message GitModifiedFilesKeyProto {
+  string project = 1;
+  bytes a_tree = 2; // SHA-1 hash of the left git tree ID in the diff
+  bytes b_tree = 3; // SHA-1 hash of the right git tree ID in the diff
+  int32 rename_score = 4;
+}
+
+// Serialized key for
+// com.google.gerrit.server.patch.diff.ModifiedFilesCacheKey
+// Next ID: 5
+message ModifiedFilesKeyProto {
+  string project = 1;
+  bytes a_commit = 2; // SHA-1 hash of the left commit ID in the diff
+  bytes b_commit = 3; // SHA-1 hash of the right commit ID in the diff
+  int32 rename_score = 4;
+}
+
+// Serialized form of com.google.gerrit.server.patch.gitdiff.ModifiedFile
+// Next ID: 4
+message ModifiedFileProto {
+  string change_type = 1; // ENUM as string
+  string old_path = 2;
+  string new_path = 3;
+}
+
+// Serialized form of a collection of
+// com.google.gerrit.server.patch.gitdiff.ModifiedFile
+// Next ID: 2
+message ModifiedFilesProto {
+  repeated ModifiedFileProto modifiedFile = 1;
+}
diff --git a/resources/com/google/gerrit/pgm/init/gerrit.sh b/resources/com/google/gerrit/pgm/init/gerrit.sh
index ce858d5..87a6c05 100755
--- a/resources/com/google/gerrit/pgm/init/gerrit.sh
+++ b/resources/com/google/gerrit/pgm/init/gerrit.sh
@@ -296,6 +296,11 @@
 GERRIT_FDS=`expr $FDS_MULTIPLIER \* $GERRIT_FDS`
 test $GERRIT_FDS -lt 1024 && GERRIT_FDS=1024
 
+CACHE_FDS=`get_config --get cache.openFiles`
+if test -n "$CACHE_FDS"; then
+  GERRIT_FDS=`expr $CACHE_FDS \+ $GERRIT_FDS`
+fi
+
 GERRIT_STARTUP_TIMEOUT=`get_config --get container.startupTimeout`
 test -z "$GERRIT_STARTUP_TIMEOUT" && GERRIT_STARTUP_TIMEOUT=90  # seconds
 
diff --git a/resources/com/google/gerrit/server/mail/HeaderHtml.soy b/resources/com/google/gerrit/server/mail/ChangeHeader.soy
similarity index 62%
copy from resources/com/google/gerrit/server/mail/HeaderHtml.soy
copy to resources/com/google/gerrit/server/mail/ChangeHeader.soy
index 4710d8c..fde69f1 100644
--- a/resources/com/google/gerrit/server/mail/HeaderHtml.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeHeader.soy
@@ -1,5 +1,5 @@
 /**
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2020 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.
@@ -16,5 +16,17 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .HeaderHtml}
+{template .ChangeHeader kind="text"}
+  {@param attentionSet: ?}
+  {if $attentionSet}
+    Attention is currently required from:{sp}
+    {for $attentionSetUser in $attentionSet}
+      {$attentionSetUser}
+      // add commas or dot.
+      {if isLast($attentionSetUser)}.
+      {else},{sp}
+      {/if}
+    {/for}
+    {\n}
+  {/if}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/HeaderHtml.soy b/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
similarity index 62%
rename from resources/com/google/gerrit/server/mail/HeaderHtml.soy
rename to resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
index 4710d8c..ea12455 100644
--- a/resources/com/google/gerrit/server/mail/HeaderHtml.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
@@ -1,5 +1,5 @@
 /**
- * Copyright (C) 2016 The Android Open Source Project
+ * Copyright (C) 2020 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.
@@ -14,7 +14,20 @@
  * limitations under the License.
 */
 
+
 {namespace com.google.gerrit.server.mail.template}
 
-{template .HeaderHtml}
+{template .ChangeHeaderHtml}
+  {@param attentionSet: ?}
+  {if $attentionSet}
+    <p> Attention is currently required from:{sp}
+    {for $attentionSetUser in $attentionSet}
+      {$attentionSetUser}
+      //add commas or dot.
+      {if isLast($attentionSetUser)}.
+      {else},{sp}
+      {/if}
+    {/for} </p>
+    {\n}
+  {/if}
 {/template}
diff --git a/resources/com/google/gerrit/server/mime/mime-types.properties b/resources/com/google/gerrit/server/mime/mime-types.properties
index 41566c8..6ab682c 100644
--- a/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -232,6 +232,7 @@
 toml = text/x-toml
 tpl = text/x-smarty
 ts = application/typescript
+tsx = text/tsx
 ttcn = text/x-ttcn
 ttcnpp = text/x-ttcn
 ttcn3 = text/x-ttcn
diff --git a/tools/bzl/asciidoc.bzl b/tools/bzl/asciidoc.bzl
index 1e7ec96..7977cf0 100644
--- a/tools/bzl/asciidoc.bzl
+++ b/tools/bzl/asciidoc.bzl
@@ -18,8 +18,7 @@
     ]
 
 def _replace_macros_impl(ctx):
-    cmd = [
-        ctx.file._exe.path,
+    args = [
         "--suffix",
         ctx.attr.suffix,
         "-s",
@@ -28,13 +27,14 @@
         ctx.outputs.out.path,
     ]
     if ctx.attr.searchbox:
-        cmd.append("--searchbox")
+        args.append("--searchbox")
     else:
-        cmd.append("--no-searchbox")
-    ctx.actions.run_shell(
+        args.append("--no-searchbox")
+    ctx.actions.run(
         inputs = [ctx.file._exe, ctx.file.src],
         outputs = [ctx.outputs.out],
-        command = cmd,
+        executable = ctx.file._exe.path,
+        arguments = args,
         use_default_shell_env = True,
         progress_message = "Replacing macros in %s" % ctx.file.src.short_path,
     )
diff --git a/tools/bzl/license-map.py b/tools/bzl/license-map.py
index c32579c..221ae2f 100644
--- a/tools/bzl/license-map.py
+++ b/tools/bzl/license-map.py
@@ -238,7 +238,7 @@
                                 key=lambda package: get_package_display_name(
                                     package)),
             ))
-    return result
+    return sorted(result, key=lambda license: license.name)
 
 def get_licensed_files(json_licensed_file_dict):
     """Convert json dictionary to LicensedFiles"""
@@ -305,4 +305,4 @@
     return result
 
 if __name__ == "__main__":
-    main()
\ No newline at end of file
+    main()
diff --git a/tools/eclipse/BUILD b/tools/eclipse/BUILD
index e091fc1..61ea4fe 100644
--- a/tools/eclipse/BUILD
+++ b/tools/eclipse/BUILD
@@ -43,5 +43,14 @@
 
 classpath_collector(
     name = "autovalue_classpath_collect",
-    deps = ["//lib/auto:auto-value"],
+    deps = [
+        "//lib/auto:auto-value",
+        "@auto-value-annotations//jar",
+        "@auto-value-gson-extension//jar",
+        "@auto-value-gson-factory//jar",
+        "@auto-value-gson-runtime//jar",
+        "@autotransient//jar",
+        "@gson//jar",
+        "@javapoet//jar",
+    ],
 )
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index b1d5242..acb5346 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -23,11 +23,14 @@
 
 MAIN = '//tools/eclipse:classpath'
 AUTO = '//lib/auto:auto-value'
-JRE = '/'.join([
-    'org.eclipse.jdt.launching.JRE_CONTAINER',
-    'org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType',
-    'JavaSE-1.8',
-])
+
+def JRE(java_vers = '11'):
+    return '/'.join([
+        'org.eclipse.jdt.launching.JRE_CONTAINER',
+        'org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType',
+        "JavaSE-%s" % java_vers,
+    ])
+
 # Map of targets to corresponding classpath collector rules
 cp_targets = {
     AUTO: '//tools/eclipse:autovalue_classpath_collect',
@@ -46,9 +49,9 @@
 opts.add_argument('-b', '--batch', action='store_true',
                   dest='batch', help='Bazel batch option')
 opts.add_argument('-j', '--java', action='store',
-                  dest='java', help='Post Java 8 support (9)')
+                  dest='java', help='Legacy Java 1.8 or post Java 11')
 opts.add_argument('-e', '--edge_java', action='store',
-                  dest='edge_java', help='Post Java 9 support (10|11|...)')
+                  dest='edge_java', help='Post Java 11 support (14|...)')
 opts.add_argument('--bazel',
                   help=('name of the bazel executable. Defaults to using'
                         ' bazelisk if found, or bazel if bazelisk is not'
@@ -95,7 +98,9 @@
         if arg == "build":
             build = True
         cmd.append(arg)
-    if custom_java and not edge_java:
+    if custom_java == '1.8':
+        cmd.append('--java_toolchain=//tools:error_prone_warnings_toolchain')
+    elif custom_java and not edge_java:
         cmd.append('--host_java_toolchain=@bazel_tools//tools/jdk:toolchain_java%s' % custom_java)
         cmd.append('--java_toolchain=@bazel_tools//tools/jdk:toolchain_java%s' % custom_java)
         if edge_java and build:
@@ -312,7 +317,7 @@
         s = s.replace('.jar', '-src.jar')
         classpathentry('lib', p, s)
 
-    classpathentry('con', JRE)
+    classpathentry('con', JRE(custom_java) if custom_java else JRE())
     classpathentry('output', 'eclipse-out/classes')
     classpathentry('src', '.apt_generated')
     classpathentry('src', '.apt_generated_tests', out="eclipse-out/test")
diff --git a/tools/js/eslint-rules/goog-module-id.js b/tools/js/eslint-rules/goog-module-id.js
deleted file mode 100644
index 56cd645..0000000
--- a/tools/js/eslint-rules/goog-module-id.js
+++ /dev/null
@@ -1,160 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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.
- */
-
-const fs = require('fs');
-const path = require('path');
-const jsExt = '.js';
-
-class NonJsValidator {
-  onProgramEnd(context, node) {
-  }
-  onGoogDeclareModuleId(context, node) {
-    context.report({
-      message: 'goog.declareModuleId is allowed only in .js files',
-      node: node,
-    });
-  }
-}
-
-class JsOnlyValidator {
-  onProgramEnd(context, node) {
-  }
-  onGoogDeclareModuleId(context, node) {
-    context.report({
-      message: 'goog.declareModuleId present, but .d.ts file doesn\'t exist. '
-        + 'Either remove goog.declareModuleId or add the .d.ts file.',
-      node: node,
-    });
-  }
-}
-
-class JsWithDtsValidator {
-  constructor() {
-    this._googDeclareModuleIdExists = false;
-  }
-  onProgramEnd(context, node) {
-    if(!this._googDeclareModuleIdExists) {
-      context.report({
-        message: 'goog.declareModuleId(...) is missed. ' +
-            'Either add it or remove the associated .d.ts file.',
-        node: node,
-      })
-    }
-  }
-  onGoogDeclareModuleId(context, node) {
-    if(this._googDeclareModuleIdExists) {
-      context.report({
-        message: 'Duplicated goog.declareModuleId.',
-        node: node,
-      });
-      return;
-    }
-
-    const filename = context.getFilename();
-    this._googDeclareModuleIdExists = true;
-
-    const scope = context.getScope();
-    if(scope.type !== 'global' && scope.type !== 'module') {
-      context.report({
-        message: 'goog.declareModuleId is allowed only at the root level.',
-        node: node,
-      });
-      // no return - other problems are possible
-    }
-    if(node.arguments.length !== 1) {
-      context.report({
-        message: 'goog.declareModuleId must have exactly one parameter.',
-        node: node,
-      });
-      if(node.arguments.length === 0) {
-        return;
-      }
-    }
-
-    const argument = node.arguments[0];
-    if(argument.type !== 'Literal') {
-      context.report({
-        message: 'The argument for the declareModuleId method '
-            + 'must be a string literal.',
-        node: argument,
-      });
-      return;
-    }
-    const pathStart = '/polygerrit-ui/app/';
-    const index = filename.lastIndexOf(pathStart);
-    if(index < 0) {
-      context.report({
-        message: 'The file located outside of polygerrit-ui/app directory. ' +
-          'Please check eslint config.',
-        node: argument,
-      });
-      return;
-    }
-    const expectedName = 'polygerrit.' +
-        filename.slice(index + pathStart.length, -jsExt.length)
-            .replace(/\//g, '.') // Replace all occurrences of '/' with '.'
-            .replace(/-/g, '$2d'); // Replace all occurrences of '-' with '$2d'
-    if(argument.value !== expectedName) {
-      context.report({
-        message: `Invalid module id. It must be '${expectedName}'.`,
-        node: argument,
-        fix: function(fixer) {
-          return fixer.replaceText(argument, `'${expectedName}'`);
-        },
-      });
-    }
-  }
-}
-
-module.exports = {
-  meta: {
-    type: 'problem',
-    docs: {
-      description: 'Check that goog.declareModuleId is valid',
-      category: 'TS imports JS errors',
-      recommended: false,
-    },
-    fixable: "code",
-    schema: [],
-  },
-  create: function (context) {
-    let fileValidator;
-    return {
-      Program: function(node) {
-        const filename = context.getFilename();
-        if(filename.endsWith(jsExt)) {
-          const dtsFilename = filename.slice(0, -jsExt.length) + ".d.ts";
-          if(fs.existsSync(dtsFilename)) {
-            fileValidator = new JsWithDtsValidator();
-          } else {
-            fileValidator = new JsOnlyValidator();
-          }
-        }
-        else {
-          fileValidator = new NonJsValidator();
-        }
-      },
-      "Program:exit": function(node) {
-        fileValidator.onProgramEnd(context, node);
-        fileValidator = null;
-      },
-      'ExpressionStatement > CallExpression[callee.property.name="declareModuleId"][callee.object.name="goog"]': function(node) {
-        fileValidator.onGoogDeclareModuleId(context, node);
-      }
-    };
-  },
-};
diff --git a/tools/js/eslint-rules/report-ts-error.js b/tools/js/eslint-rules/report-ts-error.js
deleted file mode 100644
index 48dddf4..0000000
--- a/tools/js/eslint-rules/report-ts-error.js
+++ /dev/null
@@ -1,101 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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.
- */
-
-// While we are migrating to typescript, gerrit can have .d.ts files.
-// The option "skipLibCheck" is set to true  In the tsconfig.json.
-// This is required, because we want to skip type checking in node_modules
-// directory - some .d.ts files in 3rd-party modules are incorrect.
-// Unfortunately, this options also excludes our own .d.ts files from type
-// checking. This rule reports all .ts errors in a file as tslint errors.
-
-function getMassageTextFromChain(chainNode, prefix) {
-  let nestedMessages = prefix + chainNode.messageText;
-  if (chainNode.next && chainNode.next.length > 0) {
-    nestedMessages += "\n";
-    for (const node of chainNode.next) {
-      nestedMessages +=
-          getMassageTextFromChain(node, prefix + " ");
-      if(!nestedMessages.endsWith('\n')) {
-        nestedMessages += "\n";
-      }
-    }
-  }
-  return nestedMessages;
-}
-
-function getMessageText(diagnostic) {
-  if (typeof diagnostic.messageText === 'string') {
-    return diagnostic.messageText;
-  }
-  return getMassageTextFromChain(diagnostic.messageText, "");
-}
-
-function getDiagnosticStartAndEnd(diagnostic) {
-  if(diagnostic.start) {
-    const file = diagnostic.file;
-    const start = file.getLineAndCharacterOfPosition(diagnostic.start);
-    const length = diagnostic.length ? diagnostic.length : 0;
-    return {
-      start,
-      end: file.getLineAndCharacterOfPosition(diagnostic.start + length),
-    };
-  }
-  return {
-    start: {line:0, character: 0},
-    end: {line:0, character: 0},
-  }
-}
-
-module.exports = {
-  meta: {
-    type: "problem",
-    docs: {
-      description: "Reports all typescript problems as linter problems",
-      category: ".d.ts",
-      recommended: false
-    },
-    schema: [],
-  },
-  create: function (context) {
-    const program = context.parserServices.program;
-    return {
-      Program: function(node) {
-        const sourceFile =
-            context.parserServices.esTreeNodeToTSNodeMap.get(node);
-        const allDiagnostics = [
-            ...program.getDeclarationDiagnostics(sourceFile),
-            ...program.getSemanticDiagnostics(sourceFile)];
-        for(const diagnostic of allDiagnostics) {
-          const {start, end } = getDiagnosticStartAndEnd(diagnostic);
-          context.report({
-            message: getMessageText(diagnostic),
-            loc: {
-              start: {
-                line: start.line + 1,
-                column: start.character,
-              },
-              end: {
-                line: end.line + 1,
-                column: end.character,
-              }
-            }
-          });
-        }
-      },
-    };
-  }
-};
diff --git a/tools/js/eslint-rules/ts-imports-js.js b/tools/js/eslint-rules/ts-imports-js.js
deleted file mode 100644
index c7f4278..0000000
--- a/tools/js/eslint-rules/ts-imports-js.js
+++ /dev/null
@@ -1,109 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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.
- */
-
-const path = require('path');
-const fs = require('fs');
-
-function checkImportValid(context, node) {
-  const file = context.getFilename();
-  const importSource = node.source.value;
-
-  if(importSource.startsWith('/')) {
-    return {
-      message: 'Do not use absolute path for import.',
-    };
-  }
-
-  const targetFile = path.resolve(path.dirname(file), importSource);
-  const extName = path.extname(targetFile);
-  // There is a polymer.dom.js file, so .dom is not an extension
-  if(extName !== '' && !targetFile.endsWith('polymer.dom')) {
-    return {
-      message: 'Do not specify extensions for import path.',
-      fix: function(fixer) {
-        return fixer.replaceText(node.source, `'${importSource.slice(0, -extName.length)}'`);
-      },
-    };
-  }
-
-  if(!importSource.startsWith('./') && !importSource.startsWith('../')) {
-    // Import from node_modules - nothing else to check
-    return null;
-  }
-
-
-  if(fs.existsSync(targetFile + ".ts")) {
-    // .ts file exists - nothing to check
-    return null;
-  }
-
-  const jsFileExists = fs.existsSync(targetFile + '.js');
-  const dtsFileExists = fs.existsSync(targetFile + '.d.ts');
-
-  if(jsFileExists && !dtsFileExists) {
-    return {
-      message: `The '${importSource}.d.ts' file doesn't exist.`
-    };
-  }
-
-  if(!jsFileExists && dtsFileExists) {
-    return {
-      message: `The '${importSource}.js' file doesn't exist.`
-    };
-  }
-  // If both files (.js and .d.ts) don't exist, the error is reported by
-  // the typescript compiler. Do not report anything from the rule.
-  return null;
-}
-
-module.exports = {
-  meta: {
-    type: "problem",
-    docs: {
-      description: "Check that TS file can import specific JS file",
-      category: "TS imports JS errors",
-      recommended: false
-    },
-    schema: [],
-    fixable: "code",
-  },
-  create: function (context) {
-    return {
-      Program: function(node) {
-        const filename = context.getFilename();
-        if(filename.endsWith('.ts') && !filename.endsWith('.d.ts')) {
-          return;
-        }
-        context.report({
-          message: 'The rule must be used only with .ts files. ' +
-              'Check eslint settings.',
-          node: node,
-        });
-      },
-      ImportDeclaration: function (node) {
-        const importProblem = checkImportValid(context, node);
-        if(importProblem) {
-          context.report({
-            message: importProblem.message,
-            node: node.source,
-            fix: importProblem.fix,
-          });
-        }
-      }
-    };
-  }
-};
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index 14c726e..970a4a9 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/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>3.3.0-SNAPSHOT</version>
+  <version>3.4.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
@@ -35,12 +35,18 @@
       <name>David Pursehouse</name>
     </developer>
     <developer>
+      <name>Dmitrii Filippov</name>
+    </developer>
+    <developer>
       <name>Edwin Kempin</name>
     </developer>
     <developer>
       <name>Han-Wen Nienhuys</name>
     </developer>
     <developer>
+      <name>Joerg Zieren</name>
+    </developer>
+    <developer>
       <name>Luca Milanesio</name>
     </developer>
     <developer>
@@ -53,7 +59,7 @@
       <name>Matthias Sohn</name>
     </developer>
     <developer>
-      <name>Ole Rehmsen</name>
+      <name>Nasser Grainawi</name>
     </developer>
     <developer>
       <name>Patrick Hiesel</name>
@@ -64,6 +70,9 @@
     <developer>
       <name>Sven Selberg</name>
     </developer>
+    <developer>
+      <name>Tao Zhou</name>
+    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index bd323ba..74c4769 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/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>3.3.0-SNAPSHOT</version>
+  <version>3.4.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
@@ -35,12 +35,18 @@
       <name>David Pursehouse</name>
     </developer>
     <developer>
+      <name>Dmitrii Filippov</name>
+    </developer>
+    <developer>
       <name>Edwin Kempin</name>
     </developer>
     <developer>
       <name>Han-Wen Nienhuys</name>
     </developer>
     <developer>
+      <name>Joerg Zieren</name>
+    </developer>
+    <developer>
       <name>Luca Milanesio</name>
     </developer>
     <developer>
@@ -53,7 +59,7 @@
       <name>Matthias Sohn</name>
     </developer>
     <developer>
-      <name>Ole Rehmsen</name>
+      <name>Nasser Grainawi</name>
     </developer>
     <developer>
       <name>Patrick Hiesel</name>
@@ -64,6 +70,9 @@
     <developer>
       <name>Sven Selberg</name>
     </developer>
+    <developer>
+      <name>Tao Zhou</name>
+    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index 3b059e5..e8fae82 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/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>3.3.0-SNAPSHOT</version>
+  <version>3.4.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
@@ -35,12 +35,18 @@
       <name>David Pursehouse</name>
     </developer>
     <developer>
+      <name>Dmitrii Filippov</name>
+    </developer>
+    <developer>
       <name>Edwin Kempin</name>
     </developer>
     <developer>
       <name>Han-Wen Nienhuys</name>
     </developer>
     <developer>
+      <name>Joerg Zieren</name>
+    </developer>
+    <developer>
       <name>Luca Milanesio</name>
     </developer>
     <developer>
@@ -53,7 +59,7 @@
       <name>Matthias Sohn</name>
     </developer>
     <developer>
-      <name>Ole Rehmsen</name>
+      <name>Nasser Grainawi</name>
     </developer>
     <developer>
       <name>Patrick Hiesel</name>
@@ -64,6 +70,9 @@
     <developer>
       <name>Sven Selberg</name>
     </developer>
+    <developer>
+      <name>Tao Zhou</name>
+    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index b8fa132..be6688a 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>3.3.0-SNAPSHOT</version>
+  <version>3.4.0-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
@@ -35,12 +35,18 @@
       <name>David Pursehouse</name>
     </developer>
     <developer>
+      <name>Dmitrii Filippov</name>
+    </developer>
+    <developer>
       <name>Edwin Kempin</name>
     </developer>
     <developer>
       <name>Han-Wen Nienhuys</name>
     </developer>
     <developer>
+      <name>Joerg Zieren</name>
+    </developer>
+    <developer>
       <name>Luca Milanesio</name>
     </developer>
     <developer>
@@ -53,7 +59,7 @@
       <name>Matthias Sohn</name>
     </developer>
     <developer>
-      <name>Ole Rehmsen</name>
+      <name>Nasser Grainawi</name>
     </developer>
     <developer>
       <name>Patrick Hiesel</name>
@@ -64,6 +70,9 @@
     <developer>
       <name>Sven Selberg</name>
     </developer>
+    <developer>
+      <name>Tao Zhou</name>
+    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/node_tools/node_modules_licenses/installed-node-modules-map.ts b/tools/node_tools/node_modules_licenses/installed-node-modules-map.ts
index 3f4955e..49beda3 100644
--- a/tools/node_tools/node_modules_licenses/installed-node-modules-map.ts
+++ b/tools/node_tools/node_modules_licenses/installed-node-modules-map.ts
@@ -45,8 +45,12 @@
 export class InsalledPackagesBuilder {
   private readonly rootPathToPackageMap: Map<DirPath, InstalledPackage> = new Map();
 
+  public constructor(private readonly nonPackages: Set<string>) {
+  }
+
   public addPackageJson(packageJsonPath: string) {
     const pack = this.createInstalledPackage(packageJsonPath);
+    if (!pack) return;
     this.rootPathToPackageMap.set(pack.rootPath, pack)
   }
   public addFile(file: string) {
@@ -60,19 +64,23 @@
    * For example for the packageJsonFile='/a/node_modules/b/node_modules/d/e/package.json'
    * the package name is 'd/e'
    */
-  private createInstalledPackage(packageJsonFile: string): InstalledPackage {
+  private createInstalledPackage(packageJsonFile: string): InstalledPackage | undefined {
     const nameParts: Array<string> = [];
     const rootPath = path.dirname(packageJsonFile);
     let currentDir = rootPath;
     while(currentDir != "") {
       const partName = path.basename(currentDir);
       if(partName === "node_modules") {
+        const packageName = nameParts.reverse().join("/");
         const version = JSON.parse(fs.readFileSync(packageJsonFile, {encoding: 'utf-8'}))["version"];
         if(!version) {
+          if (this.nonPackages.has(packageName)) {
+            return undefined;
+          }
           fail(`Can't get version for ${packageJsonFile}`)
         }
         return {
-          name: nameParts.reverse().join("/"),
+          name: packageName,
           rootPath: rootPath,
           version: version,
           files: []
diff --git a/tools/node_tools/node_modules_licenses/licenses-map.ts b/tools/node_tools/node_modules_licenses/licenses-map.ts
index 9f277e5..7dfb23e 100644
--- a/tools/node_tools/node_modules_licenses/licenses-map.ts
+++ b/tools/node_tools/node_modules_licenses/licenses-map.ts
@@ -216,7 +216,13 @@
 
   /** getInstalledPackages Collects information about all installed packages */
   private getInstalledPackages(nodeModulesFiles: ReadonlyArray<string>): InstalledPackage[] {
-    const builder = new InsalledPackagesBuilder();
+    const fullNonPackageNames: string[] = [];
+    for (const p of this.packages) {
+      if (p.nonPackages) {
+        fullNonPackageNames.push(...p.nonPackages.map(name => `${p.name}/${name}`));
+      }
+    }
+    const builder = new InsalledPackagesBuilder(new Set(fullNonPackageNames));
     // Register all package.json files - such files exists in the root folder of each module
     nodeModulesFiles.filter(f => path.basename(f) === "package.json")
       .forEach(packageJsonFile => builder.addPackageJson(packageJsonFile));
diff --git a/tools/node_tools/node_modules_licenses/package-license-info.ts b/tools/node_tools/node_modules_licenses/package-license-info.ts
index c5cdb0f..79dea09 100644
--- a/tools/node_tools/node_modules_licenses/package-license-info.ts
+++ b/tools/node_tools/node_modules_licenses/package-license-info.ts
@@ -67,4 +67,6 @@
   versions?: string[];
   /** Predicate to select files to apply license. */
   filesFilter?: FilesFilter;
+  /** List of nested directories with package.json files, that are not real packages*/
+  nonPackages?: string[];
 }
diff --git a/tools/node_tools/package.json b/tools/node_tools/package.json
index 1030877..36a10d3 100644
--- a/tools/node_tools/package.json
+++ b/tools/node_tools/package.json
@@ -3,8 +3,8 @@
   "description": "Gerrit Build Tools",
   "browser": false,
   "dependencies": {
-    "@bazel/rollup": "^2.0.0",
-    "@bazel/typescript": "^2.0.0",
+    "@bazel/rollup": "^2.2.2",
+    "@bazel/typescript": "^2.2.2",
     "@types/node": "^10.17.12",
     "@types/parse5": "^4.0.0",
     "@types/parse5-html-rewriting-stream": "^5.1.2",
diff --git a/tools/node_tools/yarn.lock b/tools/node_tools/yarn.lock
index 993bfe9..988deb7 100644
--- a/tools/node_tools/yarn.lock
+++ b/tools/node_tools/yarn.lock
@@ -492,15 +492,15 @@
     lodash "^4.17.13"
     to-fast-properties "^2.0.0"
 
-"@bazel/rollup@^2.0.0":
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-2.0.0.tgz#1980cb3f6922227659260bfdca99c457889a5bc1"
-  integrity sha512-mifUfCZbD1RIhfowh4N8E4881ag3FChz7F4z35wxMOP52g1q3+6Bvh5wv9iysFQopxGmS5jNEj3Dq/CWtSoOnw==
+"@bazel/rollup@^2.2.2":
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-2.2.2.tgz#1abfc5cbf5eb65db2aa145e584d225684d961055"
+  integrity sha512-z3sK0dt7pftjxlLuo66e3PMMGyjq6vD/8B+OEFN3LD3GjE34e8X0/KeRX5lXWs1ecVlrnTroiBxLCJSHwqBrEA==
 
-"@bazel/typescript@^2.0.0":
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-2.0.0.tgz#2ff5615f09c733cc681ba2ada92b11c356b694cd"
-  integrity sha512-5FPkxULWIjAKLG5J1XvpXpY1/4IK39dAoWA/Hhg+16gXTES32fT8w42k96pb6BTaNnyBuYgIHBpELEAJ40OOAQ==
+"@bazel/typescript@^2.2.2":
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-2.2.2.tgz#c7cd49cb630ca3720c04c94046ba8ca4c0d5b0aa"
+  integrity sha512-hkx/7L3s8q5gIgaSFmkUZWPqdKmdJmQ04GaLnsI/YEp9EhPObqATSKnOHeDdT7bzqLO7giDAwAiXhEmsO1Smcw==
   dependencies:
     protobufjs "6.8.8"
     semver "5.6.0"
@@ -950,9 +950,9 @@
   integrity sha512-N33cKXGSqhOYaPiT4xUGsYlPPDwFtQM/6QxJxuMXA/7BcySW+lkn2yigWP7vfs4daiL/7NJNU6DMCqg5N4B+xQ==
 
 "@types/node@^10.1.0":
-  version "10.17.27"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.27.tgz#391cb391c75646c8ad2a7b6ed3bbcee52d1bdf19"
-  integrity sha512-J0oqm9ZfAXaPdwNXMMgAhylw5fhmXkToJd06vuDUSAgEDZ/n/69/69UmyBZbc+zT34UnShuDSBqvim3SPnozJg==
+  version "10.17.42"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.42.tgz#90dd71b26fe4f4e2929df6b07e72ef2e9648a173"
+  integrity sha512-HElxYF7C/MSkuvlaHB2c+82zhXiuO49Cq056Dol8AQuTph7oJtduo2n6J8rFa+YhJyNgQ/Lm20ZaxqD0vxU0+Q==
 
 "@types/node@^10.17.12":
   version "10.17.24"
@@ -7835,9 +7835,9 @@
   integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==
 
 tslib@^1.8.1:
-  version "1.13.0"
-  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
-  integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
+  version "1.14.1"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
+  integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
 
 tslib@^1.9.0:
   version "1.10.0"
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index ff8116b..459143d 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -102,8 +102,8 @@
 
     maven_jar(
         name = "jackson-core",
-        artifact = "com.fasterxml.jackson.core:jackson-core:2.11.2",
-        sha1 = "bc022ab0f0c83c07f9c52c5ab9a6a4932b15cc35",
+        artifact = "com.fasterxml.jackson.core:jackson-core:2.11.3",
+        sha1 = "c2351800432bdbdd8284c3f5a7f0782a352aa84a",
     )
 
     # Google internal dependencies: these are developed at Google, so there is
@@ -143,18 +143,40 @@
         sha1 = "dc13ae4faca6df981fc7aeb5a522d9db446d5d50",
     )
 
-    TESTCONTAINERS_VERSION = "1.14.3"
+    DOCKER_JAVA_VERS = "3.2.5"
+
+    maven_jar(
+        name = "docker-java-api",
+        artifact = "com.github.docker-java:docker-java-api:" + DOCKER_JAVA_VERS,
+        sha1 = "8fe5c5e39f940ce58620e77cedc0a2a52d76f9d8",
+    )
+
+    maven_jar(
+        name = "docker-java-transport",
+        artifact = "com.github.docker-java:docker-java-transport:" + DOCKER_JAVA_VERS,
+        sha1 = "27af0ee7ebc2f5672e23ea64769497b5d55ce3ac",
+    )
+
+    # https://github.com/docker-java/docker-java/blob/3.2.5/pom.xml#L61
+    # <=> DOCKER_JAVA_VERS
+    maven_jar(
+        name = "jackson-annotations",
+        artifact = "com.fasterxml.jackson.core:jackson-annotations:2.10.3",
+        sha1 = "0f63b3b1da563767d04d2e4d3fc1ae0cdeffebe7",
+    )
+
+    TESTCONTAINERS_VERSION = "1.15.0"
 
     maven_jar(
         name = "testcontainers",
         artifact = "org.testcontainers:testcontainers:" + TESTCONTAINERS_VERSION,
-        sha1 = "071fc82ba663f469447a19434e7db90f3a872753",
+        sha1 = "b627535b444d88e7b14953bb953d80d9b7b3bd76",
     )
 
     maven_jar(
         name = "testcontainers-elasticsearch",
         artifact = "org.testcontainers:elasticsearch:" + TESTCONTAINERS_VERSION,
-        sha1 = "3709e2ebb0b6aa4e2ba2b6ca92ffdd3bf637a86c",
+        sha1 = "2bd79fd915e5c7bcf9b5d86cd8e0b7a0fff4b8ce",
     )
 
     maven_jar(
diff --git a/tools/release_noter/.editorconfig b/tools/release_noter/.editorconfig
new file mode 100644
index 0000000..9d2865f
--- /dev/null
+++ b/tools/release_noter/.editorconfig
@@ -0,0 +1,2 @@
+[*.py]
+indent_size = 4
diff --git a/tools/release_noter/.flake8 b/tools/release_noter/.flake8
new file mode 100644
index 0000000..24f2db7
--- /dev/null
+++ b/tools/release_noter/.flake8
@@ -0,0 +1,5 @@
+[flake8]
+max-line-length = 100
+extend-ignore =
+    # https://github.com/PyCQA/pycodestyle/issues/373
+    E203,
diff --git a/tools/release_noter/.gitignore b/tools/release_noter/.gitignore
new file mode 100644
index 0000000..c791f63
--- /dev/null
+++ b/tools/release_noter/.gitignore
@@ -0,0 +1,2 @@
+/.idea/
+/release_noter*.md
diff --git a/tools/release_noter/Makefile b/tools/release_noter/Makefile
new file mode 100644
index 0000000..f18a814
--- /dev/null
+++ b/tools/release_noter/Makefile
@@ -0,0 +1,26 @@
+COMMITS := 10
+
+.PHONY: all clean
+
+all: deploy black flake test
+
+clean:
+	rm -f release_noter*.md
+
+setup:
+	pipenv install --dev
+
+deploy:
+	pipenv install --dev --deploy
+
+black:
+	pipenv run black release_noter.py
+
+flake:
+	pipenv run flake8 release_noter.py
+
+help:
+	pipenv run python release_noter.py -h
+
+test:
+	pipenv run python release_noter.py HEAD~$(COMMITS)..HEAD -l
diff --git a/tools/release_noter/Pipfile b/tools/release_noter/Pipfile
new file mode 100644
index 0000000..8e67cf8
--- /dev/null
+++ b/tools/release_noter/Pipfile
@@ -0,0 +1,15 @@
+[[source]]
+name = "pypi"
+url = "https://pypi.org/simple"
+verify_ssl = true
+
+[dev-packages]
+black = { version = "==20.8b1", markers = "python_version >= '3.8'" }
+flake8 = { version = "==3.8.4", markers = "python_version >= '3.8'" }
+
+[packages]
+jinja2 = { version = "==2.11.2", markers = "python_version >= '3.8'" }
+pygerrit2 = { version = "==2.0.13", markers = "python_version >= '3.8'" }
+
+[requires]
+python_version = "3.8"
diff --git a/tools/release_noter/Pipfile.lock b/tools/release_noter/Pipfile.lock
new file mode 100644
index 0000000..7454fe7
--- /dev/null
+++ b/tools/release_noter/Pipfile.lock
@@ -0,0 +1,266 @@
+{
+    "_meta": {
+        "hash": {
+            "sha256": "66a7d7fdb0a62b702f5414852b80c579a3c16d7a4ed1f3b5344943437c6157ee"
+        },
+        "pipfile-spec": 6,
+        "requires": {
+            "python_version": "3.8"
+        },
+        "sources": [
+            {
+                "name": "pypi",
+                "url": "https://pypi.org/simple",
+                "verify_ssl": true
+            }
+        ]
+    },
+    "default": {
+        "certifi": {
+            "hashes": [
+                "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3",
+                "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"
+            ],
+            "version": "==2020.6.20"
+        },
+        "chardet": {
+            "hashes": [
+                "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
+                "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
+            ],
+            "version": "==3.0.4"
+        },
+        "idna": {
+            "hashes": [
+                "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
+                "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==2.10"
+        },
+        "jinja2": {
+            "hashes": [
+                "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0",
+                "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"
+            ],
+            "index": "pypi",
+            "markers": "python_version >= '3.8'",
+            "version": "==2.11.2"
+        },
+        "markupsafe": {
+            "hashes": [
+                "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473",
+                "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161",
+                "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235",
+                "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5",
+                "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42",
+                "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff",
+                "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b",
+                "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1",
+                "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e",
+                "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183",
+                "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66",
+                "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b",
+                "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1",
+                "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15",
+                "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1",
+                "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e",
+                "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b",
+                "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905",
+                "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735",
+                "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d",
+                "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e",
+                "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d",
+                "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c",
+                "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21",
+                "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2",
+                "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5",
+                "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b",
+                "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",
+                "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",
+                "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f",
+                "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2",
+                "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7",
+                "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==1.1.1"
+        },
+        "pbr": {
+            "hashes": [
+                "sha256:14bfd98f51c78a3dd22a1ef45cf194ad79eee4a19e8e1a0d5c7f8e81ffe182ea",
+                "sha256:5adc0f9fc64319d8df5ca1e4e06eea674c26b80e6f00c530b18ce6a6592ead15"
+            ],
+            "markers": "python_version >= '2.6'",
+            "version": "==5.5.0"
+        },
+        "pygerrit2": {
+            "hashes": [
+                "sha256:4e3c66017e02833bb9302f98fca47fb21cc01d5d2281d62eaefa18e8bd2c2c08"
+            ],
+            "index": "pypi",
+            "markers": "python_version >= '3.8'",
+            "version": "==2.0.13"
+        },
+        "requests": {
+            "hashes": [
+                "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b",
+                "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+            "version": "==2.24.0"
+        },
+        "urllib3": {
+            "hashes": [
+                "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a",
+                "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
+            "version": "==1.25.10"
+        }
+    },
+    "develop": {
+        "appdirs": {
+            "hashes": [
+                "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41",
+                "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"
+            ],
+            "version": "==1.4.4"
+        },
+        "black": {
+            "hashes": [
+                "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"
+            ],
+            "index": "pypi",
+            "markers": "python_version >= '3.8'",
+            "version": "==20.8b1"
+        },
+        "click": {
+            "hashes": [
+                "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
+                "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+            "version": "==7.1.2"
+        },
+        "flake8": {
+            "hashes": [
+                "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839",
+                "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"
+            ],
+            "index": "pypi",
+            "markers": "python_version >= '3.8'",
+            "version": "==3.8.4"
+        },
+        "mccabe": {
+            "hashes": [
+                "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
+                "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
+            ],
+            "version": "==0.6.1"
+        },
+        "mypy-extensions": {
+            "hashes": [
+                "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d",
+                "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"
+            ],
+            "version": "==0.4.3"
+        },
+        "pathspec": {
+            "hashes": [
+                "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0",
+                "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"
+            ],
+            "version": "==0.8.0"
+        },
+        "pycodestyle": {
+            "hashes": [
+                "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367",
+                "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==2.6.0"
+        },
+        "pyflakes": {
+            "hashes": [
+                "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92",
+                "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==2.2.0"
+        },
+        "regex": {
+            "hashes": [
+                "sha256:02686a2f0b1a4be0facdd0d3ad4dc6c23acaa0f38fb5470d892ae88584ba705c",
+                "sha256:137da580d1e6302484be3ef41d72cf5c3ad22a076070051b7449c0e13ab2c482",
+                "sha256:20cdd7e1736f4f61a5161aa30d05ac108ab8efc3133df5eb70fe1e6a23ea1ca6",
+                "sha256:25991861c6fef1e5fd0a01283cf5658c5e7f7aa644128e85243bc75304e91530",
+                "sha256:26b85672275d8c7a9d4ff93dbc4954f5146efdb2ecec89ad1de49439984dea14",
+                "sha256:2f60ba5c33f00ce9be29a140e6f812e39880df8ba9cb92ad333f0016dbc30306",
+                "sha256:3dd952f3f8dc01b72c0cf05b3631e05c50ac65ddd2afdf26551638e97502107b",
+                "sha256:578ac6379e65eb8e6a85299b306c966c852712c834dc7eef0ba78d07a828f67b",
+                "sha256:5d4a3221f37520bb337b64a0632716e61b26c8ae6aaffceeeb7ad69c009c404b",
+                "sha256:608d6c05452c0e6cc49d4d7407b4767963f19c4d2230fa70b7201732eedc84f2",
+                "sha256:65b6b018b07e9b3b6a05c2c3bb7710ed66132b4df41926c243887c4f1ff303d5",
+                "sha256:698f8a5a2815e1663d9895830a063098ae2f8f2655ae4fdc5dfa2b1f52b90087",
+                "sha256:6c72adb85adecd4522a488a751e465842cdd2a5606b65464b9168bf029a54272",
+                "sha256:6d4cdb6c20e752426b2e569128488c5046fb1b16b1beadaceea9815c36da0847",
+                "sha256:6e9f72e0ee49f7d7be395bfa29e9533f0507a882e1e6bf302c0a204c65b742bf",
+                "sha256:828618f3c3439c5e6ef8621e7c885ca561bbaaba90ddbb6a7dfd9e1ec8341103",
+                "sha256:85b733a1ef2b2e7001aff0e204a842f50ad699c061856a214e48cfb16ace7d0c",
+                "sha256:8958befc139ac4e3f16d44ec386c490ea2121ed8322f4956f83dd9cad8e9b922",
+                "sha256:a51e51eecdac39a50ede4aeed86dbef4776e3b73347d31d6ad0bc9648ba36049",
+                "sha256:aeac7c9397480450016bc4a840eefbfa8ca68afc1e90648aa6efbfe699e5d3bb",
+                "sha256:aef23aed9d4017cc74d37f703d57ce254efb4c8a6a01905f40f539220348abf9",
+                "sha256:af1f5e997dd1ee71fb6eb4a0fb6921bf7a778f4b62f1f7ef0d7445ecce9155d6",
+                "sha256:b5eeaf4b5ef38fab225429478caf71f44d4a0b44d39a1aa4d4422cda23a9821b",
+                "sha256:d25f5cca0f3af6d425c9496953445bf5b288bb5b71afc2b8308ad194b714c159",
+                "sha256:d81be22d5d462b96a2aa5c512f741255ba182995efb0114e5a946fe254148df1",
+                "sha256:e935a166a5f4c02afe3f7e4ce92ce5a786f75c6caa0c4ce09c922541d74b77e8",
+                "sha256:ef3a55b16c6450574734db92e0a3aca283290889934a23f7498eaf417e3af9f0"
+            ],
+            "version": "==2020.10.15"
+        },
+        "toml": {
+            "hashes": [
+                "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f",
+                "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"
+            ],
+            "version": "==0.10.1"
+        },
+        "typed-ast": {
+            "hashes": [
+                "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
+                "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919",
+                "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa",
+                "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652",
+                "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75",
+                "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01",
+                "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d",
+                "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1",
+                "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907",
+                "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c",
+                "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3",
+                "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b",
+                "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614",
+                "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb",
+                "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b",
+                "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41",
+                "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6",
+                "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34",
+                "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe",
+                "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4",
+                "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"
+            ],
+            "version": "==1.4.1"
+        },
+        "typing-extensions": {
+            "hashes": [
+                "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918",
+                "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c",
+                "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"
+            ],
+            "version": "==3.7.4.3"
+        }
+    }
+}
diff --git a/tools/release_noter/README.md b/tools/release_noter/README.md
new file mode 100644
index 0000000..449522b
--- /dev/null
+++ b/tools/release_noter/README.md
@@ -0,0 +1,53 @@
+# Release Noter
+
+## Setup
+
+```bash
+make setup
+make deploy
+```
+
+* The `deploy` target may not succeed if `Pipfile.lock` is out of date.
+  * The `setup` target can be used first in such a case.
+* Using `make all` will run the `deploy` target, among the other key targets.
+
+## Warning
+
+The make `clean` target removes any previously made `release_noter*.md` file(s).
+
+Running `release_noter.py` multiple times without cleaning creates the next `N`
+`release_noter-N.md` file, without overwriting the previous one(s).
+
+## Usage
+
+```bash
+make help
+```
+
+* The resulting `release_noter*.md` file(s) can be edited then copied over to the `homepage`.
+  * The markdown file name should be `x.y.md`, where `x.y` is the major release version.
+  * Alternatively, an existing `x.y.md` can be edited with `release_noter*.md` snippets.
+
+## Testing
+
+```bash
+make test
+make test COMMITS=100
+```
+
+This target will use the `-l` option, which takes more time as `COMMITS` increases.
+
+## Examples
+
+```bash
+pipenv run python release_noter.py v3.2.3..HEAD
+pipenv run python release_noter.py v3.2.3..v3.3.0-rc0
+pipenv run python release_noter.py v3.2.3..v3.3.0-rc0 -l
+```
+
+## Coding
+
+```bash
+make black
+make flake
+```
diff --git a/tools/release_noter/release_noter.md.template b/tools/release_noter/release_noter.md.template
new file mode 100644
index 0000000..06399a1
--- /dev/null
+++ b/tools/release_noter/release_noter.md.template
@@ -0,0 +1,36 @@
+---
+title: "Gerrit {{ data.new }} release (in development)"
+permalink: {{ data.major }}.html
+hide_sidebar: true
+hide_navtoggle: true
+toc: true
+---
+
+Download: **[{{ data.new }}](https://gerrit-releases.storage.googleapis.com/gerrit-{{ data.new }}.war)**
+| [{{ data.previous }}](https://gerrit-releases.storage.googleapis.com/gerrit-{{ data.previous }}.war)
+
+Documentation: **[{{ data.new }}](https://gerrit-documentation.storage.googleapis.com/Documentation/{{ data.doc }}/index.html)**
+| [{{ data.previous }}](https://gerrit-documentation.storage.googleapis.com/Documentation/{{ data.previous }}/index.html)
+
+## Release highlights
+
+## Important notes
+
+### Schema changes
+
+### Breaking changes
+
+## Native packaging
+
+## New features
+
+### REST APIs
+
+* Accounts
+* Changes
+* Groups
+* Projects
+
+## End-to-end tests
+
+## Plugin changes
diff --git a/tools/release_noter/release_noter.py b/tools/release_noter/release_noter.py
new file mode 100644
index 0000000..05fa023
--- /dev/null
+++ b/tools/release_noter/release_noter.py
@@ -0,0 +1,363 @@
+#!/usr/bin/env python
+
+import argparse
+import os
+import re
+import subprocess
+
+from enum import Enum
+from jinja2 import Template
+from os import path
+from pygerrit2 import Anonymous, GerritRestAPI
+
+EXCLUDED_SUBJECTS = {
+    "annotat",
+    "assert",
+    "AutoValue",
+    "avadoc",  # Javadoc &co.
+    "avaDoc",
+    "ava-doc",
+    "baz",  # bazel, bazlet(s)
+    "Baz",
+    "circular",
+    "class",
+    "common.ts",
+    "construct",
+    "controls",
+    "debounce",
+    "Debounce",
+    "decorat",
+    "efactor",  # Refactor &co.
+    "format",
+    "Format",
+    "getter",
+    "gr-",
+    "hide",
+    "icon",
+    "ignore",
+    "immutab",
+    "import",
+    "inject",
+    "iterat",
+    "IT",
+    "js",
+    "label",
+    "licence",
+    "license",
+    "lint",
+    "listener",
+    "Listener",
+    "lock",
+    "method",
+    "metric",
+    "mock",
+    "module",
+    "naming",
+    "nits",
+    "nongoogle",
+    "prone",  # error prone &co.
+    "Prone",
+    "register",
+    "Register",
+    "remove",
+    "Remove",
+    "rename",
+    "Rename",
+    "Revert",
+    "serializ",
+    "Serializ",
+    "server.go",
+    "setter",
+    "spell",
+    "Spell",
+    "test",  # testing, tests; unit or else
+    "Test",
+    "thread",
+    "tsetse",
+    "type",
+    "Type",
+    "typo",
+    "util",
+    "variable",
+    "version",
+    "warning",
+}
+
+COMMIT_SHA1_PATTERN = r"^commit ([a-z0-9]+)$"
+DATE_HEADER_PATTERN = r"Date: .+"
+SUBJECT_SUBMODULES_PATTERN = r"^Update git submodules$"
+ISSUE_ID_PATTERN = r"[a-zA-Z]+: [Ii]ssue ([0-9]+)"
+CHANGE_ID_PATTERN = r"^Change-Id: [I0-9a-z]+$"
+PLUGIN_PATTERN = r"plugins/([a-z\-]+)"
+RELEASE_VERSIONS_PATTERN = r"v([0-9\.\-rc]+)\.\.v([0-9\.\-rc]+)"
+RELEASE_MAJOR_PATTERN = r"^([0-9]+\.[0-9]+).+"
+RELEASE_DOC_PATTERN = r"^([0-9]+\.[0-9]+\.[0-9]+).*"
+
+CHANGE_URL = "/c/gerrit/+/"
+COMMIT_URL = "/changes/?q=commit%3A"
+GERRIT_URL = "https://gerrit-review.googlesource.com"
+ISSUE_URL = "https://bugs.chromium.org/p/gerrit/issues/detail?id="
+
+MARKDOWN = "release_noter"
+GIT_COMMAND = "git"
+GIT_PATH = "../.."
+PLUGINS = "plugins/"
+UTF8 = "UTF-8"
+
+
+def parse_args():
+    parser = argparse.ArgumentParser(
+        description="Generate an initial release notes markdown file.",
+        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+    )
+    parser.add_argument(
+        "-l",
+        "--link",
+        dest="link",
+        required=False,
+        default=False,
+        action="store_true",
+        help="link commits to change in Gerrit; slower as it gets each _number from gerrit",
+    )
+    parser.add_argument("range", help="git log revision range")
+    return parser.parse_args()
+
+
+def list_submodules():
+    submodule_names = [
+        GIT_COMMAND,
+        "submodule",
+        "foreach",
+        "--quiet",
+        "echo $name",
+    ]
+    return subprocess.check_output(submodule_names, cwd=f"{GIT_PATH}", encoding=UTF8)
+
+
+def open_git_log(options, cwd=os.getcwd()):
+    git_log = [
+        GIT_COMMAND,
+        "log",
+        "--no-merges",
+        options.range,
+    ]
+    return subprocess.check_output(git_log, cwd=cwd, encoding=UTF8)
+
+
+class Component:
+    name = None
+    sentinels = set()
+
+    def __init__(self, name, sentinels):
+        self.name = name
+        self.sentinels = sentinels
+
+
+class Components(Enum):
+    plugin_ce = Component("Codemirror-editor", {PLUGINS})
+    plugin_cm = Component("Commit-message-length-validator", {PLUGINS})
+    plugin_dp = Component("Delete-project", {PLUGINS})
+    plugin_dc = Component("Download-commands", {PLUGINS})
+    plugin_gt = Component("Gitiles", {PLUGINS})
+    plugin_ho = Component("Hooks", {PLUGINS})
+    plugin_pm = Component("Plugin-manager", {PLUGINS})
+    plugin_re = Component("Replication", {PLUGINS})
+    plugin_rn = Component("Reviewnotes", {PLUGINS})
+    plugin_su = Component("Singleusergroup", {PLUGINS})
+    plugin_wh = Component("Webhooks", {PLUGINS})
+
+    ui = Component(
+        "Polygerrit UI",
+        {"poly", "gwt", "button", "dialog", "icon", "hover", "menu", "ux"},
+    )
+    doc = Component("Documentation", {"document"})
+    jgit = Component("JGit", {"jgit"})
+    elastic = Component("Elasticsearch", {"elastic"})
+    deps = Component("Other dependency", {"upgrade", "dependenc"})
+    otherwise = Component("Other core", {})
+
+
+class Task(Enum):
+    start_commit = 1
+    finish_headers = 2
+    capture_subject = 3
+    finish_commit = 4
+
+
+class Commit:
+    sha1 = None
+    subject = None
+    component = None
+    issues = set()
+
+    def reset(self, signature, task):
+        if signature is not None:
+            self.sha1 = signature.group(1)
+            self.subject = None
+            self.component = None
+            self.issues = set()
+            return Task.finish_headers
+        return task
+
+
+def parse_log(process, gerrit, options, commits, cwd=os.getcwd()):
+    commit = Commit()
+    task = Task.start_commit
+    for line in process.splitlines():
+        line = line.strip()
+        if not line:
+            continue
+        if task == Task.start_commit:
+            task = commit.reset(re.search(COMMIT_SHA1_PATTERN, line), task)
+        elif task == Task.finish_headers:
+            if re.match(DATE_HEADER_PATTERN, line):
+                task = Task.capture_subject
+        elif task == Task.capture_subject:
+            commit.subject = line
+            task = Task.finish_commit
+        elif task == Task.finish_commit:
+            commit_issue = re.search(ISSUE_ID_PATTERN, line)
+            if commit_issue is not None:
+                commit.issues.add(commit_issue.group(1))
+            else:
+                commit_end = re.match(CHANGE_ID_PATTERN, line)
+                if commit_end is not None:
+                    commit = finish(commit, commits, gerrit, options, cwd)
+                    task = Task.start_commit
+        else:
+            raise RuntimeError("FIXME")
+
+
+def finish(commit, commits, gerrit, options, cwd):
+    if re.match(SUBJECT_SUBMODULES_PATTERN, commit.subject):
+        return Commit()
+    if len(commit.issues) == 0:
+        for exclusion in EXCLUDED_SUBJECTS:
+            if exclusion in commit.subject:
+                return Commit()
+        for component in commits:
+            for noted_commit in commits[component]:
+                if noted_commit.subject == commit.subject:
+                    return Commit()
+    set_component(commit, commits, cwd)
+    link_subject(commit, gerrit, options, cwd)
+    escape_these(commit)
+    return Commit()
+
+
+def set_component(commit, commits, cwd):
+    component_found = None
+    for component in Components:
+        for sentinel in component.value.sentinels:
+            if component_found is None:
+                if re.match(f"{GIT_PATH}/{PLUGINS}{component.value.name.lower()}", cwd):
+                    component_found = component
+                elif sentinel.lower() in commit.subject.lower():
+                    component_found = component
+                if component_found is not None:
+                    commits[component].append(commit)
+    if component_found is None:
+        commits[Components.otherwise].append(commit)
+    commit.component = component_found
+
+
+def init_components():
+    components = dict()
+    for component in Components:
+        components[component] = []
+    return components
+
+
+def link_subject(commit, gerrit, options, cwd):
+    if options.link:
+        gerrit_change = gerrit.get(f"{COMMIT_URL}{commit.sha1}")
+        if not gerrit_change:
+            return
+        change_number = gerrit_change[0]["_number"]
+        plugin_wd = re.search(f"{GIT_PATH}/({PLUGINS}.+)", cwd)
+        if plugin_wd is not None:
+            change_address = f"{GERRIT_URL}/c/{plugin_wd.group(1)}/+/{change_number}"
+        else:
+            change_address = f"{GERRIT_URL}{CHANGE_URL}{change_number}"
+        short_sha1 = commit.sha1[0:7]
+        commit.subject = f"[{short_sha1}]({change_address})\n  {commit.subject}"
+
+
+def escape_these(in_change):
+    in_change.subject = in_change.subject.replace("<", "\\<")
+    in_change.subject = in_change.subject.replace(">", "\\>")
+
+
+def print_commits(commits, md):
+    for component in commits:
+        if len(commits[component]) > 0:
+            if PLUGINS in component.value.sentinels:
+                md.write(f"\n### {component.value.name}\n")
+            else:
+                md.write(f"\n## {component.value.name} changes\n")
+            for commit in commits[component]:
+                print_from(commit, md)
+
+
+def print_from(this_change, md):
+    md.write("\n*")
+    for issue in sorted(this_change.issues):
+        md.write(f" [Issue {issue}]({ISSUE_URL}{issue});\n ")
+    md.write(f" {this_change.subject}\n")
+
+
+def print_template(md, options):
+    previous = "0.0.0"
+    new = "0.1.0"
+    versions = re.search(RELEASE_VERSIONS_PATTERN, options.range)
+    if versions is not None:
+        previous = versions.group(1)
+        new = versions.group(2)
+    data = {
+        "previous": previous,
+        "new": new,
+        "major": re.search(RELEASE_MAJOR_PATTERN, new).group(1),
+        "doc": re.search(RELEASE_DOC_PATTERN, new).group(1),
+    }
+    template = Template(open(f"{MARKDOWN}.md.template").read())
+    md.write(f"{template.render(data=data)}\n")
+
+
+def print_notes(commits, options):
+    markdown = f"{MARKDOWN}.md"
+    next_md = 2
+    while path.exists(markdown):
+        markdown = f"{MARKDOWN}-{next_md}.md"
+        next_md += 1
+    with open(markdown, "w") as md:
+        print_template(md, options)
+        print_commits(commits, md)
+        md.write("\n## Bugfix releases\n")
+
+
+def plugin_changes():
+    plugin_commits = init_components()
+    for submodule_name in list_submodules().splitlines():
+        plugin_name = re.search(PLUGIN_PATTERN, submodule_name)
+        if plugin_name is not None:
+            plugin_wd = f"{GIT_PATH}/{PLUGINS}{plugin_name.group(1)}"
+            plugin_log = open_git_log(script_options, plugin_wd)
+            parse_log(
+                plugin_log,
+                gerrit_api,
+                script_options,
+                plugin_commits,
+                plugin_wd,
+            )
+    return plugin_commits
+
+
+if __name__ == "__main__":
+    gerrit_api = GerritRestAPI(url=GERRIT_URL, auth=Anonymous())
+    script_options = parse_args()
+    if script_options.link:
+        print("Link option used; slower.")
+    noted_changes = plugin_changes()
+    change_log = open_git_log(script_options)
+    parse_log(change_log, gerrit_api, script_options, noted_changes)
+    print_notes(noted_changes, script_options)
diff --git a/version.bzl b/version.bzl
index 78b286b..066d07e 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 = "3.3.0-SNAPSHOT"
+GERRIT_VERSION = "3.4.0-SNAPSHOT"
diff --git a/yarn.lock b/yarn.lock
index 438cafd..34f761f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -485,20 +485,20 @@
     lodash "^4.17.11"
     to-fast-properties "^2.0.0"
 
-"@bazel/rollup@^2.0.0":
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-2.0.0.tgz#1980cb3f6922227659260bfdca99c457889a5bc1"
-  integrity sha512-mifUfCZbD1RIhfowh4N8E4881ag3FChz7F4z35wxMOP52g1q3+6Bvh5wv9iysFQopxGmS5jNEj3Dq/CWtSoOnw==
+"@bazel/rollup@^2.2.2":
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-2.2.2.tgz#1abfc5cbf5eb65db2aa145e584d225684d961055"
+  integrity sha512-z3sK0dt7pftjxlLuo66e3PMMGyjq6vD/8B+OEFN3LD3GjE34e8X0/KeRX5lXWs1ecVlrnTroiBxLCJSHwqBrEA==
 
-"@bazel/terser@^2.0.0":
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/@bazel/terser/-/terser-2.0.0.tgz#a841db8aefd7c51c216b34a26bc02a6c93d5e56a"
-  integrity sha512-6mBYcfzP6pWxycYZ8r4Lz5kgiWZ7n08bVHZBIRExFeqs7Yy92dD92LPeA9FZIzFiX00IuR9Q1Lqy23xH5q7FeQ==
+"@bazel/terser@^2.2.2":
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/@bazel/terser/-/terser-2.2.2.tgz#2a72b739de8a12ab9ca1cfe60c6c118215acc10f"
+  integrity sha512-pPhNr21g8PN0jGhzQHOIL9pOicMgU1Jfrh+liI4PVBfSFrJbTjJw3iNRDX0skYAlsR0WG433kn8CkEjY4IvJVw==
 
-"@bazel/typescript@^2.0.0":
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-2.0.0.tgz#2ff5615f09c733cc681ba2ada92b11c356b694cd"
-  integrity sha512-5FPkxULWIjAKLG5J1XvpXpY1/4IK39dAoWA/Hhg+16gXTES32fT8w42k96pb6BTaNnyBuYgIHBpELEAJ40OOAQ==
+"@bazel/typescript@^2.2.2":
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-2.2.2.tgz#c7cd49cb630ca3720c04c94046ba8ca4c0d5b0aa"
+  integrity sha512-hkx/7L3s8q5gIgaSFmkUZWPqdKmdJmQ04GaLnsI/YEp9EhPObqATSKnOHeDdT7bzqLO7giDAwAiXhEmsO1Smcw==
   dependencies:
     protobufjs "6.8.8"
     semver "5.6.0"
@@ -937,9 +937,9 @@
   integrity sha512-rp7La3m845mSESCgsJePNL/JQyhkOJA6G4vcwvVgkDAwHhGdq5GCumxmPjEk1MZf+8p5ZQAUE7tqgQRQTXN7uQ==
 
 "@types/node@^10.1.0":
-  version "10.17.24"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.24.tgz#c57511e3a19c4b5e9692bb2995c40a3a52167944"
-  integrity sha512-5SCfvCxV74kzR3uWgTYiGxrd69TbT1I6+cMx1A5kEly/IVveJBimtAMlXiEyVFn5DvUFewQWxOOiJhlxeQwxgA==
+  version "10.17.42"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.42.tgz#90dd71b26fe4f4e2929df6b07e72ef2e9648a173"
+  integrity sha512-HElxYF7C/MSkuvlaHB2c+82zhXiuO49Cq056Dol8AQuTph7oJtduo2n6J8rFa+YhJyNgQ/Lm20ZaxqD0vxU0+Q==
 
 "@types/node@^4.0.30":
   version "4.9.3"