Merge "A11y - Fix tabbing out of searchbar"
diff --git a/.bazelversion b/.bazelversion
index d5c0c99..7c69a55d 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-3.5.1
+3.7.0
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 4add381..529718a 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:
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 8e5ef03..e7c0d13 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -1705,6 +1705,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::
 +
@@ -3397,6 +3403,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::
 +
@@ -3763,8 +3774,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.
 
@@ -3773,6 +3789,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::
@@ -5518,6 +5539,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
 
@@ -5535,6 +5599,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-plugins.txt b/Documentation/dev-plugins.txt
index 8f81775..b2e1589 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
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/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 512e0cb..d43203f 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -3191,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
 
@@ -3450,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
 
@@ -3936,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.
 
 ----
 
@@ -4001,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/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-changes.txt b/Documentation/rest-api-changes.txt
index 6b73482..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.
@@ -7662,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/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/WORKSPACE b/WORKSPACE
index 30bf6cf..3c30026 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",
     ],
 )
 
@@ -875,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(
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 4ab5d51..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;
@@ -767,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,
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/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 30514a6..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 {
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 404f5e4..4d55b36 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -103,6 +103,7 @@
 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;
@@ -146,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;
@@ -250,6 +252,8 @@
     final ChangeFinder changeFinder;
     final RetryHelper retryHelper;
     final PluginSetContext<ExceptionHook> exceptionHooks;
+    final Injector injector;
+    final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
     @Inject
     Globals(
@@ -265,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;
@@ -280,6 +286,8 @@
       this.retryHelper = retryHelper;
       this.exceptionHooks = exceptionHooks;
       allowOrigin = makeAllowOrigin(config);
+      this.injector = injector;
+      this.dynamicBeans = dynamicBeans;
     }
 
     private static Pattern makeAllowOrigin(Config cfg) {
@@ -498,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) {
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/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 8323cfd..6ab0c61 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -244,7 +244,7 @@
       Provider<ConsistencyChecker> checkerProvider,
       ActionJson actionJson,
       ChangeNotes.Factory notesFactory,
-      LabelsJson.Factory labelsJsonFactory,
+      LabelsJson labelsJson,
       RemoveReviewerControl removeReviewerControl,
       TrackingFooters trackingFooters,
       Metrics metrics,
@@ -261,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;
@@ -602,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);
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/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/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 2edf00c..6a25afd 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -111,7 +111,6 @@
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
-import com.google.gerrit.server.change.LabelsJson;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
 import com.google.gerrit.server.change.ReviewerSuggestion;
 import com.google.gerrit.server.change.RevisionJson;
@@ -273,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);
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/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 2ec9a8d..fb8a9d3 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -2692,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;
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 890a1b1..d48cbc4 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -118,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()) {
@@ -131,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.
         }
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/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/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/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/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 6f4ccb7..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";
@@ -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/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/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/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/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/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 45e544d2..01c7b75 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -353,7 +353,7 @@
     }
     if (record.requirements != null) {
       record.requirements.stream()
-          .map(SubmitRequirement::fallbackText)
+          .map(MergeOp::describeSubmitRequirement)
           .forEach(blockingConditions::add);
     }
     return Joiner.on("; ").join(blockingConditions);
@@ -389,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(
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/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 9efcff2..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;
@@ -64,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 a027dd1..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();
@@ -464,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) {
@@ -533,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/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/ListMembersCommand.java b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
index 3269c2b..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;
@@ -50,8 +51,8 @@
   }
 
   @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/Query.java b/java/com/google/gerrit/sshd/commands/Query.java
index 772eabe..da19153 100644
--- a/java/com/google/gerrit/sshd/commands/Query.java
+++ b/java/com/google/gerrit/sshd/commands/Query.java
@@ -116,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/ReviewCommand.java b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
index b58cc45..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;
@@ -320,7 +321,7 @@
   }
 
   @Override
-  protected void parseCommandLine() throws UnloggedFailure {
+  protected void parseCommandLine(DynamicOptions pluginOptions) throws UnloggedFailure {
     optionMap = new LinkedHashMap<>();
     customLabels = new HashMap<>();
 
@@ -341,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/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/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 496a2b4..ccfa60e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -1499,19 +1499,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());
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 0d94ce6..436ad7c 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -113,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;
@@ -704,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);
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 9fa562a..39366bd 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -452,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");
@@ -880,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();
@@ -2845,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 c526e31..763e7b1 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -87,6 +87,7 @@
 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;
@@ -1123,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);
@@ -1148,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);
@@ -1180,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
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 d0641e4..9e944a2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -35,6 +35,7 @@
 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;
@@ -115,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);
   }
@@ -164,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));
@@ -191,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,
@@ -199,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(
@@ -214,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();
@@ -244,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();
 
@@ -258,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();
 
@@ -286,11 +286,10 @@
 
   @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(
@@ -301,11 +300,12 @@
             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);
@@ -533,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();
@@ -567,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 =
@@ -587,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(), "");
 
@@ -611,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()
@@ -772,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);
 
@@ -1144,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());
@@ -1237,6 +1253,8 @@
         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 =
@@ -1335,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
@@ -1532,6 +1552,156 @@
     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/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/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/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/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 44dd831..cc0b109 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -809,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/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/package.json b/package.json
index 46f20c7..913b7a8 100644
--- a/package.json
+++ b/package.json
@@ -30,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/replication b/plugins/replication
index 6673bec..b37a255 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 6673becb41c774b1063e4b18c1821ced049fee2e
+Subproject commit b37a255e9500dabdf6aa5385b87624a2108f04d3
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 8486019c..9834ddc 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -280,6 +280,11 @@
         // 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": {
@@ -287,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"
@@ -311,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 807adef..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',
 }
 
 /**
@@ -327,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 {
@@ -369,3 +359,34 @@
   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/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-group-members/gr-group-members.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
index ae10c03..e94a933 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
@@ -46,6 +46,7 @@
 import {AutocompleteQuery} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {PolymerDomRepeatEvent} from '../../../types/types';
 import {hasOwnProperty} from '../../../utils/common-util';
+import {EventType, fire} from '../../../utils/event-util';
 
 const SUGGESTIONS_LIMIT = 15;
 const SAVING_ERROR_TEXT =
@@ -297,13 +298,7 @@
         (errResponse, err) => {
           if (errResponse) {
             if (errResponse.status === 404) {
-              this.dispatchEvent(
-                new CustomEvent('show-alert', {
-                  detail: {message: SAVING_ERROR_TEXT},
-                  bubbles: true,
-                  composed: true,
-                })
-              );
+              fire(this, EventType.SHOW_ALERT, SAVING_ERROR_TEXT);
               return errResponse;
             }
             throw Error(errResponse.statusText);
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..ac65297 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 {EventType, fire} from '../../../utils/event-util';
 
 const NOTHING_TO_SAVE = 'No changes to save.';
 
@@ -516,13 +517,7 @@
       !Object.keys(addRemoveObj.remove).length &&
       !addRemoveObj.parent
     ) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: NOTHING_TO_SAVE},
-          bubbles: true,
-          composed: true,
-        })
-      );
+      fire(this, EventType.SHOW_ALERT, 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..7fce91f 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,7 @@
 } from '../../../types/common';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrCreateChangeDialog} from '../gr-create-change-dialog/gr-create-change-dialog';
+import {fire, EventType} from '../../../utils/event-util';
 
 const GC_MESSAGE = 'Garbage collection completed successfully.';
 const CONFIG_BRANCH = 'refs/meta/config' as BranchName;
@@ -142,13 +143,7 @@
       .runRepoGC(this.repo)
       .then(response => {
         if (response?.status === 200) {
-          this.dispatchEvent(
-            new CustomEvent('show-alert', {
-              detail: {message: GC_MESSAGE},
-              bubbles: true,
-              composed: true,
-            })
-          );
+          fire(this, EventType.SHOW_ALERT, GC_MESSAGE);
         }
       })
       .finally(() => {
@@ -190,13 +185,7 @@
         const message = change
           ? CREATE_CHANGE_SUCCEEDED_MESSAGE
           : CREATE_CHANGE_FAILED_MESSAGE;
-        this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message},
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fire(this, EventType.SHOW_ALERT, message);
         if (!change) {
           return;
         }
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 d70e891..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
@@ -152,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;
   }
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-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.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index a9024ef..1eff047 100644
--- 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
@@ -54,7 +54,10 @@
   HttpMethod,
   NotifyType,
 } from '../../../constants/constants';
-import {EventType, TargetElement} from '../../plugins/gr-plugin-types';
+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 {
@@ -110,6 +113,7 @@
   RevisionActions,
   UIActionInfo,
 } from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
+import {fire, EventType} from '../../../utils/event-util';
 
 const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
 const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
@@ -251,6 +255,14 @@
   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;
 
@@ -440,7 +452,7 @@
     type: Array,
     computed:
       '_computeTopLevelActions(_allActionValues.*, ' +
-      '_hiddenActions.*, _overflowActions.*)',
+      '_hiddenActions.*, editMode, _overflowActions.*)',
     observer: '_filterPrimaryActions',
   })
   _topLevelActions?: UIActionInfo[];
@@ -601,13 +613,7 @@
         this._handleLoadingComplete();
       })
       .catch(err => {
-        this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message: ERR_REVISION_ACTIONS},
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fire(this, EventType.SHOW_ALERT, ERR_REVISION_ACTIONS);
         this._loading = false;
         throw err;
       });
@@ -623,7 +629,7 @@
     change: ChangeInfo;
     revisionActions: ActionNameToActionInfoMap;
   }) {
-    this.$.jsAPI.handleEvent(EventType.SHOW_REVISION_ACTIONS, detail);
+    this.$.jsAPI.handleEvent(PluginEventType.SHOW_REVISION_ACTIONS, detail);
   }
 
   @observe('change')
@@ -1362,23 +1368,11 @@
   _handleCherryPickRestApi(conflicts: boolean) {
     const el = this.$.confirmCherrypick;
     if (!el.branch) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: ERR_BRANCH_EMPTY},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(this, EventType.SHOW_ALERT, ERR_BRANCH_EMPTY);
       return;
     }
     if (!el.message) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: ERR_COMMIT_EMPTY},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(this, EventType.SHOW_ALERT, ERR_COMMIT_EMPTY);
       return;
     }
     this.$.overlay.close();
@@ -1399,13 +1393,7 @@
   _handleMoveConfirm() {
     const el = this.$.confirmMove;
     if (!el.branch) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: ERR_BRANCH_EMPTY},
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(this, EventType.SHOW_ALERT, ERR_BRANCH_EMPTY);
       return;
     }
     this.$.overlay.close();
@@ -1833,8 +1821,6 @@
   }
 
   _handlePublishEditTap() {
-    // Type of payload is PublishChangeEditInput.
-    const payload = {notify: NotifyType.NONE};
     if (!this.actions.publishEdit) {
       return;
     }
@@ -1842,7 +1828,7 @@
       '/edit:publish',
       assertUIActionInfo(this.actions.publishEdit),
       false,
-      payload
+      {notify: NotifyType.NONE}
     );
   }
 
@@ -1985,12 +1971,14 @@
 
   _computeTopLevelActions(
     actionRecord: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
-    hiddenActionsRecord: PolymerDeepPropertyChange<string[], string[]>
+    hiddenActionsRecord: PolymerDeepPropertyChange<string[], string[]>,
+    editMode: boolean
   ): UIActionInfo[] {
     const hiddenActions = hiddenActionsRecord.base || [];
     return actionRecord.base.filter(a => {
-      const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
-      return !(overflow || hiddenActions.includes(a.__key));
+      if (hiddenActions.includes(a.__key)) return false;
+      if (editMode) return EDIT_ACTIONS.has(a.__key);
+      return this._getActionOverflowIndex(a.__type, a.__key) === -1;
     });
   }
 
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 301f176..ae49a57 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');
 
@@ -1810,10 +1814,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();
@@ -1826,12 +1831,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,
@@ -1945,12 +1950,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');
 
@@ -1965,12 +1970,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) => {
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
index 79e18b5..f8a5940 100644
--- 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
@@ -50,7 +50,10 @@
 } from '../../../constants/constants';
 import {changeIsOpen} from '../../../utils/change-util';
 import {customElement, property, observe} from '@polymer/decorators';
-import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {
+  EditRevisionInfo,
+  ParsedChangeInfo,
+} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 import {
   AccountDetailInfo,
   AccountInfo,
@@ -135,7 +138,7 @@
   account?: AccountDetailInfo;
 
   @property({type: Object})
-  revision?: RevisionInfo;
+  revision?: RevisionInfo | EditRevisionInfo;
 
   @property({type: Object})
   commitInfo?: CommitInfoWithRequiredCommit;
@@ -591,8 +594,7 @@
 
     if (
       role === ChangeRole.AUTHOR &&
-      rev.commit &&
-      rev.commit.author &&
+      rev.commit?.author &&
       change.owner.email !== rev.commit.author.email
     ) {
       return rev.commit.author;
@@ -600,9 +602,11 @@
 
     if (
       role === ChangeRole.COMMITTER &&
-      rev.commit &&
-      rev.commit.committer &&
-      change.owner.email !== rev.commit.committer.email
+      rev.commit?.committer &&
+      change.owner.email !== rev.commit.committer.email &&
+      !(
+        rev.uploader?.email && rev.uploader.email === rev.commit.committer.email
+      )
     ) {
       return rev.commit.committer;
     }
@@ -612,7 +616,7 @@
 
   _computeParents(
     change?: ParsedChangeInfo,
-    revision?: RevisionInfo
+    revision?: RevisionInfo | EditRevisionInfo
   ): ParentCommitInfo[] {
     if (!revision || !revision.commit) {
       if (!change || !change.current_revision) {
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 5f53641..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');
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
index bafe32a..26b553e 100644
--- 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
@@ -43,6 +43,7 @@
 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';
@@ -73,7 +74,7 @@
   PatchSet,
 } from '../../../utils/patch-set-util';
 import {changeStatuses, changeStatusString} from '../../../utils/change-util';
-import {EventType} from '../../plugins/gr-plugin-types';
+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';
@@ -98,7 +99,6 @@
   ConfigInfo,
   PreferencesInfo,
   CommitInfo,
-  DiffPreferencesInfo,
   RevisionInfo,
   EditInfo,
   LabelNameToInfoMap,
@@ -107,6 +107,7 @@
   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';
@@ -132,7 +133,10 @@
 import {AppElementChangeViewParams} from '../../gr-app-types';
 import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
 import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
-import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {
+  EditRevisionInfo,
+  ParsedChangeInfo,
+} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 import {
   GrFileList,
   DEFAULT_NUM_FILES_SHOWN,
@@ -142,8 +146,15 @@
   CustomKeyboardEvent,
   EditableContentSaveEvent,
   OpenFixPreviewEvent,
+  ShowAlertEventDetail,
   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 {fire, EventType} from '../../../utils/event-util';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
 const CHANGE_ID_ERROR = {
   MISMATCH: 'mismatch',
@@ -217,8 +228,15 @@
     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))
@@ -247,6 +265,8 @@
 
   reporting = appContext.reportingService;
 
+  flagsService = appContext.flagsService;
+
   /**
    * URL params passed from the router.
    */
@@ -377,7 +397,7 @@
   _changeIdCommitMessageError?: string;
 
   @property({type: Object})
-  _patchRange?: PatchRange;
+  _patchRange?: ChangeViewPatchRange;
 
   @property({type: String})
   _filesExpanded?: string;
@@ -386,7 +406,7 @@
   _basePatchNum?: string;
 
   @property({type: Object})
-  _selectedRevision?: RevisionInfo;
+  _selectedRevision?: RevisionInfo | EditRevisionInfo;
 
   @property({type: Object})
   _currentRevisionActions?: ActionNameToActionInfoMap;
@@ -431,7 +451,7 @@
     type: String,
     computed: '_computeChangeStatusChips(_change, _mergeable, _submitEnabled)',
   })
-  _changeStatuses?: string;
+  _changeStatuses?: string[];
 
   /** If false, then the "Show more" button was used to expand. */
   @property({type: Boolean})
@@ -516,6 +536,8 @@
 
   _throttledToggleChangeStar?: EventListener;
 
+  _isChecksEnabled = false;
+
   keyboardShortcuts() {
     return {
       [Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
@@ -540,6 +562,14 @@
   }
 
   /** @override */
+  ready() {
+    super.ready();
+    this._isChecksEnabled = this.flagsService.isEnabled(
+      KnownExperimentId.CI_REBOOT_CHECKS
+    );
+  }
+
+  /** @override */
   connectedCallback() {
     super.connectedCallback();
     this._throttledToggleChangeStar = this._throttleWrap(e =>
@@ -653,11 +683,11 @@
     }
   }
 
-  get messagesList() {
+  get messagesList(): GrMessagesList | null {
     return this.shadowRoot!.querySelector('gr-messages-list');
   }
 
-  get threadList() {
+  get threadList(): GrThreadList | null {
     return this.shadowRoot!.querySelector('gr-thread-list');
   }
 
@@ -868,9 +898,9 @@
     loggedIn: boolean,
     editing: boolean,
     change: ChangeInfo,
-    editMode: boolean,
-    collapsed: boolean,
-    collapsible: boolean
+    editMode?: boolean,
+    collapsed?: boolean,
+    collapsible?: boolean
   ) {
     if (
       !loggedIn ||
@@ -1223,14 +1253,13 @@
         this._patchRange.basePatchNum !== value.basePatchNum);
     const changeChanged = this._changeNum !== value.changeNum;
 
-    const patchRange = {
+    const patchRange: ChangeViewPatchRange = {
       patchNum: value.patchNum,
-      basePatchNum: value.basePatchNum || 'PARENT',
+      basePatchNum: value.basePatchNum || ParentPatchSetNum,
     };
 
     this.$.fileList.collapseAllDiffs();
-    // TODO(TS): change patchRange to PatchRange.
-    this._patchRange = patchRange as PatchRange;
+    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.
@@ -1283,7 +1312,7 @@
   _sendShowChangeEvent() {
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
-    this.$.jsAPI.handleEvent(EventType.SHOW_CHANGE, {
+    this.$.jsAPI.handleEvent(PluginEventType.SHOW_CHANGE, {
       change: this._change,
       patchNum: this._patchRange.patchNum,
       info: {mergeable: this._mergeable},
@@ -1475,7 +1504,7 @@
    */
   _getBasePatchNum(
     change: ChangeInfo | ParsedChangeInfo,
-    patchRange: PatchRange
+    patchRange: ChangeViewPatchRange
   ) {
     if (patchRange.basePatchNum && patchRange.basePatchNum !== 'PARENT') {
       return patchRange.basePatchNum;
@@ -1575,7 +1604,7 @@
       GrChangeView,
       '_diffDrafts'
     > | null,
-    canStartReview?: PolymerDeepPropertyChange<boolean, boolean>
+    canStartReview?: boolean
   ) {
     if (changeRecord === undefined || canStartReview === undefined) {
       return 'Reply';
@@ -1640,15 +1669,7 @@
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
     if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Base is already selected.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(this, EventType.SHOW_ALERT, 'Base is already selected.');
       return;
     }
     GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
@@ -1662,15 +1683,7 @@
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
     if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Left is already base.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(this, EventType.SHOW_ALERT, 'Left is already base.');
       return;
     }
     GerritNav.navigateToChange(this._change, this._patchRange.basePatchNum);
@@ -1685,11 +1698,12 @@
       throw new Error('missing required _patchRange property');
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
     if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+      const detail: ShowAlertEventDetail = {
+        message: 'Latest is already selected.',
+      };
       this.dispatchEvent(
         new CustomEvent('show-alert', {
-          detail: {
-            message: 'Latest is already selected.',
-          },
+          detail,
           composed: true,
           bubbles: true,
         })
@@ -1712,15 +1726,7 @@
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
     if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Right is already latest.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(this, EventType.SHOW_ALERT, 'Right is already latest.');
       return;
     }
     GerritNav.navigateToChange(
@@ -1742,15 +1748,7 @@
       patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
       patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)
     ) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Already diffing base against latest.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(this, EventType.SHOW_ALERT, 'Already diffing base against latest.');
       return;
     }
     GerritNav.navigateToChange(this._change, latestPatchNum);
@@ -1858,7 +1856,7 @@
         changeRecord.path
       );
     }
-    this.$.jsAPI.handleEvent(EventType.LABEL_CHANGE, {
+    this.$.jsAPI.handleEvent(PluginEventType.LABEL_CHANGE, {
       change: this._change,
     });
   }
@@ -1927,7 +1925,7 @@
         basePatchNum: edit.base_patch_set_number,
         commit: edit.commit,
         fetch: edit.fetch,
-      } as RevisionInfo;
+      };
 
     // 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.
@@ -2113,9 +2111,20 @@
     this._robotCommentThreads = undefined;
     if (!this._changeNum)
       throw new Error('missing required changeNum property');
-    return this.$.commentAPI
+
+    const portedCommentsPromise = this.$.commentAPI.getPortedComments(
+      this._changeNum
+    );
+    const commentsPromise = this.$.commentAPI
       .loadAll(this._changeNum)
-      .then(comments => this._recomputeComments(comments));
+      .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;
   }
 
   /**
@@ -2568,7 +2577,7 @@
     this.$.relatedChanges.reload();
   }
 
-  _computeHeaderClass(editMode: boolean) {
+  _computeHeaderClass(editMode?: boolean) {
     const classes = ['header'];
     if (editMode) {
       classes.push('editMode');
@@ -2577,7 +2586,10 @@
   }
 
   _computeEditMode(
-    patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>,
+    patchRangeRecord: PolymerDeepPropertyChange<
+      ChangeViewPatchRange,
+      ChangeViewPatchRange
+    >,
     paramsRecord: PolymerDeepPropertyChange<
       AppElementChangeViewParams,
       AppElementChangeViewParams
@@ -2734,7 +2746,10 @@
    * Wrapper for using in the element template and computed properties
    */
   _hasEditPatchsetLoaded(
-    patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>
+    patchRangeRecord: PolymerDeepPropertyChange<
+      ChangeViewPatchRange,
+      ChangeViewPatchRange
+    >
   ) {
     const patchRange = patchRangeRecord.base;
     if (!patchRange) {
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 efc86bc..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
@@ -558,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]]"
@@ -648,6 +653,12 @@
       </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
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 8236331..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
+++ /dev/null
@@ -1,2544 +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();
-    element._changeNum = '1';
-    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,
-    };
-    element._change = {
-      _number: '1',
-      project: '',
-      change_id: '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 => {
-      element._changeNum = '1';
-      // 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 = {
-        changeNum: '1',
-        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 = {
-        changeNum: '1',
-        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,
-          }));
-      element._changeNum = '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', () => {
-    element._change = {};
-    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',
-      done => {
-        flush(() => {
-          const openStub = sinon.stub(element, '_openReplyDialog');
-          MockInteractions.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 = {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', 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 = 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,
-    };
-    element._change = {
-      _number: '1',
-      project: '',
-      change_id: '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 => {
-    element._change = {
-      _number: '1',
-      project: '',
-      change_id: '1',
-    };
-    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-revert-dialog/gr-confirm-revert-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
index 5c0b19f..b3e054f 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,6 +25,7 @@
 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 {fire, EventType} from '../../../utils/event-util';
 
 const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
 const CHANGE_SUBJECT_LIMIT = 50;
@@ -124,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,
-        })
-      );
+      fire(this, EventType.SHOW_ALERT, ERR_COMMIT_NOT_FOUND);
       return;
     }
     const revertCommitText = `This reverts commit ${commitHash}.`;
@@ -168,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,
-        })
-      );
+      fire(this, EventType.SHOW_ALERT, ERR_COMMIT_NOT_FOUND);
       return;
     }
     if (!changes || changes.length <= 1) return;
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..2f9f800 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 {fire, EventType} 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,
-        })
-      );
+      fire(this, EventType.SHOW_ALERT, 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.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index 3243af0..108f9ed 100644
--- 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
@@ -57,7 +57,6 @@
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {
   ConfigInfo,
-  DiffPreferencesInfo,
   ElementPropertyDeepChange,
   FileInfo,
   FileNameToFileInfoMap,
@@ -67,6 +66,7 @@
   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';
@@ -637,17 +637,17 @@
         patchNum: patchRange.patchNum,
         path,
       });
-    const commentCount =
-      changeComments.computeCommentCount({
+    const commentThreadCount =
+      changeComments.computeCommentThreadCount({
         patchNum: patchRange.basePatchNum,
         path,
       }) +
-      changeComments.computeCommentCount({
+      changeComments.computeCommentThreadCount({
         patchNum: patchRange.patchNum,
         path,
       });
     const commentString = GrCountStringFormatter.computePluralString(
-      commentCount,
+      commentThreadCount,
       'comment'
     );
     const unresolvedString = GrCountStringFormatter.computeString(
@@ -733,16 +733,16 @@
     ) {
       return '';
     }
-    const commentCount =
-      changeComments.computeCommentCount({
+    const commentThreadCount =
+      changeComments.computeCommentThreadCount({
         patchNum: patchRange.basePatchNum,
         path,
       }) +
-      changeComments.computeCommentCount({
+      changeComments.computeCommentThreadCount({
         patchNum: patchRange.patchNum,
         path,
       });
-    return GrCountStringFormatter.computeShortString(commentCount, 'c');
+    return GrCountStringFormatter.computeShortString(commentThreadCount, 'c');
   }
 
   private _reviewFile(path: string, reviewed?: boolean) {
@@ -998,6 +998,7 @@
       return;
     }
     e.preventDefault();
+    this.classList.remove('hideComments');
     this.$.diffCursor.createCommentInPlace();
   }
 
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 85d79e6..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';
@@ -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,
           },
         ],
@@ -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', () => {
@@ -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);
@@ -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);
@@ -1467,6 +1498,7 @@
     const commitMsgComments = [
       {
         patch_set: 2,
+        path: '/p',
         id: 'ecf0b9fa_fe1a5f62',
         line: 20,
         updated: '2018-02-08 18:49:18.000000000',
@@ -1475,6 +1507,7 @@
       },
       {
         patch_set: 2,
+        path: '/p',
         id: '503008e2_0ab203ee',
         line: 10,
         updated: '2018-02-14 22:07:43.000000000',
@@ -1483,6 +1516,7 @@
       },
       {
         patch_set: 2,
+        path: '/p',
         id: 'cc788d2c_cb1d728c',
         line: 20,
         in_reply_to: 'ecf0b9fa_fe1a5f62',
@@ -1492,7 +1526,7 @@
       },
     ];
 
-    const setupDiff = function(diff) {
+    async function setupDiff(diff) {
       diff.comments = {
         left: diff.path === '/COMMIT_MSG' ? commitMsgComments : [],
         right: [],
@@ -1521,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', {
@@ -1599,10 +1641,10 @@
       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.
@@ -1629,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();
@@ -1640,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.
@@ -1821,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
@@ -1849,6 +1891,7 @@
       const commentStubRes1 = [
         {
           patch_set: 2,
+          path: '/p',
           id: '503008e2_0ab203ee',
           line: 20,
           updated: '2018-02-08 18:49:18.000000000',
@@ -1859,6 +1902,7 @@
       const commentStubRes2 = [
         {
           patch_set: 2,
+          path: '/p',
           id: 'ecf0b9fa_fe1a5f62',
           line: 20,
           updated: '2018-02-08 18:49:18.000000000',
@@ -1867,6 +1911,7 @@
         },
         {
           patch_set: 2,
+          path: '/p',
           id: '503008e2_0ab203ee',
           line: 10,
           in_reply_to: 'ecf0b9fa_fe1a5f62',
@@ -1876,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-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-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index 687861d..12f8021 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
@@ -110,6 +110,7 @@
 import {isAttentionSetEnabled} from '../../../utils/attention-set-util';
 import {CODE_REVIEW, getMaxAccounts} from '../../../utils/label-util';
 import {isUnresolved} from '../../../utils/comment-util';
+import {fire, EventType} from '../../../utils/event-util';
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
@@ -240,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 = '';
 
@@ -359,7 +366,7 @@
   _sendDisabled?: boolean;
 
   @property({type: Array, observer: '_handleHeightChanged'})
-  draftCommentThreads?: CommentThread[];
+  draftCommentThreads: CommentThread[] | undefined;
 
   @property({type: Boolean})
   _isResolvedPatchsetLevelComment = true;
@@ -453,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);
   }
@@ -531,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,
-              })
-            );
+            fire(this, EventType.SHOW_ALERT, message);
           }
         }
       }
@@ -961,7 +973,7 @@
     'draftCommentThreads',
     '_includeComments',
     '_labelsChanged',
-    'draft'
+    'hasDrafts'
   )
   _computeNewAttention(
     currentUser?: AccountInfo,
@@ -974,7 +986,7 @@
     draftCommentThreads?: CommentThread[],
     includeComments?: boolean,
     _labelsChanged?: boolean,
-    draft?: boolean
+    hasDrafts?: boolean
   ) {
     if (
       currentUser === undefined ||
@@ -990,7 +1002,6 @@
     // 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 hasDraft = draftCommentThreads.length > 0 || !!draft;
     const hasVote = !!_labelsChanged;
     const isOwner = this._isOwner(currentUser, change);
     const isUploader = this._uploader?._account_id === currentUser._account_id;
@@ -1009,13 +1020,13 @@
       // 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 && (hasDraft || hasVote));
+        !(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 owner and uploader, if someone else replies.
-      if (hasDraft || hasVote) {
+      if (hasDrafts || hasVote) {
         if (this._uploader?._account_id && !isUploader) {
           newAttention.add(this._uploader._account_id);
         }
@@ -1259,13 +1270,7 @@
       return;
     }
     if (this._sendDisabled) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          bubbles: true,
-          composed: true,
-          detail: {message: EMPTY_REPLY_MESSAGE},
-        })
-      );
+      fire(this, EventType.SHOW_ALERT, EMPTY_REPLY_MESSAGE);
       return;
     }
     return this.send(this._includeComments, this.canBeStarted)
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 7a89f3d..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
@@ -230,9 +230,12 @@
     }
     element.change = change;
     element._reviewers = reviewers.base;
+
     flush();
+    const hasDrafts = draftThreads.length > 0;
     element._computeNewAttention(
-        user, reviewers, [], change, draftThreads, includeComments);
+        user, reviewers, [], change, draftThreads, includeComments, undefined,
+        hasDrafts);
     assert.sameMembers([...element._newAttentionSet], expectedIds);
   }
 
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/test/source-map-support-install.js b/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
similarity index 70%
copy from polygerrit-ui/app/test/source-map-support-install.js
copy to polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
index a8f147382..85183ed 100644
--- a/polygerrit-ui/app/test/source-map-support-install.js
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
@@ -15,6 +15,12 @@
  * limitations under the License.
  */
 
-// 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();
+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-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.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
index 233d71e..caa0521 100644
--- 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
@@ -154,8 +154,9 @@
   @property({type: String})
   _registerText = 'Sign up';
 
+  // Empty string means that the register <div> will be hidden.
   @property({type: String})
-  _registerURL?: string;
+  _registerURL = '';
 
   @property({type: Boolean})
   mobileSearchHidden = false;
@@ -328,15 +329,15 @@
 
   _retrieveRegisterURL(config: ServerInfo) {
     if (AUTH_TYPES_WITH_REGISTER_URL.has(config.auth.auth_type)) {
-      this._registerURL = config.auth.register_url;
+      this._registerURL = config.auth.register_url ?? '';
       if (config.auth.register_text) {
         this._registerText = config.auth.register_text;
       }
     }
   }
 
-  _computeIsInvisible(registerURL?: string) {
-    return registerURL ? '' : 'invisible';
+  _computeRegisterHidden(registerURL: string) {
+    return !registerURL;
   }
 
   _createHeaderLink(linkObj: TopMenuItemInfo): MainHeaderLink {
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 048b461..0000000
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.js
+++ /dev/null
@@ -1,391 +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._createHeaderLink), [
-      {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(
-        /* 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 = [{
-      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(
-        /* 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 = [{
-      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(
-        /* 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 = [{
-      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(
-        /* 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',
-    }];
-    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: 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(
-        /* userLinks= */ [],
-        adminLinks,
-        topMenus,
-        /* baseDocUrl= */ '',
-        /* defaultLinks= */ []
-    ), [{
-      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-search-bar/gr-search-bar.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
index 68e045f..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
@@ -36,9 +36,10 @@
 } 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}`))
 );
 
@@ -148,6 +149,8 @@
     return htmlTemplate;
   }
 
+  private searchOperators = new Set(SEARCH_OPERATORS_WITH_NEGATIONS_SET);
+
   /**
    * Fired when a search is committed
    *
@@ -197,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) {
@@ -222,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}`);
     }
   }
 
@@ -261,9 +266,9 @@
     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;
       }
@@ -316,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_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/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 0e73516..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,14 +29,13 @@
 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 {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {isRobot} from '../../../utils/comment-util';
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 0514553..b01a110 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
@@ -35,6 +35,7 @@
   RobotCommentInfo,
   UrlEncodedCommentId,
   NumericChangeId,
+  RevisionId,
 } from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {CommentSide} from '../../../constants/constants';
@@ -45,11 +46,11 @@
   CommentThread,
   DraftInfo,
   isUnresolved,
-  sortComments,
   UIComment,
   UIDraft,
   UIHuman,
   UIRobot,
+  createCommentThreads,
 } from '../../../utils/comment-util';
 import {PatchSetFile, PatchNumOnly, isPatchSetFile} from '../../../types/types';
 
@@ -435,14 +436,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;
   }
 
   /**
@@ -474,56 +480,14 @@
     }
 
     comments = comments.concat(drafts);
-    const threads = this.getCommentThreads(sortComments(comments));
+    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);
   }
 
   /**
@@ -611,6 +575,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-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 bb7b518..b48efb4 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 {fire, EventType} from '../../../utils/event-util';
 
 const DiffViewMode = {
   SIDE_BY_SIDE: 'SIDE_BY_SIDE',
@@ -150,6 +147,9 @@
   @property({type: Array})
   coverageRanges: CoverageRange[] = [];
 
+  @property({type: Boolean})
+  useNewContextControls = false;
+
   @property({
     type: Array,
     computed: '_computeLeftCoverageRanges(coverageRanges)',
@@ -360,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,
-      })
-    );
+    fire(this, EventType.SHOW_ALERT, message);
     throw Error(`Invalid preference value: ${pref}`);
   }
 
@@ -405,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 cae547e..5090462 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';
@@ -33,6 +36,7 @@
 import {PolymerDomWrapper} from '../../../types/types';
 import {GrDiffGroupType} from '../gr-diff/gr-diff-group';
 import {GrDiff} from '../gr-diff/gr-diff';
+import {fire, EventType} from '../../../utils/event-util';
 
 const DiffViewMode = {
   SIDE_BY_SIDE: 'SIDE_BY_SIDE',
@@ -61,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;
@@ -107,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();
@@ -152,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()) {
@@ -168,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();
     }
   }
 
@@ -196,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 =>
@@ -211,7 +211,7 @@
     if (
       navigateToNextFile &&
       result === CursorMoveResult.CLIPPED &&
-      this.$.cursorManager.isAtEnd()
+      this.isAtEnd()
     ) {
       if (
         this.lastDisplayedNavigateToNextFileToast &&
@@ -228,39 +228,39 @@
         );
       }
       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,
-        })
+      fire(
+        this,
+        EventType.SHOW_ALERT,
+        'Press n again to navigate to next unreviewed file'
       );
     }
 
     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) {
@@ -337,13 +337,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();
@@ -355,25 +355,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 =>
@@ -396,7 +400,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) {
@@ -523,7 +526,7 @@
 
   _updateStops() {
     this.$.cursorManager.stops = this.diffs.reduce(
-      (stops: HTMLElement[], diff) => stops.concat(diff.getCursorStops()),
+      (stops: Stop[], diff) => stops.concat(diff.getCursorStops()),
       []
     );
   }
@@ -553,6 +556,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
         );
@@ -568,6 +575,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
         );
@@ -588,21 +599,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 8e95f3d..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>
@@ -100,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();
@@ -117,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'},
         }));
@@ -225,6 +226,7 @@
     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() {
@@ -268,27 +270,62 @@
       ]};
     });
 
-    test('chunk skip functionality', () => {
-      const chunks = diffElement.root.querySelectorAll(
-          '.section.delta');
-      const indexOfChunk = function(chunk) {
-        return Array.prototype.indexOf.call(chunks, chunk);
+    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.',
+          ],
+        },
+      ]};
+    });
 
-      // We should be initialized to the first chunk (b)
-      let currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
-      assert.equal(currentIndex, 0);
-      assert.equal(cursorElement.side, 'right');
-
-      // Move to the next chunk.
-      cursorElement.moveToNextChunk();
-
-      // Since the next chunk only has content on the left side (a). we should have been
-      // automatically moved over.
-      const previousIndex = currentIndex;
-      currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
-      assert.equal(currentIndex, previousIndex + 1);
-      assert.equal(cursorElement.side, 'left');
+    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');
     });
   });
 
@@ -473,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;
 
@@ -490,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 bf6201c..4b0208a 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
@@ -34,8 +34,9 @@
 import {
   Comment,
   isDraft,
-  sortComments,
   UIComment,
+  CommentThread,
+  createCommentThreads,
 } from '../../../utils/comment-util';
 import {TwoSidesComments} from '../gr-comment-api/gr-comment-api';
 import {customElement, observe, property} from '@polymer/decorators';
@@ -44,33 +45,34 @@
   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 {PatchSetFile} from '../../../types/types';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {EventType, fire} from '../../../utils/event-util';
 
 const MSG_EMPTY_BLAME = 'No blame information for this diff.';
 
@@ -108,20 +110,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;
@@ -273,6 +261,8 @@
 
   private readonly reporting = appContext.reportingService;
 
+  private readonly flags = appContext.flagsService;
+
   /** @override */
   created() {
     super.created();
@@ -326,7 +316,6 @@
   /**
    * @param shouldReportMetric indicate a new Diff Page. This is a
    * signal to report metrics event that started on location change.
-   * @return
    */
   async reload(shouldReportMetric?: boolean) {
     this.clear();
@@ -420,50 +409,53 @@
     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, 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;
-            }
+      .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);
@@ -521,13 +513,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,
-            })
-          );
+          fire(this, EventType.SHOW_ALERT, MSG_EMPTY_BLAME);
           return Promise.reject(MSG_EMPTY_BLAME);
         }
 
@@ -713,46 +699,13 @@
     // and recreate them. If this changes in future, we might want to reuse
     // some DOM nodes here.
     this._clearThreads();
-    const threads = this._createThreads(allComments);
+    const threads = createCommentThreads(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;
   }
@@ -768,13 +721,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);
 
@@ -788,19 +742,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);
     }
@@ -821,18 +777,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 {
@@ -842,7 +798,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) => {
@@ -908,19 +864,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') {
@@ -933,7 +886,7 @@
 
   _getIgnoreWhitespace(): IgnoreWhitespaceType {
     if (!this.prefs || !this.prefs.ignore_whitespace) {
-      return IgnoreWhitespaceType.IGNORE_NONE;
+      return 'IGNORE_NONE';
     }
     return this.prefs.ignore_whitespace;
   }
@@ -1065,18 +1018,28 @@
     >,
     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)) {
+      fire(
+        this,
+        EventType.SHOW_ALERT,
+        `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) {
+      fire(
+        this,
+        EventType.SHOW_ALERT,
+        `A diff is longer than ${SYNTAX_MAX_DIFF_LENGTH}.` +
+          ' Syntax Highlighting was turned off.'
+      );
+      return false;
+    }
+    return true;
   }
 
   /**
@@ -1194,6 +1157,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 d1564b0..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
@@ -41,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 e4dda13..bf4d912 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,9 +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 '../../../utils/comment-util.js';
-import {Side} from '../../../constants/constants.js';
-import {generateChange} from '../../../test/test-utils';
+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');
 
@@ -55,7 +56,7 @@
     });
     test('plugin layers requested', () => {
       element.patchRange = {};
-      element.change = generateChange();
+      element.change = createChange();
       element.reload();
       assert(element.$.jsAPI.getDiffLayers.called);
     });
@@ -254,16 +255,20 @@
   });
 
   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;
@@ -323,7 +328,7 @@
       sinon.stub(element.$.restAPI, 'getDiff').returns(
           Promise.resolve({content: []}));
       element.patchRange = {};
-      element.change = generateChange();
+      element.change = createChange();
       element.$.restAPI.getDiffPreferences().then(prefs => {
         element.prefs = prefs;
         return element.reload(true);
@@ -344,7 +349,7 @@
       sinon.stub(element.$.restAPI, 'getDiff').returns(
           Promise.resolve({content: []}));
       element.patchRange = {};
-      element.change = generateChange();
+      element.change = createChange();
       element.reload();
       // Multiple cascading microtasks are scheduled.
       await flush();
@@ -366,7 +371,7 @@
       sinon.stub(element.$.restAPI, 'getDiff').returns(
           Promise.resolve({content: []}));
       element.patchRange = {};
-      element.change = generateChange();
+      element.change = createChange();
       let reloadComplete = false;
       element.$.restAPI.getDiffPreferences()
           .then(prefs => {
@@ -392,7 +397,7 @@
     // Stub the network calls into requests that never resolve.
     sinon.stub(element, '_getDiff').callsFake(() => new Promise(() => {}));
     element.patchRange = {};
-    element.change = generateChange();
+    element.change = createChange();
 
     // Needs to be set to something first for it to cancel.
     element.diff = {
@@ -410,7 +415,7 @@
       getLoggedIn = false;
       element = basicFixture.instantiate();
       element.changeNum = 123;
-      element.change = generateChange();
+      element.change = createChange();
       element.path = 'some/path';
     });
 
@@ -542,7 +547,7 @@
             );
 
         element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
-        element.change = generateChange();
+        element.change = createChange();
         element.comments = {
           left: [],
           right: [],
@@ -1161,6 +1166,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',
@@ -1168,6 +1175,8 @@
         __commentSide: 'left',
         line: 1,
         in_reply_to: 'sallys_confession',
+        patch_set: 1,
+        path: 'some/path',
       },
       {
         id: 'new_draft',
@@ -1175,25 +1184,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', () => {
@@ -1208,15 +1219,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: {
@@ -1236,13 +1252,12 @@
           end_line: 1,
           end_character: 2,
         },
-        lineNum: 1,
-        isOnParent: false,
+        line: 1,
       },
     ];
 
     assert.deepEqual(
-        element._createThreads(comments),
+        createCommentThreads(comments),
         expectedThreads);
   });
 
@@ -1254,55 +1269,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.
@@ -1314,63 +1305,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);
   });
 
@@ -1405,8 +1395,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,
@@ -1425,10 +1413,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,
@@ -1447,7 +1431,7 @@
       element.patchRange = {};
       element.prefs = prefs;
       element.changeNum = 123;
-      element.change = generateChange();
+      element.change = createChange();
       element.path = 'some/path';
     });
 
@@ -1502,7 +1486,7 @@
         }],
       };
       element.patchRange = {};
-      element.change = generateChange();
+      element.change = createChange();
       element.prefs = prefs;
     });
 
@@ -1531,8 +1515,8 @@
     setup(() => {
       notifyStub = sinon.stub();
       stub('gr-js-api-interface', {
-        getCoverageAnnotationApi() {
-          return Promise.resolve({
+        getCoverageAnnotationApis() {
+          return Promise.resolve([{
             notify: notifyStub,
             getCoverageProvider() {
               return () => Promise.resolve([
@@ -1554,12 +1538,12 @@
                 },
               ]);
             },
-          });
+          }]);
         },
       });
       element = basicFixture.instantiate();
       element.changeNum = 123;
-      element.change = generateChange();
+      element.change = createChange();
       element.path = 'some/path';
       const prefs = {
         line_length: 10,
@@ -1576,10 +1560,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 b75ba8f..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';
 
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
index 4bde136..cbee7c5 100644
--- 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
@@ -75,8 +75,6 @@
   ChangeInfo,
   CommitId,
   ConfigInfo,
-  DiffInfo,
-  DiffPreferencesInfo,
   EditInfo,
   EditPatchSetNum,
   ElementPropertyDeepChange,
@@ -89,6 +87,7 @@
   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';
@@ -101,6 +100,8 @@
 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 {fire, EventType} from '../../../utils/event-util';
 
 const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
 const MSG_LOADING_BLAME = 'Loading blame...';
@@ -295,8 +296,8 @@
       [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_REPLY_DIALOG]: '_handleOpenReplyDialog',
+      [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
       [Shortcut.OPEN_DOWNLOAD_DIALOG]: '_handleOpenDownloadDialog',
       [Shortcut.UP_TO_CHANGE]: '_handleUpToChange',
       [Shortcut.OPEN_DIFF_PREFS]: '_handleCommaKey',
@@ -457,13 +458,7 @@
     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,
-        })
-      );
+      fire(this, EventType.SHOW_ALERT, ERR_REVIEW_STATUS);
       throw err;
     });
   }
@@ -605,6 +600,7 @@
     if (this.modifierPressed(e)) return;
 
     e.preventDefault();
+    this.classList.remove('hideComments');
     this.$.cursor.createCommentInPlace();
   }
 
@@ -658,16 +654,8 @@
     }
   }
 
-  _handleOpenReplyDialogOrToggleLeftPane(e: CustomKeyboardEvent) {
+  _handleOpenReplyDialog(e: CustomKeyboardEvent) {
     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;
 
@@ -676,6 +664,14 @@
     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;
@@ -880,18 +876,12 @@
 
   _displayDiffBaseAgainstLeftToast() {
     if (!this._patchRange) return;
-    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,
-      })
+    fire(
+      this,
+      EventType.SHOW_ALERT,
+      `Patchset ${this._patchRange.basePatchNum} vs ` +
+        `${this._patchRange.patchNum} selected. Press v + \u2190 to view ` +
+        `Base vs ${this._patchRange.basePatchNum}`
     );
   }
 
@@ -903,17 +893,12 @@
     )
       ? 'Base'
       : `Patchset ${this._patchRange.basePatchNum}`;
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {
-          // \u2191 = ↑
-          message: `${leftPatchset} vs
+    fire(
+      this,
+      EventType.SHOW_ALERT,
+      `${leftPatchset} vs
             ${this._patchRange.patchNum} selected\n. Press v + \u2191 to view
-            ${leftPatchset} vs Patchset ${latestPatchNum}`,
-        },
-        composed: true,
-        bubbles: true,
-      })
+            ${leftPatchset} vs Patchset ${latestPatchNum}`
     );
   }
 
@@ -956,6 +941,23 @@
     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;
@@ -965,15 +967,7 @@
         this.params.commentId
       );
       if (!comment) {
-        this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {
-              message: 'comment not found',
-            },
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fire(this, EventType.SHOW_ALERT, 'comment not found');
         GerritNav.navigateToChange(this._change);
         return;
       }
@@ -1014,6 +1008,12 @@
     }
     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);
 
     this._commentsForDiff = this._getCommentsForPath(
@@ -1060,6 +1060,11 @@
       return;
     }
 
+    const portedCommentsPromise = this.$.commentAPI.getPortedComments(
+      value.changeNum,
+      value.patchNum || 'current'
+    );
+
     const promises: Promise<unknown>[] = [];
 
     promises.push(this._getDiffPreferences());
@@ -1080,10 +1085,14 @@
     this._loading = true;
     return Promise.all(promises)
       .then(r => {
+        this.reporting.time(PORTING_COMMENTS_DIFF_LATENCY_LABEL);
         this._loading = false;
         this._initPatchRange();
         this._initCommitRange();
         this.$.diffHost.comments = this._commentsForDiff;
+        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}`, {
@@ -1113,17 +1122,13 @@
             return;
           }
 
-          this.dispatchEvent(
-            new CustomEvent('show-alert', {
-              detail: {
-                message: `File is unchanged between Patchset
+          fire(
+            this,
+            EventType.SHOW_ALERT,
+            `File is unchanged between Patchset
                   ${this._patchRange.basePatchNum} and
                   ${this._patchRange.patchNum}. Showing diff of Base vs
-                  ${this._patchRange.basePatchNum}`,
-              },
-              composed: true,
-              bubbles: true,
-            })
+                  ${this._patchRange.basePatchNum}`
           );
           GerritNav.navigateToDiff(
             this._change,
@@ -1359,9 +1364,12 @@
       patchNum,
       path,
     });
-    const commentCount = changeComments.computeCommentCount({patchNum, path});
-    const commentString = GrCountStringFormatter.computePluralString(
-      commentCount,
+    const commentThreadCount = changeComments.computeCommentThreadCount({
+      patchNum,
+      path,
+    });
+    const commentThreadString = GrCountStringFormatter.computePluralString(
+      commentThreadCount,
       'comment'
     );
     const unresolvedString = GrCountStringFormatter.computeString(
@@ -1371,7 +1379,7 @@
 
     const unmodifiedString = changeFileInfo.status === 'U' ? 'no changes' : '';
 
-    return [unmodifiedString, commentString, unresolvedString]
+    return [unmodifiedString, commentThreadString, unresolvedString]
       .filter(v => v && v.length > 0)
       .join(', ');
   }
@@ -1452,26 +1460,12 @@
     _: Event,
     detail: {side: Side | CommentSide; number: number}
   ) {
-    if (!this._change) return;
-    if (!this._path) return;
-    if (!this._changeNum) return;
-    if (!this._patchRange) 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 === Side.LEFT || detail.side === CommentSide.PARENT;
-    const url = GerritNav.getUrlForDiffById(
-      this._changeNum,
-      this._change.project,
-      this._path,
-      this._patchRange.patchNum,
-      this._patchRange.basePatchNum,
-      number,
-      leftSide
+    this._updateUrlToDiffUrl(
+      detail.number,
+      detail.side === Side.LEFT || detail.side === CommentSide.PARENT
     );
-    history.replaceState(null, '', url);
   }
 
   _computeDownloadDropdownLinks(
@@ -1678,24 +1672,12 @@
 
   _loadBlame() {
     this._isBlameLoading = true;
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {message: MSG_LOADING_BLAME},
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fire(this, EventType.SHOW_ALERT, MSG_LOADING_BLAME);
     this.$.diffHost
       .loadBlame()
       .then(() => {
         this._isBlameLoading = false;
-        this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message: MSG_LOADED_BLAME},
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fire(this, EventType.SHOW_ALERT, MSG_LOADED_BLAME);
       })
       .catch(() => {
         this._isBlameLoading = false;
@@ -1741,15 +1723,7 @@
     if (!this._patchRange) return;
 
     if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Base is already selected.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(this, EventType.SHOW_ALERT, 'Base is already selected.');
       return;
     }
     GerritNav.navigateToDiff(
@@ -1766,15 +1740,7 @@
     if (!this._patchRange) return;
 
     if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Left is already base.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(this, EventType.SHOW_ALERT, 'Left is already base.');
       return;
     }
     GerritNav.navigateToDiff(
@@ -1796,15 +1762,7 @@
 
     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,
-        })
-      );
+      fire(this, EventType.SHOW_ALERT, 'Latest is already selected.');
       return;
     }
 
@@ -1824,15 +1782,7 @@
 
     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,
-        })
-      );
+      fire(this, EventType.SHOW_ALERT, 'Right is already latest.');
       return;
     }
     GerritNav.navigateToDiff(
@@ -1854,15 +1804,7 @@
       patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
       patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)
     ) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {
-            message: 'Already diffing base against latest.',
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fire(this, EventType.SHOW_ALERT, 'Already diffing base against latest.');
       return;
     }
     GerritNav.navigateToDiff(this._change, this._path, latestPatchNum);
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 d7fa86b..7ef75ed 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,12 +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');
 
@@ -144,7 +148,7 @@
             path: '/COMMIT_MSG',
           },
         ]},
-        computeCommentCount: () => {},
+        computeCommentThreadCount: () => {},
         computeUnresolvedNum: () => {},
         getPaths: () => {},
         getCommentsBySideForPath: () => {},
@@ -180,12 +184,16 @@
     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',
@@ -197,13 +205,20 @@
         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));
       });
     });
 
@@ -237,8 +252,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',
@@ -247,7 +264,10 @@
             commentId: 'c1',
           };
           sinon.stub(element.$.diffHost, '_commentsChanged');
-          element._change = generateChange({revisionsCount: 11});
+          element._change = {
+            ...createChange(),
+            revisions: createRevisions(11),
+          };
           return element._paramsChanged.returnValues[0].then(() => {
             assert.isTrue(diffNavStub.lastCall.calledWithExactly(
                 element._change, '/COMMIT_MSG', 2, 'PARENT', 10));
@@ -262,8 +282,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',
@@ -272,7 +294,10 @@
             commentId: 'c3',
           };
           sinon.stub(element.$.diffHost, '_commentsChanged');
-          element._change = generateChange({revisionsCount: 11});
+          element._change = {
+            ...createChange(),
+            revisions: createRevisions(11),
+          };
           return element._paramsChanged.returnValues[0].then(() => {
             assert.isFalse(diffNavStub.called);
           });
@@ -317,7 +342,10 @@
       element.$.restAPI.getDiffChangeDetail.restore();
       sinon.stub(element.$.restAPI, 'getDiffChangeDetail')
           .returns(
-              Promise.resolve(generateChange({revisionsCount: 11})));
+              Promise.resolve({
+                ...createChange(),
+                revisions: createRevisions(11),
+              }));
       element._patchRange = {
         patchNum: 2,
         basePatchNum: 1,
@@ -472,7 +500,10 @@
     });
 
     test('diff against latest', () => {
-      element._change = generateChange({revisionsCount: 12});
+      element._change = {
+        ...createChange(),
+        revisions: createRevisions(12),
+      };
       element._patchRange = {
         basePatchNum: 5,
         patchNum: 10,
@@ -486,7 +517,10 @@
     });
 
     test('_handleDiffBaseAgainstLeft', () => {
-      element._change = generateChange({revisionsCount: 10});
+      element._change = {
+        ...createChange(),
+        revisions: createRevisions(10),
+      };
       element._patchRange = {
         patchNum: 3,
         basePatchNum: 1,
@@ -504,7 +538,10 @@
 
     test('_handleDiffBaseAgainstLeft when initially navigating to a comment',
         () => {
-          element._change = generateChange({revisionsCount: 10});
+          element._change = {
+            ...createChange(),
+            revisions: createRevisions(10),
+          };
           element._patchRange = {
             patchNum: 3,
             basePatchNum: 1,
@@ -523,7 +560,10 @@
         });
 
     test('_handleDiffRightAgainstLatest', () => {
-      element._change = generateChange({revisionsCount: 10});
+      element._change = {
+        ...createChange(),
+        revisions: createRevisions(10),
+      };
       element._patchRange = {
         basePatchNum: 1,
         patchNum: 3,
@@ -538,7 +578,10 @@
     });
 
     test('_handleDiffBaseAgainstLatest', () => {
-      element._change = generateChange({revisionsCount: 10});
+      element._change = {
+        ...createChange(),
+        revisions: createRevisions(10),
+      };
       element._patchRange = {
         basePatchNum: 1,
         patchNum: 3,
@@ -866,14 +909,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);
@@ -920,8 +963,8 @@
           basePatchNum: PARENT,
           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(
@@ -1404,9 +1447,6 @@
 
     suite('_initPatchRange', () => {
       setup(async () => {
-        // const changeDetail = generateChange({revisionsCount: 5});
-        // sinon.stub(element.$.restAPI, 'getDiffChangeDetail')
-        //     .returns(Promise.resolve(changeDetail));
         element.params = {
           view: GerritView.DIFF,
           changeNum: '42',
@@ -1701,7 +1741,10 @@
         patchNum: 1,
         basePatchNum: 'PARENT',
       };
-      element._change = generateChange({revisionsCount: 1});
+      element._change = {
+        ...createChange(),
+        revisions: createRevisions(1),
+      };
       flush();
       assert.isTrue(GerritNav.navigateToDiff.notCalled);
 
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 7c317bb..a63b468 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 {fire, EventType} 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.';
@@ -191,9 +195,18 @@
   @property({type: Object})
   lineOfInterest?: LineOfInterest;
 
-  /** True when diff is changed, until the content is done rendering. */
-  @property({type: Boolean})
-  _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;
@@ -240,6 +253,9 @@
   @property({type: Boolean})
   showNewlineWarningRight = false;
 
+  @property({type: Boolean})
+  useNewContextControls = false;
+
   @property({
     type: String,
     computed:
@@ -272,6 +288,7 @@
   /** @override */
   created() {
     super.created();
+    this._setLoading(true);
     this.addEventListener('create-range-comment', (e: Event) =>
       this._handleCreateRangeComment(e as CustomEvent)
     );
@@ -402,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: {}};
@@ -416,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,
@@ -456,14 +472,17 @@
     this.cancelDebouncer(RENDER_DIFF_TABLE_DEBOUNCE_NAME);
   }
 
-  getCursorStops(): HTMLElement[] {
+  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<HTMLElement>(
+      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,
-        })
-      );
+      fire(this, EventType.SHOW_ALERT, '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,12 +613,10 @@
       return false;
     }
     if (!this.patchRange) {
-      this.dispatchEvent(
-        new CustomEvent('show-alert', {
-          detail: {message: 'Cannot create comment. Patch range undefined.'},
-          composed: true,
-          bubbles: true,
-        })
+      fire(
+        this,
+        EventType.SHOW_ALERT,
+        'Cannot create comment. Patch range undefined.'
       );
       return false;
     }
@@ -624,24 +630,14 @@
       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,
-        })
-      );
+      fire(this, EventType.SHOW_ALERT, '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,
-        })
+      fire(
+        this,
+        EventType.SHOW_ALERT,
+        'You cannot comment on the base patchset of an edit.'
       );
       return false;
     }
@@ -665,6 +661,7 @@
       lineEl,
       contentEl
     );
+    const commentSide = isOnParent ? CommentSide.PARENT : CommentSide.REVISION;
     this.dispatchEvent(
       new CustomEvent('create-comment', {
         bubbles: true,
@@ -672,9 +669,11 @@
         detail: {
           lineNum,
           side,
+          commentSide,
           patchNum: patchForNewThreads,
-          isOnParent,
           range,
+          path: this.path,
+          isOnParent,
         },
       })
     );
@@ -826,7 +825,7 @@
   }
 
   _diffChanged(newValue?: DiffInfo) {
-    this._loading = true;
+    this._setLoading(true);
     this._cleanup();
     if (newValue) {
       this._diffLength = this.getDiffLength(newValue);
@@ -886,7 +885,7 @@
   }
 
   _handleRenderContent() {
-    this._loading = false;
+    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 e9de9e7..b587d8a 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,
@@ -206,12 +238,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 +271,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);
     }
@@ -437,6 +570,7 @@
           base-image="[[baseImage]]"
           layers="[[layers]]"
           revision-image="[[revisionImage]]"
+          use-new-context-controls="[[useNewContextControls]]"
         >
           <table
             id="diffTable"
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 ed44807..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,9 +45,6 @@
         --native-select-style: {
           max-width: 5.25em;
         }
-        --dropdown-content-stype: {
-          max-width: 300px;
-        }
       }
     }
   </style>
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/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 3f2c6a3..8121855 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 {fire, EventType} 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;
 
@@ -249,13 +256,7 @@
           storedContent.message &&
           storedContent.message !== content
         ) {
-          this.dispatchEvent(
-            new CustomEvent('show-alert', {
-              detail: {message: RESTORED_MESSAGE},
-              bubbles: true,
-              composed: true,
-            })
-          );
+          fire(this, EventType.SHOW_ALERT, RESTORED_MESSAGE);
 
           this._newContent = storedContent.message;
         } else {
@@ -289,22 +290,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,
-      })
-    );
+    fire(this, EventType.SHOW_ALERT, message);
   }
 
   _computeSaveDisabled(
@@ -328,6 +324,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/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-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-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..6a906fa 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 {fire, EventType} from '../../../utils/event-util';
 
 export interface GrClaView {
   $: {
@@ -156,13 +157,7 @@
   }
 
   _createToast(message: string) {
-    this.dispatchEvent(
-      new CustomEvent('show-alert', {
-        detail: {message},
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fire(this, EventType.SHOW_ALERT, message);
   }
 
   _computeShowAgreementsClass(showAgreements: boolean) {
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-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.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
index 9f9840b..6fcb6e8 100644
--- 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
@@ -68,6 +68,7 @@
 import {GerritView} from '../../core/gr-navigation/gr-navigation';
 import {GrEmailEditor} from '../gr-email-editor/gr-email-editor';
 import {CustomKeyboardEvent} from '../../../types/events';
+import {fire, EventType} from '../../../utils/event-util';
 
 const PREFS_SECTION_FIELDS: Array<keyof PreferencesInput> = [
   'changes_per_page',
@@ -286,13 +287,7 @@
       promises.push(
         this.$.restAPI.confirmEmail(this.params.emailToken).then(message => {
           if (message) {
-            this.dispatchEvent(
-              new CustomEvent('show-alert', {
-                detail: {message},
-                composed: true,
-                bubbles: true,
-              })
-            );
+            fire(this, EventType.SHOW_ALERT, message);
           }
           this.$.emailEditor.loadData();
         })
@@ -542,14 +537,10 @@
       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,
-      })
+    fire(
+      this,
+      EventType.SHOW_ALERT,
+      `Theme changed to ${this._isDark ? 'dark' : 'light'}.`
     );
   }
 
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..e5f0367 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 {fire, EventType} 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,
-          })
-        );
+        fire(this, EventType.SHOW_ALERT, VALID_EMAIL_ALERT);
         return false;
       } else {
         const account = {email: item, _pendingAdd: true};
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 878c01c..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
@@ -50,6 +50,7 @@
 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;
@@ -88,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
@@ -146,8 +147,8 @@
   @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;
@@ -200,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(
@@ -223,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;
@@ -272,7 +273,7 @@
         path,
         patchNum,
         undefined,
-        this.lineNum
+        this.lineNum === FILE ? undefined : this.lineNum
       );
     }
     const id = this.comments[0].id;
@@ -293,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 '';
   }
@@ -490,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),
@@ -516,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/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index e4d520f..48fbb69 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -60,6 +60,7 @@
   UIRobot,
 } from '../../../utils/comment-util';
 import {OpenFixPreviewEventDetail} from '../../../types/events';
+import {fire, EventType} from '../../../utils/event-util';
 
 const STORAGE_DEBOUNCE_INTERVAL = 400;
 const TOAST_DEBOUNCE_INTERVAL = 200;
@@ -850,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,
-          })
-        );
+        fire(document.body, EventType.SHOW_ALERT, 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 c021461..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=""
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-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_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-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index 90aaa9f..71287a7 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 {fire, EventType} 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,
-          })
-        );
+        fire(this, EventType.SHOW_ALERT, RESTORED_MESSAGE);
       }
     }
     if (!content) {
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-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 a493a2e..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
@@ -19,13 +19,9 @@
   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 = '/',
@@ -102,7 +98,7 @@
 
   ActionType = ActionType;
 
-  constructor(public plugin: Plugin, el?: GrChangeActionsElement) {
+  constructor(public plugin: PluginApi, el?: GrChangeActionsElement) {
     this.setEl(el);
   }
 
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 626c2dc..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
@@ -281,18 +281,18 @@
    * 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(): MenuLink[] {
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 261298b..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
@@ -50,7 +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-public-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
index 0625f67..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,
@@ -46,6 +46,7 @@
 import {HttpMethod} from '../../../constants/constants';
 import {JsApiService} from './gr-js-api-types';
 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 626e918..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
@@ -97,9 +97,9 @@
    * 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 (!isDetailedLabelInfo(labelInfo)) {
@@ -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 {
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 957496c..cb25b81 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
@@ -22,6 +22,7 @@
 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;
@@ -59,7 +60,9 @@
 
   private _boundHandleClose: () => void = () => super.close();
 
-  private focusableNodes: Node[] | undefined;
+  private focusableNodes?: Node[];
+
+  private returnFocusTo?: HTMLElement;
 
   get _focusableNodes() {
     if (this.focusableNodes) {
@@ -89,6 +92,7 @@
   }
 
   open() {
+    this.returnFocusTo = findActiveElement(document, true) ?? undefined;
     window.addEventListener('popstate', this._boundHandleClose);
     return new Promise((resolve, reject) => {
       super.open.apply(this);
@@ -121,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 75af8a4..3f3ded2 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,
@@ -142,6 +140,11 @@
   MergeableInfo,
 } from '../../../types/common';
 import {
+  DiffInfo,
+  DiffPreferencesInfo,
+  IgnoreWhitespaceType,
+} from '../../../types/diff';
+import {
   CancelConditionCallback,
   ErrorCallback,
   RestApiService,
@@ -152,7 +155,6 @@
   CommentSide,
   DiffViewMode,
   HttpMethod,
-  IgnoreWhitespaceType,
   ReviewerState,
 } from '../../../constants/constants';
 
@@ -183,7 +185,7 @@
   reportEndpointAsIs?: boolean;
   endpoint: string;
   anonymizedEndpoint?: string;
-  patchNum?: PatchSetNum;
+  revision?: RevisionId;
   changeNum: NumericChangeId;
   errFn?: ErrorCallback;
   params?: FetchParams;
@@ -251,7 +253,6 @@
 
 interface GetDiffParams {
   [paramName: string]: string | undefined | null | number | boolean;
-  context?: number | 'ALL';
   intraline?: boolean | null;
   whitespace?: IgnoreWhitespaceType;
   parent?: number;
@@ -351,9 +352,7 @@
     );
   }
 
-  private _fetchSharedCacheURL(
-    req: FetchJSONRequest
-  ): Promise<ParsedJSON | undefined> {
+  _fetchSharedCacheURL(req: FetchJSONRequest): Promise<ParsedJSON | undefined> {
     // Cache is shared across instances
     return this._restApiHelper.fetchCacheURL(req);
   }
@@ -830,7 +829,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,
@@ -1550,7 +1549,7 @@
     return this._getChangeURLAndFetch({
       changeNum,
       endpoint: '/commit?links',
-      patchNum,
+      revision: patchNum,
       reportEndpointAsIs: true,
     }) as Promise<CommitInfo | undefined>;
   }
@@ -1568,7 +1567,7 @@
     return this._getChangeURLAndFetch({
       changeNum,
       endpoint: '/files',
-      patchNum: patchRange.patchNum,
+      revision: patchRange.patchNum,
       params,
       reportEndpointAsIs: true,
     }) as Promise<FileNameToFileInfoMap | undefined>;
@@ -1600,7 +1599,7 @@
     return this._getChangeURLAndFetch({
       changeNum,
       endpoint: `/files?q=${encodeURIComponent(query)}`,
-      patchNum,
+      revision: patchNum,
       anonymizedEndpoint: '/files?q=*',
     }) as Promise<string[] | undefined>;
   }
@@ -1624,7 +1623,7 @@
     const req: FetchChangeJSON = {
       changeNum,
       endpoint: '/actions',
-      patchNum,
+      revision: patchNum,
       reportEndpointAsIs: true,
     };
     return this._getChangeURLAndFetch(req) as Promise<
@@ -2006,7 +2005,7 @@
     return this._getChangeURLAndFetch({
       changeNum,
       endpoint: '/related',
-      patchNum,
+      revision: patchNum,
       reportEndpointAsIs: true,
     }) as Promise<RelatedChangesInfo | undefined>;
   }
@@ -2098,7 +2097,7 @@
     return this._getChangeURLAndFetch({
       changeNum,
       endpoint: '/files?reviewed',
-      patchNum,
+      revision: patchNum,
       reportEndpointAsIs: true,
     }) as Promise<string[] | undefined>;
   }
@@ -2371,7 +2370,7 @@
   ): Promise<FilePathToDiffInfoMap | undefined> {
     return this._getChangeURLAndFetch({
       changeNum,
-      patchNum,
+      revision: patchNum,
       endpoint: `/fixes/${encodeURIComponent(fixId)}/preview`,
       reportEndpointAsId: true,
     }) as Promise<FilePathToDiffInfoMap | undefined>;
@@ -2525,9 +2524,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);
@@ -2539,7 +2537,7 @@
     const req: FetchChangeJSON = {
       changeNum,
       endpoint,
-      patchNum,
+      revision: patchNum,
       errFn,
       params,
       anonymizedEndpoint: '/files/*/diff',
@@ -2727,7 +2725,7 @@
         {
           changeNum,
           endpoint,
-          patchNum,
+          revision: patchNum,
           reportEndpointAsIs: true,
         },
         noAcceptHeader
@@ -2797,6 +2795,43 @@
     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,
@@ -3432,10 +3467,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,
@@ -3505,7 +3540,7 @@
     return this._getChangeURLAndFetch({
       changeNum,
       endpoint: `/files/${encodedPath}/blame`,
-      patchNum,
+      revision: patchNum,
       params: base ? {base: 't'} : undefined,
       anonymizedEndpoint: '/files/*/blame',
     }) as Promise<BlameInfo[] | undefined>;
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-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_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/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
index a926fdb..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
@@ -129,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',
@@ -551,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[]) {
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/polymer-resin-install.ts b/polygerrit-ui/app/scripts/polymer-resin-install.ts
index 8a30254..ee03171 100644
--- a/polygerrit-ui/app/scripts/polymer-resin-install.ts
+++ b/polygerrit-ui/app/scripts/polymer-resin-install.ts
@@ -57,10 +57,16 @@
 
 const security = window.security;
 
-export function installPolymerResin(safeTypesBridge: SafeTypeBridge) {
+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: security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER,
+    reportHandler,
     safeTypesBridge,
   });
 }
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 047e9e0..e40412d 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -25,6 +25,6 @@
  */
 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',
 }
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 950619b..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,
@@ -29,7 +30,6 @@
   PatchSetNum,
   RequestPayload,
   PreferencesInput,
-  DiffPreferencesInfo,
   EditPreferencesInfo,
   DiffPreferenceInput,
   SshKeyInfo,
@@ -86,7 +86,6 @@
   EmailAddress,
   FixId,
   FilePathToDiffInfoMap,
-  DiffInfo,
   BlameInfo,
   PatchRange,
   ImagesForDiff,
@@ -101,8 +100,12 @@
   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;
@@ -457,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>;
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 9b62718..d7b96c8 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -122,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 */
@@ -134,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;
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 eead4f8..0000000
--- a/polygerrit-ui/app/test/common-test-setup.js
+++ /dev/null
@@ -1,154 +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.
- */
-// This should be the first import to install handler before any other code
-import './source-map-support-install.js';
-// 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.js b/polygerrit-ui/app/test/source-map-support-install.ts
similarity index 73%
rename from polygerrit-ui/app/test/source-map-support-install.js
rename to polygerrit-ui/app/test/source-map-support-install.ts
index a8f147382..b8798e2 100644
--- a/polygerrit-ui/app/test/source-map-support-install.js
+++ b/polygerrit-ui/app/test/source-map-support-install.ts
@@ -15,6 +15,19 @@
  * 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 76a179e..b454b6e 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -37,7 +37,6 @@
   TimeFormat,
   EmailStrategy,
   DefaultBase,
-  IgnoreWhitespaceType,
   UserPriority,
   DiffViewMode,
   DraftsAction,
@@ -45,9 +44,13 @@
   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};
 
@@ -248,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
@@ -540,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;
@@ -714,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
@@ -736,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;
@@ -750,9 +743,9 @@
  */
 export interface AuthInfo {
   auth_type: AuthType; // docs incorrectly names it 'type'
-  use_contributor_agreements: boolean;
+  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;
@@ -799,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: number;
-  submit_whole_topic: boolean;
-  disable_private_changes: boolean;
-  mergeability_computation_behavior: string;
+  submit_whole_topic?: boolean;
+  disable_private_changes?: boolean;
+  mergeability_computation_behavior: MergeabilityComputationBehavior;
   enable_attention_set: boolean;
   enable_assignee: boolean;
 }
@@ -817,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;
 }
 
 /**
@@ -906,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};
@@ -954,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;
@@ -965,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;
@@ -1022,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[];
 }
@@ -1050,7 +1044,8 @@
   change: ChangeConfigInfo;
   download: DownloadInfo;
   gerrit: GerritInfo;
-  index: IndexConfigInfo;
+  // docs mentions index property, but it doesn't exists in Java class
+  // index: IndexConfigInfo;
   note_db_enabled?: boolean;
   plugin: PluginConfigInfo;
   receive?: ReceiveInfo;
@@ -1074,7 +1069,7 @@
  * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#suggest-info
  */
 export interface SuggestInfo {
-  from: string;
+  from: number;
 }
 
 /**
@@ -1149,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;
@@ -1180,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
@@ -1231,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
  */
@@ -1373,7 +1279,7 @@
 }
 
 /**
- * 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
  */
@@ -2040,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};
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
index ef0515e..529904a 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -142,6 +142,18 @@
   }
 }
 
+export interface ShowAlertEventDetail {
+  message: string;
+}
+
+export type ShowAlertEvent = CustomEvent<ShowAlertEventDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'show-alert': ShowAlertEvent;
+  }
+}
+
 /**
  * Keyboard events emitted from polymer elements.
  */
diff --git a/polygerrit-ui/app/types/globals.ts b/polygerrit-ui/app/types/globals.ts
index 9ddd521..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,
@@ -75,50 +69,50 @@
     // 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: {};
   }
 
@@ -138,4 +132,9 @@
     // 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/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/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 5f8aa82..5af9bb7 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -21,9 +21,12 @@
   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;
@@ -97,17 +100,57 @@
   });
 }
 
+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[];
-  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;
+  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 {
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index 183e56c..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;
 
 /**
diff --git a/polygerrit-ui/app/utils/date-util.ts b/polygerrit-ui/app/utils/date-util.ts
index 3cad21a..1dd2d2f 100644
--- a/polygerrit-ui/app/utils/date-util.ts
+++ b/polygerrit-ui/app/utils/date-util.ts
@@ -141,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/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index 76db40b..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();
 }
 
 /**
@@ -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/test/source-map-support-install.js b/polygerrit-ui/app/utils/event-util.ts
similarity index 69%
copy from polygerrit-ui/app/test/source-map-support-install.js
copy to polygerrit-ui/app/utils/event-util.ts
index a8f147382..5684b40 100644
--- a/polygerrit-ui/app/test/source-map-support-install.js
+++ b/polygerrit-ui/app/utils/event-util.ts
@@ -15,6 +15,16 @@
  * limitations under the License.
  */
 
-// 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();
+export enum EventType {
+  SHOW_ALERT = 'show-alert',
+}
+
+export function fire(target: EventTarget, type: EventType, message: string) {
+  target.dispatchEvent(
+    new CustomEvent(type, {
+      detail: {message},
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index 01df5ab..8974af8 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -7,7 +7,10 @@
   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
@@ -49,10 +52,6 @@
   wip?: boolean;
 }
 
-interface RevisionWithSha extends RevisionInfo {
-  sha: string;
-}
-
 interface PatchRange {
   patchNum?: PatchSetNum;
   basePatchNum?: PatchSetNum;
@@ -128,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) {
@@ -144,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
@@ -162,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.
@@ -200,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 {
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/karma.conf.js b/polygerrit-ui/karma.conf.js
index 3f7221a..fe3fa0c 100644
--- a/polygerrit-ui/karma.conf.js
+++ b/polygerrit-ui/karma.conf.js
@@ -163,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 91b8579..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",
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 a70ded8..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"
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/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 2f8c5bc..970a4a9 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -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 31cce3b..74c4769 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -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 5689009..e8fae82 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -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 4e0d545..be6688a 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -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/nongoogle.bzl b/tools/nongoogle.bzl
index a8f5af0..a3cc66e 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -23,8 +23,8 @@
 
     maven_jar(
         name = "dropwizard-core",
-        artifact = "io.dropwizard.metrics:metrics-core:4.1.12.1",
-        sha1 = "cb2f351bf4463751201f43bb99865235d5ba07ca",
+        artifact = "io.dropwizard.metrics:metrics-core:4.1.14",
+        sha1 = "14cf9dd67619a0390812dddb232df339e3383d35",
     )
 
     SSHD_VERS = "2.4.0"
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/.gitignore b/tools/release_noter/.gitignore
index 6bb75bd..c791f63 100644
--- a/tools/release_noter/.gitignore
+++ b/tools/release_noter/.gitignore
@@ -1,2 +1,2 @@
 /.idea/
-/release_noter.md
+/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
index bd9a28c..8e67cf8 100644
--- a/tools/release_noter/Pipfile
+++ b/tools/release_noter/Pipfile
@@ -8,6 +8,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
index 07ee7fe..7454fe7 100644
--- a/tools/release_noter/Pipfile.lock
+++ b/tools/release_noter/Pipfile.lock
@@ -1,7 +1,7 @@
 {
     "_meta": {
         "hash": {
-            "sha256": "9a69912e043a63c885e5f7e15fb5011ca20c3de0e7fc8e9d26bd8eaed1f58fa9"
+            "sha256": "66a7d7fdb0a62b702f5414852b80c579a3c16d7a4ed1f3b5344943437c6157ee"
         },
         "pipfile-spec": 6,
         "requires": {
@@ -15,7 +15,110 @@
             }
         ]
     },
-    "default": {},
+    "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": [
@@ -88,35 +191,35 @@
         },
         "regex": {
             "hashes": [
-                "sha256:1a16afbfadaadc1397353f9b32e19a65dc1d1804c80ad73a14f435348ca017ad",
-                "sha256:2308491b3e6c530a3bb38a8a4bb1dc5fd32cbf1e11ca623f2172ba17a81acef1",
-                "sha256:39a5ef30bca911f5a8a3d4476f5713ed4d66e313d9fb6755b32bec8a2e519635",
-                "sha256:3d5a8d007116021cf65355ada47bf405656c4b3b9a988493d26688275fde1f1c",
-                "sha256:4302153abb96859beb2c778cc4662607a34175065fc2f33a21f49eb3fbd1ccd3",
-                "sha256:463e770c48da76a8da82b8d4a48a541f314e0df91cbb6d873a341dbe578efafd",
-                "sha256:46ab6070b0d2cb85700b8863b3f5504c7f75d8af44289e9562195fe02a8dd72d",
-                "sha256:4f5c0fe46fb79a7adf766b365cae56cafbf352c27358fda811e4a1dc8216d0db",
-                "sha256:60c4f64d9a326fe48e8738c3dbc068e1edc41ff7895a9e3723840deec4bc1c28",
-                "sha256:671c51d352cfb146e48baee82b1ee8d6ffe357c292f5e13300cdc5c00867ebfc",
-                "sha256:6cf527ec2f3565248408b61dd36e380d799c2a1047eab04e13a2b0c15dd9c767",
-                "sha256:7c4fc5a8ec91a2254bb459db27dbd9e16bba1dabff638f425d736888d34aaefa",
-                "sha256:850339226aa4fec04916386577674bb9d69abe0048f5d1a99f91b0004bfdcc01",
-                "sha256:8ba3efdd60bfee1aa784dbcea175eb442d059b576934c9d099e381e5a9f48930",
-                "sha256:8c8c42aa5d3ac9a49829c4b28a81bebfa0378996f9e0ca5b5ab8a36870c3e5ee",
-                "sha256:8e7ef296b84d44425760fe813cabd7afbb48c8dd62023018b338bbd9d7d6f2f0",
-                "sha256:a2a31ee8a354fa3036d12804730e1e20d58bc4e250365ead34b9c30bbe9908c3",
-                "sha256:a63907332531a499b8cdfd18953febb5a4c525e9e7ca4ac147423b917244b260",
-                "sha256:a8240df4957a5b0e641998a5d78b3c4ea762c845d8cb8997bf820626826fde9a",
-                "sha256:b8806649983a1c78874ec7e04393ef076805740f6319e87a56f91f1767960212",
-                "sha256:c077c9d04a040dba001cf62b3aff08fd85be86bccf2c51a770c77377662a2d55",
-                "sha256:c529ba90c1775697a65b46c83d47a2d3de70f24d96da5d41d05a761c73b063af",
-                "sha256:d537e270b3e6bfaea4f49eaf267984bfb3628c86670e9ad2a257358d3b8f0955",
-                "sha256:d629d750ebe75a88184db98f759633b0a7772c2e6f4da529f0027b4a402c0e2f",
-                "sha256:d9d53518eeed12190744d366ec4a3f39b99d7daa705abca95f87dd8b442df4ad",
-                "sha256:e490f08897cb44e54bddf5c6e27deca9b58c4076849f32aaa7a0b9f1730f2c20",
-                "sha256:f579caecbbca291b0fcc7d473664c8c08635da2f9b1567c22ea32311c86ef68c"
+                "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.11"
+            "version": "==2020.10.15"
         },
         "toml": {
             "hashes": [
diff --git a/tools/release_noter/README.md b/tools/release_noter/README.md
index e3add29..449522b 100644
--- a/tools/release_noter/README.md
+++ b/tools/release_noter/README.md
@@ -2,30 +2,52 @@
 
 ## Setup
 
-The `--deploy` option is to be removed if `Pipfile.lock` is out of date.
-
 ```bash
-pipenv install --dev
-pipenv install --dev --deploy
+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
-pipenv run python release_noter.py -h
+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 -c
+pipenv run python release_noter.py v3.2.3..v3.3.0-rc0 -l
 ```
 
 ## Coding
 
 ```bash
-pipenv run black release_noter.py
-pipenv run flake8 release_noter.py
+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
index 2702764..05fa023 100644
--- a/tools/release_noter/release_noter.py
+++ b/tools/release_noter/release_noter.py
@@ -1,34 +1,62 @@
 #!/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",
+    "avadoc",  # Javadoc &co.
     "avaDoc",
     "ava-doc",
     "baz",  # bazel, bazlet(s)
     "Baz",
+    "circular",
     "class",
-    "efactor",
+    "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",
@@ -37,6 +65,8 @@
     "Rename",
     "Revert",
     "serializ",
+    "Serializ",
+    "server.go",
     "setter",
     "spell",
     "Spell",
@@ -44,25 +74,34 @@
     "Test",
     "thread",
     "tsetse",
-    "typescript",
+    "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$"
-UPDATE_SUBMODULE_PATTERN = r"\* Update ([a-z/\-]+) from branch '.+'"
-SUBMODULE_SUBJECT_PATTERN = r"^- (.+)"
-SUBMODULE_MERGE_PATTERN = r".+Merge .+"
 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_OPTION_PATTERN = r".+\.\.(v.+)"
-RELEASE_TAG_PATTERN = r"v[0-9]+\.[0-9]+\.[0-9]+$"
+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="
-CHECK_DISCLAIMER = "experimental and much slower"
+
+MARKDOWN = "release_noter"
 GIT_COMMAND = "git"
+GIT_PATH = "../.."
+PLUGINS = "plugins/"
 UTF8 = "UTF-8"
 
 
@@ -72,95 +111,97 @@
         formatter_class=argparse.ArgumentDefaultsHelpFormatter,
     )
     parser.add_argument(
-        "-c",
-        "--check",
-        dest="check",
+        "-l",
+        "--link",
+        dest="link",
         required=False,
         default=False,
         action="store_true",
-        help=f"check commits for previous releases; {CHECK_DISCLAIMER}",
+        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 check_args(options):
-    if not options.check:
-        return None
-    release_option = re.search(RELEASE_OPTION_PATTERN, options.range)
-    if release_option is None:
-        print("Check option ignored; range doesn't end with release tag.")
-        return None
-    print(f"Check option used; {CHECK_DISCLAIMER}.")
-    return release_option.group(1)
-
-
-def newly_released(commit_sha1, release):
-    if release is None:
-        return True
-    git_tag = [
+def list_submodules():
+    submodule_names = [
         GIT_COMMAND,
-        "tag",
-        "--contains",
-        commit_sha1,
+        "submodule",
+        "foreach",
+        "--quiet",
+        "echo $name",
     ]
-    process = subprocess.check_output(git_tag, stderr=subprocess.PIPE, encoding=UTF8)
-    verdict = True
-    for line in process.splitlines():
-        line = line.strip()
-        if not re.match(rf"{re.escape(release)}$", line):
-            # Wrongfully pushed or malformed tags ignored.
-            # Preceding release-candidate (-rcN) tags treated as newly released.
-            verdict = not re.match(RELEASE_TAG_PATTERN, line)
-    return verdict
+    return subprocess.check_output(submodule_names, cwd=f"{GIT_PATH}", encoding=UTF8)
 
 
-def open_git_log(options):
+def open_git_log(options, cwd=os.getcwd()):
     git_log = [
         GIT_COMMAND,
         "log",
         "--no-merges",
         options.range,
     ]
-    return subprocess.check_output(git_log, encoding=UTF8)
+    return subprocess.check_output(git_log, cwd=cwd, encoding=UTF8)
 
 
-class Change:
-    subject = None
-    issues = set()
+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
-    capture_submodule = 4
-    capture_submodule_subject = 5
-    finish_submodule_change = 6
-    finish_commit = 7
+    finish_commit = 4
 
 
 class Commit:
     sha1 = None
     subject = None
-    submodule = None
+    component = None
     issues = set()
 
     def reset(self, signature, task):
         if signature is not None:
             self.sha1 = signature.group(1)
             self.subject = None
-            self.submodule = None
+            self.component = None
             self.issues = set()
             return Task.finish_headers
         return task
 
 
-def parse_log(process, release):
+def parse_log(process, gerrit, options, commits, cwd=os.getcwd()):
     commit = Commit()
-    commits = []
-    submodules = dict()
-    submodule_change = None
     task = Task.start_commit
     for line in process.splitlines():
         line = line.strip()
@@ -172,32 +213,8 @@
             if re.match(DATE_HEADER_PATTERN, line):
                 task = Task.capture_subject
         elif task == Task.capture_subject:
-            if re.match(SUBJECT_SUBMODULES_PATTERN, line):
-                task = Task.capture_submodule
-            else:
-                commit.subject = line
-                task = Task.finish_commit
-        elif task == Task.capture_submodule:
-            commit.submodule = re.search(UPDATE_SUBMODULE_PATTERN, line).group(1)
-            if commit.submodule not in submodules:
-                submodules[commit.submodule] = []
-            task = Task.capture_submodule_subject
-        elif task == Task.capture_submodule_subject:
-            submodule_subject = re.search(SUBMODULE_SUBJECT_PATTERN, line)
-            if submodule_subject is not None:
-                if not re.match(SUBMODULE_MERGE_PATTERN, line):
-                    submodule_change = change(submodule_subject, submodules, commit)
-                    task = Task.finish_submodule_change
-            else:
-                task = update_task(line, commit, task)
-        elif task == Task.finish_submodule_change:
-            submodule_issue = re.search(ISSUE_ID_PATTERN, line)
-            if submodule_issue is not None:
-                if submodule_change is not None:
-                    issue_id = submodule_issue.group(1)
-                    submodule_change.issues.add(issue_id)
-            else:
-                task = update_task(line, commit, task)
+            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:
@@ -205,78 +222,142 @@
             else:
                 commit_end = re.match(CHANGE_ID_PATTERN, line)
                 if commit_end is not None:
-                    commit = finish(commit, commits, release)
+                    commit = finish(commit, commits, gerrit, options, cwd)
                     task = Task.start_commit
         else:
             raise RuntimeError("FIXME")
-    return commits, submodules
 
 
-def change(submodule_subject, submodules, commit):
-    submodule_change = Change()
-    submodule_change.subject = submodule_subject.group(1)
-    for exclusion in EXCLUDED_SUBJECTS:
-        if exclusion in submodule_change.subject:
-            return None
-    for noted_change in submodules[commit.submodule]:
-        if noted_change.subject == submodule_change.subject:
-            return noted_change
-    submodule_change.issues = set()
-    submodules[commit.submodule].append(submodule_change)
-    return submodule_change
-
-
-def update_task(line, commit, task):
-    update_end = re.search(COMMIT_SHA1_PATTERN, line)
-    if update_end is not None:
-        task = commit.reset(update_end, task)
-    return task
-
-
-def finish(commit, commits, release):
+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 noted_commit in commits:
-            if noted_commit.subject == commit.subject:
-                return Commit()
-    if newly_released(commit.sha1, release):
-        commits.append(commit)
-    else:
-        print(f"Previously released: commit {commit.sha1}")
+        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):
-    md.write("\n## Core Changes\n")
-    for commit in commits:
-        md.write(f"\n* {commit.subject}\n")
-        for issue in sorted(commit.issues):
-            md.write(f"  [Issue {issue}]({ISSUE_URL}{issue})\n")
+    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_submodules(submodules, md):
-    md.write("\n## Plugin Changes\n")
-    for submodule in sorted(submodules):
-        plugin = re.search(PLUGIN_PATTERN, submodule)
-        md.write(f"\n### {plugin.group(1)}\n")
-        for submodule_change in submodules[submodule]:
-            md.write(f"\n* {submodule_change.subject}\n")
-            for issue in sorted(submodule_change.issues):
-                md.write(f"  [Issue {issue}]({ISSUE_URL}{issue})\n")
+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_notes(commits, submodules):
-    with open("release_noter.md", "w") as md:
-        md.write("# Release Notes\n")
-        print_submodules(submodules, md)
+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()
-    release_tag = check_args(script_options)
+    if script_options.link:
+        print("Link option used; slower.")
+    noted_changes = plugin_changes()
     change_log = open_git_log(script_options)
-    core_changes, submodule_changes = parse_log(change_log, release_tag)
-    print_notes(core_changes, submodule_changes)
+    parse_log(change_log, gerrit_api, script_options, noted_changes)
+    print_notes(noted_changes, script_options)